diff --git a/AGENTS.md b/AGENTS.md index 95ca9f2cc..c49de5376 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -212,6 +212,7 @@ Every instance that has not updated yet runs the code an attacker can read here. - passwords are hashed with **Argon2id** and nothing else. No change may introduce a second scheme, weaken the parameters, or store a password in any reversible form - `crypt.CheckPassword` (`internal/pkg/crypt/validation.go`) is the only gate on a new or changed password, and it refuses anything on the embedded NCSC breached list (`internal/pkg/crypt/passwords/breached.txt`). Every path that accepts a password must call it: registration, reset, change, invitation acceptance, and any future one - every auth-sensitive entry point is behind CAPTCHA (`internal/pkg/captcha/turnstile.go`): login, registration, password reset. The login and registration code-confirmation steps take no second captcha: their signed, single-use session from the start step already proves it passed +- the native app proves itself with App Attest instead (`internal/app/appattest`, verifier in `internal/pkg/appattest`): a key attested against Apple's root once per install, then an assertion over a single-use challenge bound to the action on each of those calls, with an advancing counter and a per-key budget. `verifyHuman` in `internal/app/auth/service.go` is the one gate for both; a new captcha-gated path calls it, never `captcha.Verify` alone. The app never renders Turnstile - TOTP verification records the step it consumed (`user_totp_settings.last_used_step`) and refuses a replay of it. Any new second factor needs equivalent single-use enforcement - **admin routes require a session that verified a second factor.** `middleware.AdminMiddleware` refuses `!session.MFAVerified` with `admin_mfa_required`, and `RequireAdminPermission` then checks the admin bit. Every admin route sits behind both; never add one outside them. The instance-wide Warmbly Cloud link is gated the same way, because it belongs to the instance, not to a workspace - an operation that changes who can get in, or moves money or ownership, requires a fresh authentication (`middleware.RequireFreshAuth`, `POST /v1/auth/reauth`). API-key and OAuth callers pass through, because they present a credential on every call and have no session to refresh @@ -754,7 +755,7 @@ Instead, the codebase uses layered abuse controls and trust signals across auth, ### Auth and signup protection -Authentication flows use Cloudflare Turnstile: +Authentication flows use Cloudflare Turnstile from a browser and App Attest from the native app: - login - registration diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 26485e4b3..068520e39 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -26,6 +26,7 @@ import ( "github.com/warmbly/warmbly/internal/app/admin" "github.com/warmbly/warmbly/internal/app/adminoutreach" "github.com/warmbly/warmbly/internal/app/advanced" + "github.com/warmbly/warmbly/internal/app/appattest" "github.com/warmbly/warmbly/internal/app/unsublink" "github.com/warmbly/warmbly/internal/observability/errs" @@ -212,6 +213,7 @@ func main() { var uniboxService unibox.UniboxService var cipherService cipher.CipherService var passkeyService passkey.Service + var appAttestService *appattest.Service var encryptedKeys encryptedkeys.Store var storageBackendRepo repository.StorageBackendRepository var fleetNodeRepo repository.FleetNodeRepository @@ -954,6 +956,12 @@ func main() { // gate can issue a pending challenge. twofaService = twofa.NewService(repository.NewTOTPRepository(primaryDB.Pool), userRepostory, tokenService, cache, twofa.DeriveKey(authCfg.TwoFASecret)) authService.WireTwoFA(twofaService) + // The native app proves itself with App Attest where a browser solves Turnstile. + appAttestService = appattest.New(repository.NewAppAttestRepository(primaryDB.Pool), cache, appattest.Config{ + AppIDs: authCfg.AppAttestAppIDs, + AllowDevelopment: authCfg.AppAttestAllowDevelopment, + }) + authService.WireAppAttest(appAttestService) // Deployment-level auth policy: whether an emailed code gates a login, // whether signups are open, whether verification is required. Resolved @@ -2338,6 +2346,7 @@ func main() { TokenService: tokenService, PasskeyService: passkeyService, + AppAttestService: appAttestService, UserService: userService, EmailService: emailService, MailboxImportService: mailboxImportService, diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index 9c911b481..c8a79603e 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -516,6 +516,7 @@ With [multiple dashboards sharing a backend](/development/configuration/#google- - `POST /auth/login`, `/auth/login/confirm`, `/auth/register`, `/auth/register/confirm`, `/auth/refresh`, `/auth/reset-password`, `/auth/reset-password/confirm` - `POST /auth/reauth` (re-prove the account holder behind a live session, for the changes that require a recent confirmation) - `GET /auth/config` (public deployment capabilities: which sign-in methods this backend has enabled, whether a login code step follows, whether signups are open, whether the instance still needs claiming) +- `POST /auth/app-attest/challenge`, `/auth/app-attest/keys` (the native app's [App Attest](/api/reference/account-org/#app-attest-native-apps) challenge and one-time key registration, on their own per-IP budget `APP_ATTEST_IP_RATE_LIMIT`) - `GET /auth/instance` (JWT only: the running Warmbly version of a self-hosted instance and whether a newer release exists, for the dashboard's version pill; a hosted deployment answers `self_hosted: false` and nothing else) `POST /auth/register` accepts an optional `invite` field carrying an invitation token. On a deployment running `DISABLE_REGISTRATION=invite_only` it is what permits the signup, and the account is created inside the inviting organization rather than in a new one. The token must resolve to a live invitation whose email equals the submitted address, otherwise the request is refused with `invitation_invalid`. Omitting it on a closed deployment returns `registration_invite_only` or `registration_closed`. See [error codes](/api/error-codes/#registration-and-invitation-refusals). @@ -557,6 +558,8 @@ Alongside them, `api_url` is this API's own public base (for a copyable example `GET /auth/config` also carries `gmail_oauth_connect` (boolean): whether a new Gmail mailbox may be connected with Google sign-in. Both `BOX_GOOGLE_CLIENT_ID` and `BOX_GOOGLE_CLIENT_SECRET` are required. With them set, the capability defaults to `true`; explicit `BOX_GOOGLE_OAUTH_CONNECT=false` or an invalid value disables it. When `false`, offer the app-password route (`POST /emails/onboarding/smtp-imap` against Gmail's servers) rather than start an OAuth round trip that returns `mailbox_gmail_oauth_disabled`. The dashboard additionally requires its deployment-local [frontend opt-in](/development/configuration/#google-sign-in-per-dashboard-deployment) to show Google sign-in. Existing Google sign-in mailboxes are unaffected either way. +`GET /auth/config` also carries `app_attest` (boolean): whether this server accepts an [App Attest proof](/api/reference/account-org/#app-attest-native-apps) from the native app in place of a Turnstile token on `POST /auth/login`, `/auth/register`, `/auth/reset-password` and `/auth/reset-password/confirm`. Read it with `captcha`: no captcha means nothing to send, a captcha with `app_attest` means the app sends a proof, and a captcha without it means email and password sign-in is unavailable in the app. + `GET /auth/config` also carries `billing_enabled` (boolean). It is `false` when the deployment runs with `BILLING_PROVIDER=none`, which is the self-host default: every feature is unlocked server-side, so the dashboard shows the workspace as self-hosted instead of on a free trial and hides the billing and referral pages. `self_hosted` alone does not imply this, because a self-hosted install may still run Stripe. - `POST /auth/setup` (first-run claim: exchanges the one-time token printed at boot for the owner account. Refused once any account exists) - `GET /auth/providers`, `POST /auth/apple`, `POST /auth/google` (native-app social sign-in) diff --git a/docs/content/docs/api/error-codes.mdx b/docs/content/docs/api/error-codes.mdx index 45fcad8a9..3a74a554b 100644 --- a/docs/content/docs/api/error-codes.mdx +++ b/docs/content/docs/api/error-codes.mdx @@ -153,6 +153,27 @@ A password must be 8 to 128 characters. There is no composition rule, but it is } ``` +#### App Attest refusals + +The native app proves itself with [App Attest](/api/reference/account-org/#app-attest-native-apps) in place of the captcha on sign-in, registration and password reset. + +| `code` | Status | Meaning | +|--------|--------|---------| +| `app_attest_failed` | 400 | The proof did not verify: the challenge expired or was already used, the proof came from another app or from a development build the server does not accept, the assertion's counter did not advance, the key id was already registered, or the object was malformed. Fetch a fresh challenge and make a new proof; if it keeps failing, update the app | +| `app_attest_key_unknown` | 400 | The `key_id` was never registered on this server. Register the key again with `POST /auth/app-attest/keys`, then retry the call | +| `app_attest_unavailable` | 404 | `POST /auth/app-attest/challenge` or `/keys` on an instance with App Attest off. When `GET /auth/config` reports a captcha without `app_attest`, email and password sign-in is unavailable in the app; Sign in with Apple, Google and passkeys still work | + +Each registered key may make 30 protected calls per hour. Past that the call answers `400` with code `bad_request` and the message `Too many attempts, please try again later.`, the same answer as the other sign-in attempt budgets. + +```json +{ + "error": "Bad Request", + "message": "We couldn’t verify this copy of the app. Update it from the App Store and try again.", + "code": "app_attest_failed", + "request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e" +} +``` + #### Cloud connection refusals | `code` | Status | Meaning | @@ -618,6 +639,7 @@ Returned when the requested resource doesn't exist. | `code` | Meaning | |--------|---------| | `unknown_view` | `/me/views/:view` was given a view name other than `contacts`, `campaign_leads` or `unibox_rail` | +| `app_attest_unavailable` | `POST /auth/app-attest/challenge` or `/keys` on an instance with App Attest off. See [App Attest refusals](#app-attest-refusals) | | `lead_cc_contact_not_found` | [Set a lead's CC](/api/reference/campaigns/#set-a-leads-cc) named a contact that is not in the workspace | | `slack_not_connected` | A [Slack](/guides/slack/) route was called for a workspace that has not connected Slack, or whose connection was removed. Connect it under **Integrations > Slack** | | `slack_link_invalid` | The Slack link code is unknown, expired or already used. Mention or message the bot in Slack for a new link button | diff --git a/docs/content/docs/api/reference/account-org.mdx b/docs/content/docs/api/reference/account-org.mdx index c6bd43de0..5fd815a0c 100644 --- a/docs/content/docs/api/reference/account-org.mdx +++ b/docs/content/docs/api/reference/account-org.mdx @@ -11,7 +11,7 @@ All error responses use the shared envelope (`error`, `message`, `code`, `reques ## Authentication -These endpoints are public (no session required). Login and registration are two-step: the first call sends a one-time code by email and returns a short-lived signed `session` token, the second call confirms the code and mints the real token pair. Captcha (Cloudflare Turnstile) is required on the start calls. +These endpoints are public (no session required). Login and registration are two-step: the first call sends a one-time code by email and returns a short-lived signed `session` token, the second call confirms the code and mints the real token pair. When the deployment runs a captcha, the start calls and both password reset calls require one: a Cloudflare Turnstile token from a browser, or an [App Attest proof](#app-attest-native-apps) from the native app. ### Start login @@ -27,7 +27,8 @@ Auth: public (not available to API keys). |-------|------|----------|-------------| | `email` | string | yes | Account email. | | `password` | string | yes | Account password. | -| `turnstile` | string | yes | Cloudflare Turnstile token. | +| `turnstile` | string | yes, unless `app_attest` is sent | Cloudflare Turnstile token. | +| `app_attest` | object | no | The native app's [App Attest proof](#app-attest-native-apps), sent instead of `turnstile`. | ```json { @@ -101,7 +102,7 @@ Auth: public (not available to API keys). #### Request body -Same shape as `/auth/login`: `email`, `password`, `turnstile`. +Same shape as `/auth/login`: `email`, `password`, and `turnstile` or `app_attest`. ```json { @@ -183,7 +184,8 @@ Auth: public (not available to API keys). | Field | Type | Required | Description | |-------|------|----------|-------------| | `email` | string | yes | Account email. | -| `turnstile` | string | yes | Cloudflare Turnstile token. | +| `turnstile` | string | yes, unless `app_attest` is sent | Cloudflare Turnstile token. | +| `app_attest` | object | no | The native app's [App Attest proof](#app-attest-native-apps), sent instead of `turnstile`. | #### Response @@ -203,7 +205,8 @@ Auth: public (not available to API keys). |-------|------|----------|-------------| | `session` | string | yes | The reset session token tied to the email. | | `password` | string | yes | The new password. | -| `turnstile` | string | yes | Cloudflare Turnstile token. | +| `turnstile` | string | yes, unless `app_attest` is sent | Cloudflare Turnstile token. | +| `app_attest` | object | no | The native app's [App Attest proof](#app-attest-native-apps), sent instead of `turnstile`. | #### Response @@ -278,6 +281,51 @@ All three are public and unavailable to API keys. A provider-verified identity c Apple and Google both return a token pair (same shape as the login confirm success response), or a two-factor challenge when the account has TOTP enrolled. +### App Attest (native apps) + +The iOS app proves it is a genuine copy of the app with Apple App Attest, and that proof stands in for the captcha on the captcha-gated calls. Browsers keep sending a Turnstile token. Both endpoints below are public, unavailable to API keys, and share their own per-IP budget (`APP_ATTEST_IP_RATE_LIMIT`, 300 per 15 minutes by default) separate from the one on the rest of `/auth`. When the instance has App Attest off they answer `404` with code [`app_attest_unavailable`](/api/error-codes/#app-attest-refusals). `GET /auth/config` reports whether it is on in `app_attest`. + +| Endpoint | Body | Purpose | +|----------|------|---------| +| `POST /auth/app-attest/challenge` | none | Returns `{"challenge": "..."}`, a base64url string that is single-use and expires after five minutes. Every attestation and every assertion is made over a fresh one. | +| `POST /auth/app-attest/keys` | `key_id`, `attestation`, `challenge` (all required) | Registers the install's App Attest key, once per install. `key_id` is the base64 of the 32-byte key identifier and `attestation` the base64 object from `DCAppAttestService.attestKey`, made over a client data hash of SHA-256 of the UTF-8 bytes of `challenge`. Answers `204`. | + +The server verifies the attestation's certificate chain against Apple's App Attest root, the nonce, the app id, a counter of 0, the environment (a development-signed build is refused unless the instance allows them) and the key id, then stores the key. A key id is registered once; a second registration of the same id is refused with `app_attest_failed`. + +A captcha-gated call (`POST /auth/login`, `/auth/register`, `/auth/reset-password`, `/auth/reset-password/confirm`) then carries an `app_attest` object in place of `turnstile`: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `key_id` | string | yes | The registered key identifier, base64. | +| `assertion` | string | yes | The base64 assertion from `DCAppAttestService.generateAssertion`. | +| `challenge` | string | yes | A fresh challenge from `POST /auth/app-attest/challenge`. | + +```json +{ + "email": "alex@acme.com", + "password": "correct horse battery staple", + "app_attest": { + "key_id": "q1Zf3...=", + "assertion": "omlzaWduYXR1cmVYRzBF...", + "challenge": "b3Vy1Wq0..." + } +} +``` + +The assertion is made over a client data hash of SHA-256 of this UTF-8 string, with a newline between the parts: + +```text +warmbly-app-attest/v1 + + +``` + +`` names the call: `login`, `register`, `reset_password` or `reset_password_confirm`. A proof made for one call does not verify on another. Each challenge is spent by the first call that presents it, and the assertion's counter must be higher than the last one the key presented. Each registered key also has its own budget of 30 protected calls per hour; past it the call answers `400` with the message `Too many attempts, please try again later.`, the same answer as the other sign-in attempt budgets. + +A proof that does not verify answers [`app_attest_failed`](/api/error-codes/#app-attest-refusals). A key id this server has never registered answers `app_attest_key_unknown`: register the key again with a fresh challenge, then retry the call. When the deployment runs no captcha, neither `turnstile` nor `app_attest` is checked. + +`GET /auth/config` carries `captcha` and `app_attest` together, and the app reads them as one decision: no captcha means nothing to send, a captcha with `app_attest` means the app sends a proof, and a captcha without `app_attest` means email and password sign-in is unavailable in the app. Sign in with Apple, Google and passkeys never need a captcha. The iOS simulator cannot use App Attest, so local development keeps using `TURNSTILE_BYPASS_TOKEN`, which is honoured only when `APP_ENV=dev`. + ### Browser sign-in A browser cannot authenticate with the provider itself, so it is sent there and comes back with an authorization code. Sign in with Google, Sign in with Apple and generic OpenID Connect are the same three steps against different providers, and which ones a deployment offers is the `providers` array in `GET /auth/config`. diff --git a/docs/content/docs/api/reference/contacts.mdx b/docs/content/docs/api/reference/contacts.mdx index 56d40c857..df774a9d0 100644 --- a/docs/content/docs/api/reference/contacts.mdx +++ b/docs/content/docs/api/reference/contacts.mdx @@ -82,6 +82,16 @@ Returns a `data` array of contacts plus a `pagination` envelope. Paginate by pas "is_catch_all": false, "mail_host": "gmail", "esp_provider": "gmail", + "outreach": { + "sent": 3, + "opened": 2, + "clicked": 0, + "replied": 1, + "bounced": 0, + "last_sent_at": "2026-06-08T09:12:00Z", + "last_opened_at": "2026-06-08T10:40:00Z", + "last_replied_at": "2026-06-09T07:05:00Z" + }, "updated_at": "2026-06-10T12:00:00Z", "created_at": "2026-05-01T09:30:00Z" } @@ -140,6 +150,10 @@ On the first page (no `cursor`), the response includes a `counts` object with or } ``` +### Outreach + +Every contact carries `outreach`, its engagement across every campaign: how many emails reached it (`sent`), and how many of those it `opened`, `clicked`, `replied` to or `bounced`, with the time each last happened (`last_sent_at`, `last_opened_at`, `last_clicked_at`, `last_replied_at`, `last_bounced_at`, absent until the first one). These are the same counts the [contact 360](#get-a-contact) reports under `engagement`. Opens count a person only; a mail client fetching the message automatically is not counted. + ## Create contacts `POST /contacts` diff --git a/docs/content/docs/development/configuration.mdx b/docs/content/docs/development/configuration.mdx index bf22c0a06..315bea424 100644 --- a/docs/content/docs/development/configuration.mdx +++ b/docs/content/docs/development/configuration.mdx @@ -148,6 +148,7 @@ TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12 | `AUTH_IP_RATE_LIMIT` | Unauthenticated auth requests allowed per source IP per 15 minutes | `60` | yes | | `CLI_AUTH_IP_RATE_LIMIT` | CLI sign-in handshake requests allowed per source IP per 15 minutes. Its own budget, because one `warmbly auth login` polls around 200 times and must not exhaust the allowance above | `500` | yes | | `PASSKEY_IP_RATE_LIMIT` | Passkey login challenges allowed per source IP per 15 minutes. Its own budget, because the sign-in page asks for a challenge on load rather than when someone acts, so opening that page must not spend the allowance above. Finishing the ceremony is an authentication attempt and counts against `AUTH_IP_RATE_LIMIT` | `300` | yes | +| `APP_ATTEST_IP_RATE_LIMIT` | Native app App Attest challenges and key registrations allowed per source IP per 15 minutes. Its own budget, because the app asks for a challenge before every protected call; the call the challenge is spent on counts against `AUTH_IP_RATE_LIMIT` | `300` | yes | | `PUBLIC_IP_RATE_LIMIT` | Requests allowed per source IP per 15 minutes on the remaining public routes: unsubscribe, invitation preview, fleet enrolment and the analytics proxy. Each carries its authorization in a high-entropy token, so this bounds unmetered writes and relayed bandwidth rather than guessing. Counts reads as well as writes, unlike the auth limiter | `600` | yes | | `WARMBLY_BOOTSTRAP_EMAIL` | First owner's address, read only while the users table is empty | unset | yes | | `WARMBLY_BOOTSTRAP_PASSWORD_HASH` | Argon2id PHC string for that owner, as `warmblyctl hash-password` prints it. Preferred over the plaintext form. A malformed hash, or one whose cost parameters are below `m=65536,t=3,p=2` with a 16-byte salt and 32-byte key, is refused and no owner is created | unset | yes | @@ -202,12 +203,16 @@ The proxy forwards only what PostHog needs to read the request. Cookies and `Aut | `WARMBLY_CONFIG_OUT` | Where the dashboard and admin panel write their runtime `config.js`. Defaults to the path nginx serves; a static host sets it to the build output so the same script renders the file at build time instead of container start | the nginx path | yes (build or container start) | | `WARMBLY_CSP_CONNECT_ORIGINS` | Additional dashboard CSP connection origins, separated by spaces. Realtime is discovered automatically from the backend; use this as an offline fallback or for other connection destinations. Use exact HTTP(S) or WS(S) URLs, not wildcard hosts or CSP directives. `VITE_CSP_CONNECT_ORIGINS` supplies the build-time/development fallback | unset | yes (build or container start) | | `TURNSTILE_BYPASS_TOKEN` | A token that skips verification. Only honoured when `APP_ENV=dev` | unset | yes | +| `APP_ATTEST_APP_IDS` | Comma-separated `.` App IDs whose App Attest proofs the backend accepts in place of a captcha. Unset with no `APPLE_TEAM_ID`, App Attest is off | `APPLE_TEAM_ID` + `.` + `APPLE_IOS_BUNDLE_ID` when `APPLE_TEAM_ID` is set | yes | +| `APP_ATTEST_ALLOW_DEVELOPMENT` | Accept App Attest proofs from development-signed builds (an Xcode install on a device). Leave it off in production | `false` | yes | | `TURNSTILE_SITE_KEY` | The public Turnstile widget key. The backend hands it to the forms service, which renders it on hosted form pages that enable spam protection. The frontends carry their own copy in `WARMBLY_TURNSTILE_KEY` | unset | no (read per request) | `CAPTCHA_PROVIDER` has no constant default. Unset, it resolves to `turnstile` when `TURNSTILE_SECRET` holds a value, whether from the environment or from AWS Secrets Manager, and to `none` when it does not, so configuring the secret is what turns captcha on and clearing it is what turns captcha off. The panel reports the resolved value with source `derived`. Setting `CAPTCHA_PROVIDER=turnstile` explicitly while `TURNSTILE_SECRET` is empty is the one combination that breaks: every verification fails, which means nobody can sign in. Set the secret or set the provider back to `none`. +The native iOS app never renders Turnstile. It proves it is a genuine copy of the app with Apple [App Attest](/api/reference/account-org/#app-attest-native-apps), which stands in for the captcha on sign-in, registration and password reset. With `CAPTCHA_PROVIDER=none` the app sends nothing. With Turnstile on, the app offers email and password sign-in only when `APP_ATTEST_APP_IDS` names its App ID; otherwise it offers Sign in with Apple, Google and passkeys, which never need a captcha. A self-hosted instance that runs Turnstile and wants the iOS app to sign in with a password sets `APP_ATTEST_APP_IDS` to the iOS app's App ID. Verification needs no Apple account and makes no outbound call: it checks against Apple's App Attest root certificate, which ships in the binary. Each install's key is stored in the `app_attest_keys` table, which belongs to the instance and is not part of a [workspace export](/guides/workspace-export-import/). The iOS simulator cannot use App Attest, so local development keeps using `TURNSTILE_BYPASS_TOKEN`. + ## Platform mail Platform mail is the product's own outbound: registration codes, password resets, team invitations, notification digests and login codes where those are enabled. It is separate from campaign mail, which leaves through the mailboxes you connect. diff --git a/docs/content/docs/development/data-control.mdx b/docs/content/docs/development/data-control.mdx index ae5762100..0d87fbdcc 100644 --- a/docs/content/docs/development/data-control.mdx +++ b/docs/content/docs/development/data-control.mdx @@ -126,6 +126,10 @@ Mail whose warmup verification is temporarily unavailable waits in the `unibox_p A [tracking comparison](/guides/placement-tests/#tracking) renders each seed's copy once and sends it from both halves, so while it runs it keeps that rendered copy, with merge fields and AI blocks filled in for the chosen contact, in the `placement_renders` Postgres table, sealed with the workspace's data key. The placement poller deletes it once neither half of the comparison is running. It is included in instance backups and excluded from workspace exports. +### Native app keys + +The iOS app proves it is a genuine copy with [App Attest](/api/reference/account-org/#app-attest-native-apps), and each install registers one key in the `app_attest_keys` Postgres table: the key id, its public key, the app ID and environment it was attested under, its counter, Apple's receipt, and when it was created and last used. A row names no user and no workspace. It has no retention window, is included in instance backups, and does not travel in workspace exports, because it belongs to the instance the app verified against. + ### Warmup reconnect recovery `warmup_recovery_identifiers` holds SHA-256 fingerprints of a mailbox address and exact warmup message IDs or verification tokens, scoped to a workspace. They survive removing a mailbox so adding the same address again can recognize recent warmup mail without retaining its content or credentials. Each expires 90 days after its source record, expired identifiers are swept every minute in bounded batches, and deleting the workspace removes them immediately. They travel in the Warmup group of a [workspace export](/guides/workspace-export-import/). diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 8c378b0b9..9c9c0fe22 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -775,6 +775,133 @@ } } }, + "/auth/app-attest/challenge": { + "post": { + "operationId": "auth_app_attest_challenge", + "summary": "Mint an App Attest challenge", + "description": "Returns a single-use challenge, valid for five minutes, for the native iOS app to make an App Attest attestation or assertion over. Has its own per-IP budget (APP_ATTEST_IP_RATE_LIMIT).", + "tags": [ + "auth" + ], + "security": [], + "responses": { + "200": { + "description": "A fresh challenge.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "challenge" + ], + "properties": { + "challenge": { + "type": "string", + "description": "Base64url challenge. Single-use; expires after five minutes." + } + } + } + } + } + }, + "404": { + "description": "App Attest is off on this instance (app_attest_unavailable).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/app-attest/keys": { + "post": { + "operationId": "auth_app_attest_register_key", + "summary": "Register an App Attest key", + "description": "Registers the native iOS app install's App Attest key, once per install. The attestation must be made over a client data hash of SHA-256 of the UTF-8 bytes of the challenge. The server verifies the certificate chain against Apple's App Attest root, the nonce, the app id, a counter of 0, the environment and the key id. A key id can be registered once.", + "tags": [ + "auth" + ], + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "key_id", + "attestation", + "challenge" + ], + "properties": { + "key_id": { + "type": "string", + "description": "Base64 of the 32-byte App Attest key identifier." + }, + "attestation": { + "type": "string", + "description": "Base64 attestation object from DCAppAttestService.attestKey." + }, + "challenge": { + "type": "string", + "description": "A challenge from /auth/app-attest/challenge." + } + } + } + } + } + }, + "responses": { + "204": { + "description": "Key registered." + }, + "400": { + "description": "The attestation did not verify, or the key id is already registered (app_attest_failed).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "App Attest is off on this instance (app_attest_unavailable).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/auth/logout": { "post": { "operationId": "auth_logout", @@ -27847,9 +27974,32 @@ } } }, + "AppAttestProof": { + "type": "object", + "description": "The native iOS app's Apple App Attest proof, sent instead of a Turnstile token on the captcha-gated auth calls. The assertion is made over a client data hash of SHA-256 of the UTF-8 string \"warmbly-app-attest/v1\\n\" + action + \"\\n\" + challenge, where action is login, register, reset_password or reset_password_confirm.", + "required": [ + "key_id", + "assertion", + "challenge" + ], + "properties": { + "key_id": { + "type": "string", + "description": "The registered App Attest key identifier, base64." + }, + "assertion": { + "type": "string", + "description": "The base64 assertion from DCAppAttestService.generateAssertion." + }, + "challenge": { + "type": "string", + "description": "A fresh, single-use challenge from /auth/app-attest/challenge." + } + } + }, "AuthCredentials": { "type": "object", - "description": "Email/password credentials with a Cloudflare Turnstile token. Used for login start and registration start.", + "description": "Email/password credentials with a Cloudflare Turnstile token or an App Attest proof. Used for login start and registration start.", "required": [ "email", "password" @@ -27867,7 +28017,10 @@ }, "turnstile": { "type": "string", - "description": "Cloudflare Turnstile challenge token." + "description": "Cloudflare Turnstile challenge token. Required when the deployment runs a captcha, unless app_attest is sent." + }, + "app_attest": { + "$ref": "#/components/schemas/AppAttestProof" } } }, @@ -28014,7 +28167,10 @@ }, "turnstile": { "type": "string", - "description": "Cloudflare Turnstile challenge token." + "description": "Cloudflare Turnstile challenge token. Required when the deployment runs a captcha, unless app_attest is sent." + }, + "app_attest": { + "$ref": "#/components/schemas/AppAttestProof" } } }, @@ -28037,7 +28193,10 @@ }, "turnstile": { "type": "string", - "description": "Cloudflare Turnstile challenge token." + "description": "Cloudflare Turnstile challenge token. Required when the deployment runs a captcha, unless app_attest is sent." + }, + "app_attest": { + "$ref": "#/components/schemas/AppAttestProof" } } }, diff --git a/go.mod b/go.mod index 93b29bba8..83b92edb5 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/emersion/go-message v0.18.1 github.com/emersion/go-msgauth v0.7.0 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 + github.com/fxamacker/cbor/v2 v2.9.2 github.com/getsentry/sentry-go v0.36.2 github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.10.1 @@ -97,7 +98,6 @@ require ( github.com/cloudwego/base64x v0.1.5 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect diff --git a/internal/api/handler/auth_app_attest.go b/internal/api/handler/auth_app_attest.go new file mode 100644 index 000000000..121c224c2 --- /dev/null +++ b/internal/api/handler/auth_app_attest.go @@ -0,0 +1,49 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/warmbly/warmbly/internal/errx" +) + +var errAppAttestOff = errx.NewWithIdentifier(errx.NotFound, "app_attest_unavailable", "This server does not accept app verification.") + +// AppAttestChallenge mints the single-use challenge an attestation or assertion is made over. +func (h *Handler) AppAttestChallenge(c *gin.Context) { + if !h.AppAttestService.Enabled() { + errx.Handle(c, errAppAttestOff) + return + } + challenge, xerr := h.AppAttestService.Challenge(c.Request.Context()) + if xerr != nil { + errx.Handle(c, xerr) + return + } + c.JSON(http.StatusOK, gin.H{"challenge": challenge}) +} + +type appAttestKeyRequest struct { + KeyID string `json:"key_id"` + Attestation string `json:"attestation"` + Challenge string `json:"challenge"` +} + +// AppAttestRegisterKey stores an install's key once Apple's attestation of it verifies. +func (h *Handler) AppAttestRegisterKey(c *gin.Context) { + if !h.AppAttestService.Enabled() { + errx.Handle(c, errAppAttestOff) + return + } + var req appAttestKeyRequest + if err := c.ShouldBindJSON(&req); err != nil { + errx.Handle(c, errx.InvalidBody(err)) + return + } + if xerr := h.AppAttestService.RegisterKey(c.Request.Context(), req.KeyID, req.Attestation, req.Challenge); xerr != nil { + errx.Handle(c, xerr) + return + } + c.Status(http.StatusNoContent) +} diff --git a/internal/api/handler/auth_config.go b/internal/api/handler/auth_config.go index ca6d54e88..cc7475bcf 100644 --- a/internal/api/handler/auth_config.go +++ b/internal/api/handler/auth_config.go @@ -19,6 +19,10 @@ type DeploymentAuthConfig struct { // install cannot reach challenges.cloudflare.com. Captcha bool `json:"captcha"` + // AppAttest reports whether the native app may prove itself with App Attest + // instead of the captcha. The app never renders Turnstile. + AppAttest bool `json:"app_attest"` + // PasswordLogin is false when the deployment authenticates only through // OIDC or passkeys. PasswordLogin bool `json:"password_login"` @@ -132,6 +136,7 @@ func (h *Handler) AuthConfig(c *gin.Context) { c.JSON(http.StatusOK, DeploymentAuthConfig{ Captcha: config.CaptchaProvider() != "none", + AppAttest: h.AppAttestService.Enabled(), PasswordLogin: !policy.DisablePasswordLogin, LoginCode: policy.LoginCode, Registration: registration, diff --git a/internal/api/handler/handler.go b/internal/api/handler/handler.go index 60999fe88..d57721bf3 100644 --- a/internal/api/handler/handler.go +++ b/internal/api/handler/handler.go @@ -10,6 +10,7 @@ import ( "github.com/warmbly/warmbly/internal/app/aitools" "github.com/warmbly/warmbly/internal/app/analytics" "github.com/warmbly/warmbly/internal/app/apikey" + "github.com/warmbly/warmbly/internal/app/appattest" "github.com/warmbly/warmbly/internal/app/appdirectory" "github.com/warmbly/warmbly/internal/app/audit" "github.com/warmbly/warmbly/internal/app/auth" @@ -102,6 +103,8 @@ type Handler struct { AuthService auth.AuthService TokenService token.TokenService PasskeyService passkey.Service + // AppAttestService registers native app keys. Nil when no app id is configured. + AppAttestService *appattest.Service // Native-app social sign-in discovery (GET /auth/providers). ExternalAuthProviders models.ExternalAuthProviders diff --git a/internal/api/middleware/ratelimit_ip.go b/internal/api/middleware/ratelimit_ip.go index 261fa4e33..413408566 100644 --- a/internal/api/middleware/ratelimit_ip.go +++ b/internal/api/middleware/ratelimit_ip.go @@ -42,6 +42,12 @@ const ( passkeyIPWindow = 15 * time.Minute passkeyIPDefaultLimit = 300 + // The native app's challenge and key registration are page furniture in the + // same way, so they get a budget of their own; the request a challenge is + // spent on stays on /auth. + appAttestIPWindow = 15 * time.Minute + appAttestIPDefaultLimit = 300 + // The remaining public routes (unsubscribe, invitation preview, fleet // enrolment, the analytics proxy) carry their authorization in a // high-entropy token rather than a session, so this budget is not what @@ -80,6 +86,13 @@ func (h *Handler) PasskeyChallengeIPRateLimitMiddleware() gin.HandlerFunc { "Too many passkey sign-in requests from this address. Try again later.", false) } +// AppAttestIPRateLimitMiddleware throttles the native app's challenge and key +// registration per source IP, on a key of its own. +func (h *Handler) AppAttestIPRateLimitMiddleware() gin.HandlerFunc { + return h.ipRateLimiter("app_attest_ip:", appAttestIPDefaultLimit, "APP_ATTEST_IP_RATE_LIMIT", appAttestIPWindow, + "Too many app verification requests from this address. Try again later.", false) +} + // AuthIPRateLimitMiddleware throttles the public /auth group per source IP. // // This is the only limiter those routes have: RateLimitMiddleware keys on the diff --git a/internal/api/routes.go b/internal/api/routes.go index ad510d489..251727fbc 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -361,6 +361,15 @@ func Run( // has typed. It gets its own per-IP budget so opening that page repeatedly // cannot spend the allowance password sign-in, registration and reset all // draw on. Finishing the ceremony is an attempt and stays on /auth below. + // The native app's App Attest handshake: a single-use challenge, and the + // once-per-install key registration that Apple's attestation authorises. + appAttestPublic := v1.Group("/auth/app-attest") + appAttestPublic.Use(m.AppAttestIPRateLimitMiddleware()) + { + appAttestPublic.POST("/challenge", h.AppAttestChallenge) + appAttestPublic.POST("/keys", h.AppAttestRegisterKey) + } + passkeyPublic := v1.Group("/auth/passkey") passkeyPublic.Use(m.PasskeyChallengeIPRateLimitMiddleware()) { diff --git a/internal/app/appattest/service.go b/internal/app/appattest/service.go new file mode 100644 index 000000000..811d85a75 --- /dev/null +++ b/internal/app/appattest/service.go @@ -0,0 +1,204 @@ +// Package appattest is the native app's anti-automation check: a key attested +// by Apple once per install, then an assertion over a single-use challenge on +// every unauthenticated auth write, in place of the browser's Turnstile. +package appattest + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "slices" + "time" + + "github.com/rs/zerolog/log" + + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/infrastructure/cache" + "github.com/warmbly/warmbly/internal/observability/errs" + attest "github.com/warmbly/warmbly/internal/pkg/appattest" + "github.com/warmbly/warmbly/internal/repository" +) + +const ( + challengeTTL = 5 * time.Minute + challengePrefix = "app_attest:challenge:" + // keyBudget bounds what one install can trigger, emails included. + keyBudget = 30 + keyBudgetWindow = time.Hour + keyBudgetPrefix = "app_attest:key:" + clientDataTag = "warmbly-app-attest/v1" +) + +// Actions name the request an assertion was made for, so one cannot be spent on another. +const ( + ActionLogin = "login" + ActionRegister = "register" + ActionResetPassword = "reset_password" + ActionResetPasswordConfirm = "reset_password_confirm" +) + +// Proof is what the app sends with a protected request. +type Proof struct { + KeyID string `json:"key_id"` + Assertion string `json:"assertion"` + Challenge string `json:"challenge"` +} + +// Config is what this instance accepts. +type Config struct { + AppIDs []string + AllowDevelopment bool +} + +type Service struct { + repo repository.AppAttestRepository + cache *cache.Cache + policy attest.Policy + now func() time.Time +} + +// New returns nil when no app id is configured, which turns the check off. +func New(repo repository.AppAttestRepository, c *cache.Cache, cfg Config) *Service { + if repo == nil || c == nil || len(cfg.AppIDs) == 0 { + return nil + } + return &Service{ + repo: repo, + cache: c, + policy: attest.Policy{AppIDs: cfg.AppIDs, AllowDevelopment: cfg.AllowDevelopment}, + now: time.Now, + } +} + +// Enabled reports whether native app proofs are accepted here. +func (s *Service) Enabled() bool { return s != nil } + +// ClientData is the exact byte string the app asserts over. +func ClientData(action, challenge string) []byte { + return []byte(clientDataTag + "\n" + action + "\n" + challenge) +} + +// Challenge mints a single-use challenge. +func (s *Service) Challenge(ctx context.Context) (string, *errx.Error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + errs.CaptureException(err) + return "", errx.InternalError() + } + challenge := base64.RawURLEncoding.EncodeToString(buf) + if err := s.cache.SetEx(ctx, challengePrefix+challenge, 1, challengeTTL).Err(); err != nil { + errs.CaptureException(err) + return "", errx.InternalError() + } + return challenge, nil +} + +// consume deletes the challenge and reports whether this call was the one that did. +func (s *Service) consume(ctx context.Context, challenge string) (bool, *errx.Error) { + if challenge == "" || len(challenge) > 128 { + return false, nil + } + n, err := s.cache.Del(ctx, challengePrefix+challenge).Result() + if err != nil { + errs.CaptureException(err) + return false, errx.InternalError() + } + return n == 1, nil +} + +// RegisterKey verifies a key attestation made over SHA-256 of the challenge and stores the key. +func (s *Service) RegisterKey(ctx context.Context, keyID, attestation, challenge string) *errx.Error { + ok, xerr := s.consume(ctx, challenge) + if xerr != nil { + return xerr + } + if !ok { + return errx.ErrAppAttest + } + rawKeyID, err := base64.StdEncoding.DecodeString(keyID) + if err != nil || len(rawKeyID) != sha256.Size { + return errx.ErrAppAttest + } + object, err := base64.StdEncoding.DecodeString(attestation) + if err != nil { + return errx.ErrAppAttest + } + clientDataHash := sha256.Sum256([]byte(challenge)) + key, err := attest.VerifyAttestation(object, rawKeyID, clientDataHash[:], s.policy, s.now()) + if err != nil { + log.Info().Err(err).Msg("app attest: attestation refused") + return errx.ErrAppAttest + } + inserted, err := s.repo.Insert(ctx, repository.AppAttestKey{ + KeyID: base64.StdEncoding.EncodeToString(key.ID), + PublicKey: key.PublicKey, + AppID: key.AppID, + Environment: string(key.Environment), + Receipt: key.Receipt, + }) + if err != nil { + errs.CaptureException(err) + return errx.InternalError() + } + // A key is attested once; a second attestation of the same id is a replay. + if !inserted { + return errx.ErrAppAttest + } + return nil +} + +// Verify checks an assertion made for action over a fresh challenge. +func (s *Service) Verify(ctx context.Context, action string, proof *Proof) *errx.Error { + if proof == nil || proof.KeyID == "" || proof.Assertion == "" { + return errx.ErrAppAttest + } + ok, xerr := s.consume(ctx, proof.Challenge) + if xerr != nil { + return xerr + } + if !ok { + return errx.ErrAppAttest + } + key, err := s.repo.Get(ctx, proof.KeyID) + if err != nil { + errs.CaptureException(err) + return errx.InternalError() + } + if key == nil { + return errx.ErrAppAttestUnknown + } + // Fails open on a cache error, like every other auth budget. + if within, err := s.cache.ReserveAttempt(ctx, keyBudgetPrefix+key.KeyID, keyBudget, keyBudgetWindow); err != nil { + errs.CaptureException(err) + } else if !within { + return errx.ErrAuthLimit + } + object, err := base64.StdEncoding.DecodeString(proof.Assertion) + if err != nil { + return errx.ErrAppAttest + } + // A key outlives the policy it was attested under; it answers to the current one. + if (key.Environment == string(attest.Development) && !s.policy.AllowDevelopment) || !slices.Contains(s.policy.AppIDs, key.AppID) { + return errx.ErrAppAttest + } + counter, err := attest.VerifyAssertion(object, ClientData(action, proof.Challenge), key.PublicKey, key.AppID, key.Counter, s.policy.AllowDevelopment) + if err != nil { + if errors.Is(err, attest.ErrInvalid) { + log.Info().Err(err).Msg("app attest: assertion refused") + return errx.ErrAppAttest + } + errs.CaptureException(err) + return errx.InternalError() + } + advanced, err := s.repo.AdvanceCounter(ctx, key.KeyID, counter) + if err != nil { + errs.CaptureException(err) + return errx.InternalError() + } + if !advanced { + return errx.ErrAppAttest + } + return nil +} diff --git a/internal/app/appattest/service_test.go b/internal/app/appattest/service_test.go new file mode 100644 index 000000000..1ccdafe36 --- /dev/null +++ b/internal/app/appattest/service_test.go @@ -0,0 +1,22 @@ +package appattest + +import ( + "testing" +) + +// The iOS app builds the same bytes; changing them breaks every shipped install. +func TestClientDataIsTheWireContract(t *testing.T) { + if got := string(ClientData(ActionLogin, "abc")); got != "warmbly-app-attest/v1\nlogin\nabc" { + t.Fatalf("client data = %q", got) + } +} + +func TestOffWithoutAppIDs(t *testing.T) { + s := New(nil, nil, Config{}) + if s.Enabled() { + t.Fatal("enabled with nothing configured") + } + if New(nil, nil, Config{AppIDs: []string{"TEAM.com.example"}}).Enabled() { + t.Fatal("enabled without storage") + } +} diff --git a/internal/app/auth/login.go b/internal/app/auth/login.go index 57527c2b3..3567bd23e 100644 --- a/internal/app/auth/login.go +++ b/internal/app/auth/login.go @@ -3,6 +3,7 @@ package auth import ( "context" "errors" + "github.com/warmbly/warmbly/internal/app/appattest" "github.com/warmbly/warmbly/internal/app/authrisk" "time" @@ -24,7 +25,7 @@ func (s *authService) LoginStart(ctx context.Context, data *AuthData, ipaddr, us // A failed challenge is the caller's 400, not an incident: reporting it // filed an issue for every bot and every reloaded sign-in page. - if xerr := s.captcha.Verify(ctx, data.Turnstile, ipaddr); xerr != nil { + if xerr := s.verifyHuman(ctx, appattest.ActionLogin, data.Turnstile, data.AppAttest, ipaddr); xerr != nil { return nil, xerr } diff --git a/internal/app/auth/model.go b/internal/app/auth/model.go index 62790bb64..c822cb9a8 100644 --- a/internal/app/auth/model.go +++ b/internal/app/auth/model.go @@ -1,11 +1,16 @@ package auth -import "github.com/warmbly/warmbly/internal/models" +import ( + "github.com/warmbly/warmbly/internal/app/appattest" + "github.com/warmbly/warmbly/internal/models" +) type AuthData struct { Email string `json:"email"` Password string `json:"password"` Turnstile string `json:"turnstile"` + // AppAttest is the native app's proof, sent instead of Turnstile. + AppAttest *appattest.Proof `json:"app_attest,omitempty"` // ReferralCode is the optional ?ref= code the signup arrived with. Carried // to RegistrationConfirm so the new org can be attributed to its referrer. ReferralCode string `json:"referral_code"` @@ -31,14 +36,16 @@ type ConfirmData struct { } type ResetPasswordStart struct { - Email string `json:"email"` - Turnstile string `json:"turnstile"` + Email string `json:"email"` + Turnstile string `json:"turnstile"` + AppAttest *appattest.Proof `json:"app_attest,omitempty"` } type ResetPasswordConfirm struct { - Session string `json:"session"` - Password string `json:"password"` - Turnstile string `json:"turnstile"` + Session string `json:"session"` + Password string `json:"password"` + Turnstile string `json:"turnstile"` + AppAttest *appattest.Proof `json:"app_attest,omitempty"` } type ChangePassword struct { diff --git a/internal/app/auth/registration.go b/internal/app/auth/registration.go index cd9b45488..2b8a91c96 100644 --- a/internal/app/auth/registration.go +++ b/internal/app/auth/registration.go @@ -5,6 +5,7 @@ import ( "time" "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/app/appattest" "github.com/warmbly/warmbly/internal/app/token" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" @@ -29,7 +30,7 @@ func (s *authService) RegistrationStart(ctx context.Context, data *AuthData, ori } // The caller's 400, not an incident. See LoginStart. - if xerr := s.captcha.Verify(ctx, data.Turnstile, ipaddr); xerr != nil { + if xerr := s.verifyHuman(ctx, appattest.ActionRegister, data.Turnstile, data.AppAttest, ipaddr); xerr != nil { return nil, xerr } diff --git a/internal/app/auth/reset_password.go b/internal/app/auth/reset_password.go index 0bbef47ce..ef9bd5538 100644 --- a/internal/app/auth/reset_password.go +++ b/internal/app/auth/reset_password.go @@ -8,6 +8,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/rs/zerolog/log" + "github.com/warmbly/warmbly/internal/app/appattest" tokenpkg "github.com/warmbly/warmbly/internal/app/token" "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" @@ -20,7 +21,7 @@ import ( func (s *authService) ResetPasswordStart(ctx context.Context, data *ResetPasswordStart, ipaddr string) *errx.Error { // The caller's 400, not an incident. See LoginStart. - if err := s.captcha.Verify(ctx, data.Turnstile, ipaddr); err != nil { + if err := s.verifyHuman(ctx, appattest.ActionResetPassword, data.Turnstile, data.AppAttest, ipaddr); err != nil { return err } @@ -138,7 +139,7 @@ func (s *authService) sendResetEmailWithRetry(ctx context.Context, to, subject, func (s *authService) ResetPasswordConfirm(ctx context.Context, data *ResetPasswordConfirm, session, ipaddr string) *errx.Error { // The caller's 400, not an incident. See LoginStart. - if err := s.captcha.Verify(ctx, data.Turnstile, ipaddr); err != nil { + if err := s.verifyHuman(ctx, appattest.ActionResetPasswordConfirm, data.Turnstile, data.AppAttest, ipaddr); err != nil { return err } diff --git a/internal/app/auth/service.go b/internal/app/auth/service.go index 098446440..c5c6cd79e 100644 --- a/internal/app/auth/service.go +++ b/internal/app/auth/service.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/app/appattest" "github.com/warmbly/warmbly/internal/app/organization" "github.com/warmbly/warmbly/internal/app/token" "github.com/warmbly/warmbly/internal/app/trial" @@ -78,6 +79,8 @@ type AuthService interface { LoginConfirm(ctx context.Context, data *ConfirmData, session, ipaddr, userAgent string) (*models.LoginResult, *errx.Error) // WireTwoFA attaches the 2FA challenger (post-construction; nil = 2FA off). WireTwoFA(t TwoFAChallenger) + // WireAppAttest accepts native app proofs on the captcha-gated paths (nil = off). + WireAppAttest(a *appattest.Service) RegistrationStart(ctx context.Context, data *AuthData, origin SignupOrigin) (*models.AuthSession, *errx.Error) RegistrationConfirm(ctx context.Context, data *ConfirmData, session string, origin SignupOrigin) (*models.AuthSession, *errx.Error) @@ -173,9 +176,11 @@ type authService struct { organizationService organization.OrganizationService emailNotificationService notify.EmailNotificationService // opsNotify raises instance-wide operator alerts. Nil is the default. - opsNotify OperatorNotifier - cache *cache.Cache - captcha *captcha.Turnstile + opsNotify OperatorNotifier + cache *cache.Cache + captcha *captcha.Turnstile + // appAttest takes the native app's proof in place of Turnstile. Nil turns it off. + appAttest *appattest.Service appleIDTokens IDTokenVerifier googleIDTokens IDTokenVerifier twofa TwoFAChallenger @@ -207,6 +212,18 @@ type authService struct { func (s *authService) WireTwoFA(t TwoFAChallenger) { s.twofa = t } +// WireAppAttest accepts native app proofs on the captcha-gated paths. +func (s *authService) WireAppAttest(a *appattest.Service) { s.appAttest = a } + +// verifyHuman is the anti-automation gate on every unauthenticated auth write: +// Turnstile for a browser, an App Attest assertion for the native app. +func (s *authService) verifyHuman(ctx context.Context, action, turnstile string, proof *appattest.Proof, ipaddr string) *errx.Error { + if proof != nil && s.captcha.Required() && s.appAttest.Enabled() { + return s.appAttest.Verify(ctx, action, proof) + } + return s.captcha.Verify(ctx, turnstile, ipaddr) +} + func (s *authService) WireDeployment(policy *config.AuthPolicy, mailDelivers bool) { if policy != nil { s.policy = policy diff --git a/internal/app/auth/verify_human_test.go b/internal/app/auth/verify_human_test.go new file mode 100644 index 000000000..a3ff6c940 --- /dev/null +++ b/internal/app/auth/verify_human_test.go @@ -0,0 +1,25 @@ +package auth + +import ( + "context" + "testing" + + "github.com/warmbly/warmbly/internal/app/appattest" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/pkg/captcha" +) + +func TestVerifyHumanRouting(t *testing.T) { + proof := &appattest.Proof{KeyID: "k", Assertion: "a", Challenge: "c"} + + off := &authService{captcha: captcha.NewTurnstileFromConfig(captcha.TurnstileConfig{Disabled: true})} + if err := off.verifyHuman(context.Background(), appattest.ActionLogin, "", proof, "203.0.113.1"); err != nil { + t.Fatalf("no captcha on the deployment, proof sent: %v", err) + } + + // With App Attest off a proof is no substitute: the Turnstile check still runs. + on := &authService{captcha: captcha.NewTurnstileFromConfig(captcha.TurnstileConfig{Secret: "s"})} + if err := on.verifyHuman(context.Background(), appattest.ActionLogin, "", proof, "203.0.113.1"); err != errx.ErrCaptcha { + t.Fatalf("err = %v, want the captcha refusal", err) + } +} diff --git a/internal/config/config_auth.go b/internal/config/config_auth.go index 4cb0400a0..551c54569 100644 --- a/internal/config/config_auth.go +++ b/internal/config/config_auth.go @@ -35,6 +35,12 @@ type AuthConfig struct { TurnstileSecret string TurnstileBypass string + // AppAttestAppIDs are the "." values whose App Attest + // proofs stand in for the captcha. Empty turns native app proofs off. + AppAttestAppIDs []string + // AppAttestAllowDevelopment accepts proofs from development-signed builds. + AppAttestAllowDevelopment bool + // TwoFASecret is the server-wide key the TOTP secret is sealed with (AES-GCM). // Must be set when 2FA is in use; falls back to AuthSecret when unset so a // deployment without the env var still functions (sealed under the auth key). @@ -99,6 +105,13 @@ func (c *Config) LoadAuthConfig(ctx context.Context) (*AuthConfig, error) { SetTurnstileSecretResolved(turnstileSecret != "") turnstileBypass := c.GetSecretOptional(ctx, "TURNSTILE_BYPASS_TOKEN", "turnstile/bypass_token", "") + // Defaults to the iOS app this instance's Apple team ships, when it names one. + appAttestAppIDs := splitCSV(c.GetStringOptional(ctx, "APP_ATTEST_APP_IDS", "app-attest/app_ids", "")) + if len(appAttestAppIDs) == 0 && appleTeamID != "" && appleIOSBundleID != "" { + appAttestAppIDs = []string{appleTeamID + "." + appleIOSBundleID} + } + appAttestAllowDev := c.GetBoolOptional(ctx, "APP_ATTEST_ALLOW_DEVELOPMENT", "app-attest/allow_development", false) + // 2FA sealing key. Optional: falls back to AUTH_SECRET so existing // deployments keep working; rotating it invalidates enrolled TOTP secrets. twoFASecret := c.GetSecretOptional(ctx, "TWOFA_SECRET", "twofa_secret", authSecret) @@ -125,7 +138,10 @@ func (c *Config) LoadAuthConfig(ctx context.Context) (*AuthConfig, error) { AuthSecret: authSecret, TurnstileSecret: turnstileSecret, TurnstileBypass: turnstileBypass, - TwoFASecret: twoFASecret, + + AppAttestAppIDs: appAttestAppIDs, + AppAttestAllowDevelopment: appAttestAllowDev, + TwoFASecret: twoFASecret, WebAuthnRPID: rpID, WebAuthnRPDisplayName: rpDisplayName, diff --git a/internal/errx/common.go b/internal/errx/common.go index 52b2f6ec9..d42fbd7d3 100644 --- a/internal/errx/common.go +++ b/internal/errx/common.go @@ -114,6 +114,9 @@ var ( // Captch ErrCaptcha = New(BadRequest, "We couldn’t verify you’re human. Please try the security check again or reload the page.") + // The native app's proof in place of the captcha. + ErrAppAttest = NewWithIdentifier(BadRequest, "app_attest_failed", "We couldn’t verify this copy of the app. Update it from the App Store and try again.") + ErrAppAttestUnknown = NewWithIdentifier(BadRequest, "app_attest_key_unknown", "This device needs to verify the app again.") // Group ErrGroupTitle = New(BadRequest, "The title length must be between 1 and 50 characters.") diff --git a/internal/infrastructure/db/migrations/000290_app_attest_keys.down.sql b/internal/infrastructure/db/migrations/000290_app_attest_keys.down.sql new file mode 100644 index 000000000..9d91bd0ec --- /dev/null +++ b/internal/infrastructure/db/migrations/000290_app_attest_keys.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS app_attest_keys; diff --git a/internal/infrastructure/db/migrations/000290_app_attest_keys.up.sql b/internal/infrastructure/db/migrations/000290_app_attest_keys.up.sql new file mode 100644 index 000000000..950b2a42c --- /dev/null +++ b/internal/infrastructure/db/migrations/000290_app_attest_keys.up.sql @@ -0,0 +1,13 @@ +-- App Attest keys the native app registered: one per install, verified against +-- Apple's root, then used to check the assertion on each unauthenticated auth +-- write. Not tied to a user or a workspace. +CREATE TABLE app_attest_keys ( + key_id TEXT PRIMARY KEY, + public_key BYTEA NOT NULL, + app_id TEXT NOT NULL, + environment TEXT NOT NULL CHECK (environment IN ('production', 'development')), + counter BIGINT NOT NULL DEFAULT 0 CHECK (counter >= 0), + receipt BYTEA, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_used_at TIMESTAMPTZ +); diff --git a/internal/models/contact.go b/internal/models/contact.go index 5fb02abd3..4aa7b6e39 100644 --- a/internal/models/contact.go +++ b/internal/models/contact.go @@ -68,6 +68,10 @@ type Contact struct { // leads are queued, in progress, replied, bounced, or unsubscribed. CampaignLead *ContactCampaignProgress `json:"campaign_lead,omitempty"` + // Outreach is the contact's engagement across every campaign. Populated by + // Search on every row; nil elsewhere. + Outreach *ContactOutreach `json:"outreach,omitempty"` + // IsNew is set by the upsert write when this call inserted the row rather // than matching an existing contact. Server-side only: it decides whether // a contact.created event fires. @@ -407,6 +411,23 @@ type ContactCategoryCount struct { // ContactEngagement summarises every email touchpoint we have for a // single contact. It's denormalised on read so the contact 360 view // can render counts and "last X" timestamps in a single round-trip. +// ContactOutreach is where a contact stands with your outreach: the emails +// that reached them, what they did with them, and when each last happened. +// Opens count people only; a mail client's automatic fetch is not engagement. +type ContactOutreach struct { + Sent int `json:"sent"` + Opened int `json:"opened"` + Clicked int `json:"clicked"` + Replied int `json:"replied"` + Bounced int `json:"bounced"` + + LastSentAt *time.Time `json:"last_sent_at,omitempty"` + LastOpenedAt *time.Time `json:"last_opened_at,omitempty"` + LastClickedAt *time.Time `json:"last_clicked_at,omitempty"` + LastRepliedAt *time.Time `json:"last_replied_at,omitempty"` + LastBouncedAt *time.Time `json:"last_bounced_at,omitempty"` +} + type ContactEngagement struct { TotalSent int `json:"total_sent"` TotalOpened int `json:"total_opened"` diff --git a/internal/pkg/appattest/appattest.go b/internal/pkg/appattest/appattest.go new file mode 100644 index 000000000..3417abba2 --- /dev/null +++ b/internal/pkg/appattest/appattest.go @@ -0,0 +1,330 @@ +// Package appattest verifies Apple App Attest attestations and assertions: +// proof that a request comes from a genuine, unmodified build of the native app +// on a real Apple device. It holds no state; callers store keys and counters. +package appattest + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/sha256" + "crypto/subtle" + "crypto/x509" + _ "embed" + "encoding/asn1" + "encoding/binary" + "errors" + "fmt" + "time" + + "github.com/fxamacker/cbor/v2" +) + +//go:embed apple_app_attestation_root_ca.pem +var appleRootPEM []byte + +// Environment is the App Attest environment a key was created in. +type Environment string + +const ( + Production Environment = "production" + Development Environment = "development" +) + +// ErrInvalid wraps every verification failure; the wrapped text is for logs only. +var ErrInvalid = errors.New("appattest: invalid") + +var ( + aaguidProduction = append([]byte("appattest"), make([]byte, 7)...) + aaguidDevelopment = []byte("appattestdevelop") + nonceExtensionOID = asn1.ObjectIdentifier{1, 2, 840, 113635, 100, 8, 2} +) + +const ( + flagAttestedData = 0x40 + flagExtensions = 0x80 + + extValidationCategory = "apple_validation_category_01" + categoryTestFlight = 2 + categoryDevelopment = 3 + categoryAppStore = 4 +) + +// Policy is what a server accepts. +type Policy struct { + // AppIDs are the accepted "." values. + AppIDs []string + // AllowDevelopment accepts keys from development-signed builds. + AllowDevelopment bool + // Roots overrides Apple's root; nil means Apple's. Tests only. + Roots *x509.CertPool +} + +// Key is an attested key, ready to store. +type Key struct { + ID []byte + PublicKey []byte // uncompressed P-256 point + AppID string + Environment Environment + Receipt []byte +} + +func invalid(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrInvalid, fmt.Sprintf(format, args...)) +} + +func appleRoots() (*x509.CertPool, error) { + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(appleRootPEM) { + return nil, errors.New("appattest: embedded root does not parse") + } + return pool, nil +} + +type attestationObject struct { + Fmt string `cbor:"fmt"` + AttStmt struct { + X5C [][]byte `cbor:"x5c"` + Receipt []byte `cbor:"receipt"` + } `cbor:"attStmt"` + AuthData []byte `cbor:"authData"` +} + +type authenticatorData struct { + rpIDHash []byte + flags byte + counter uint32 + aaguid []byte + credentialID []byte + extensions map[string]any +} + +// parseAuthenticatorData reads the fields; assertions set the attested-data +// flag without carrying the data, so only attestations parse it. +func parseAuthenticatorData(raw []byte, attested bool) (*authenticatorData, error) { + if len(raw) < 37 { + return nil, invalid("authenticator data is %d bytes", len(raw)) + } + ad := &authenticatorData{rpIDHash: raw[:32], flags: raw[32], counter: binary.BigEndian.Uint32(raw[33:37])} + rest := raw[37:] + if attested { + if ad.flags&flagAttestedData == 0 { + return nil, invalid("attested credential data is missing") + } + if len(rest) < 18 { + return nil, invalid("attested credential data is truncated") + } + ad.aaguid = rest[:16] + n := int(binary.BigEndian.Uint16(rest[16:18])) + rest = rest[18:] + if len(rest) < n { + return nil, invalid("credential id is truncated") + } + ad.credentialID = rest[:n] + var coseKey cbor.RawMessage + var err error + if rest, err = cbor.UnmarshalFirst(rest[n:], &coseKey); err != nil { + return nil, invalid("credential public key: %v", err) + } + } + if ad.flags&flagExtensions != 0 { + var err error + if rest, err = cbor.UnmarshalFirst(rest, &ad.extensions); err != nil { + return nil, invalid("extensions: %v", err) + } + } + if len(rest) != 0 { + return nil, invalid("%d trailing bytes in authenticator data", len(rest)) + } + return ad, nil +} + +// checkCategory refuses a launch category the policy does not cover. Devices +// that report none (older systems) are judged on the rest of the checks. +func (ad *authenticatorData) checkCategory(allowDevelopment bool) error { + raw, ok := ad.extensions[extValidationCategory] + if !ok { + return nil + } + var category uint64 + switch v := raw.(type) { + case uint64: + category = v + case int64: + if v < 0 { + return invalid("validation category %d", v) + } + category = uint64(v) + default: + return invalid("validation category has type %T", raw) + } + switch { + case category == categoryAppStore, category == categoryTestFlight: + return nil + case category == categoryDevelopment && allowDevelopment: + return nil + } + return invalid("validation category %d is not accepted", category) +} + +func matchAppID(rpIDHash []byte, appIDs []string) (string, bool) { + for _, id := range appIDs { + sum := sha256.Sum256([]byte(id)) + if subtle.ConstantTimeCompare(sum[:], rpIDHash) == 1 { + return id, true + } + } + return "", false +} + +// VerifyAttestation checks a key attestation made over clientDataHash and +// returns the key to store. keyID is the raw 32-byte key identifier. +func VerifyAttestation(object, keyID, clientDataHash []byte, policy Policy, now time.Time) (*Key, error) { + var att attestationObject + if err := cbor.Unmarshal(object, &att); err != nil { + return nil, invalid("attestation object: %v", err) + } + if att.Fmt != "apple-appattest" { + return nil, invalid("format %q", att.Fmt) + } + if len(att.AttStmt.X5C) < 2 { + return nil, invalid("certificate chain has %d entries", len(att.AttStmt.X5C)) + } + + certs := make([]*x509.Certificate, 0, len(att.AttStmt.X5C)) + for _, der := range att.AttStmt.X5C { + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, invalid("certificate: %v", err) + } + certs = append(certs, cert) + } + roots := policy.Roots + if roots == nil { + var err error + if roots, err = appleRoots(); err != nil { + return nil, err + } + } + intermediates := x509.NewCertPool() + for _, cert := range certs[1:] { + intermediates.AddCert(cert) + } + credCert := certs[0] + if _, err := credCert.Verify(x509.VerifyOptions{ + Roots: roots, + Intermediates: intermediates, + CurrentTime: now, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + }); err != nil { + return nil, invalid("certificate chain: %v", err) + } + + nonce := sha256.Sum256(append(append([]byte{}, att.AuthData...), clientDataHash...)) + var certNonce []byte + for _, ext := range credCert.Extensions { + if !ext.Id.Equal(nonceExtensionOID) { + continue + } + var wrapped struct { + Nonce []byte `asn1:"tag:1,explicit"` + } + if _, err := asn1.Unmarshal(ext.Value, &wrapped); err != nil { + return nil, invalid("nonce extension: %v", err) + } + certNonce = wrapped.Nonce + } + if subtle.ConstantTimeCompare(certNonce, nonce[:]) != 1 { + return nil, invalid("nonce does not match the challenge") + } + + pub, ok := credCert.PublicKey.(*ecdsa.PublicKey) + if !ok || pub.Curve != elliptic.P256() { + return nil, invalid("credential key is not P-256") + } + ecdhKey, err := pub.ECDH() + if err != nil { + return nil, invalid("credential key: %v", err) + } + point := ecdhKey.Bytes() + pointHash := sha256.Sum256(point) + if subtle.ConstantTimeCompare(pointHash[:], keyID) != 1 { + return nil, invalid("key id does not match the credential key") + } + + ad, err := parseAuthenticatorData(att.AuthData, true) + if err != nil { + return nil, err + } + appID, ok := matchAppID(ad.rpIDHash, policy.AppIDs) + if !ok { + return nil, invalid("app id is not accepted") + } + if ad.counter != 0 { + return nil, invalid("counter is %d", ad.counter) + } + var env Environment + switch { + case bytes.Equal(ad.aaguid, aaguidProduction): + env = Production + case bytes.Equal(ad.aaguid, aaguidDevelopment) && policy.AllowDevelopment: + env = Development + default: + return nil, invalid("environment is not accepted") + } + if !bytes.Equal(ad.credentialID, keyID) { + return nil, invalid("credential id does not match the key id") + } + if err := ad.checkCategory(policy.AllowDevelopment); err != nil { + return nil, err + } + + return &Key{ + ID: append([]byte{}, keyID...), + PublicKey: point, + AppID: appID, + Environment: env, + Receipt: att.AttStmt.Receipt, + }, nil +} + +type assertionObject struct { + Signature []byte `cbor:"signature"` + AuthenticatorData []byte `cbor:"authenticatorData"` +} + +// VerifyAssertion checks an assertion over clientData made with a stored key +// and returns its counter, which must be stored for the next one. +func VerifyAssertion(object, clientData, publicKey []byte, appID string, prevCounter uint32, allowDevelopment bool) (uint32, error) { + var as assertionObject + if err := cbor.Unmarshal(object, &as); err != nil { + return 0, invalid("assertion object: %v", err) + } + pub, err := ecdsa.ParseUncompressedPublicKey(elliptic.P256(), publicKey) + if err != nil { + return 0, invalid("stored key: %v", err) + } + + clientDataHash := sha256.Sum256(clientData) + nonce := sha256.Sum256(append(append([]byte{}, as.AuthenticatorData...), clientDataHash[:]...)) + // The device signs nonce as the message, so ES256 hashes it once more. + digest := sha256.Sum256(nonce[:]) + if !ecdsa.VerifyASN1(pub, digest[:], as.Signature) { + return 0, invalid("signature does not verify") + } + + ad, err := parseAuthenticatorData(as.AuthenticatorData, false) + if err != nil { + return 0, err + } + if _, ok := matchAppID(ad.rpIDHash, []string{appID}); !ok { + return 0, invalid("app id does not match the key") + } + if ad.counter <= prevCounter { + return 0, invalid("counter %d did not advance past %d", ad.counter, prevCounter) + } + if err := ad.checkCategory(allowDevelopment); err != nil { + return 0, err + } + return ad.counter, nil +} diff --git a/internal/pkg/appattest/appattest_test.go b/internal/pkg/appattest/appattest_test.go new file mode 100644 index 000000000..5d4e9e96e --- /dev/null +++ b/internal/pkg/appattest/appattest_test.go @@ -0,0 +1,147 @@ +package appattest + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "os" + "testing" + "time" +) + +// Vectors produced by a real device, from github.com/bas-d/appattest +// (BSD-3-Clause, Copyright (c) 2019, Duo Labs). +type deviceVectors struct { + AppID string `json:"app_id"` + KeyID string `json:"key_id"` + Attestation string `json:"attestation"` + AttestationClientData string `json:"attestation_client_data"` + AttestedAt string `json:"attested_at"` + PublicKey string `json:"public_key"` + Assertion string `json:"assertion"` + AssertionClientData string `json:"assertion_client_data"` +} + +func loadVectors(t *testing.T) (deviceVectors, []byte, []byte, []byte, []byte, time.Time) { + t.Helper() + raw, err := os.ReadFile("testdata/device_vectors.json") + if err != nil { + t.Fatal(err) + } + var v deviceVectors + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatal(err) + } + keyID, err := base64.StdEncoding.DecodeString(v.KeyID) + if err != nil { + t.Fatal(err) + } + att, err := base64.RawURLEncoding.DecodeString(v.Attestation) + if err != nil { + t.Fatal(err) + } + pub, err := hex.DecodeString(v.PublicKey) + if err != nil { + t.Fatal(err) + } + as, err := base64.RawURLEncoding.DecodeString(v.Assertion) + if err != nil { + t.Fatal(err) + } + at, err := time.Parse(time.RFC3339, v.AttestedAt) + if err != nil { + t.Fatal(err) + } + return v, keyID, att, pub, as, at +} + +func TestVerifyAttestation(t *testing.T) { + v, keyID, att, pub, _, at := loadVectors(t) + cdh := sha256.Sum256([]byte(v.AttestationClientData)) + dev := Policy{AppIDs: []string{"OTHERTEAM.com.example", v.AppID}, AllowDevelopment: true} + + key, err := VerifyAttestation(att, keyID, cdh[:], dev, at) + if err != nil { + t.Fatalf("device attestation refused: %v", err) + } + if hex.EncodeToString(key.PublicKey) != hex.EncodeToString(pub) { + t.Fatalf("public key = %x", key.PublicKey) + } + if key.AppID != v.AppID || key.Environment != Development { + t.Fatalf("app id %q, environment %q", key.AppID, key.Environment) + } + + otherHash := sha256.Sum256([]byte("another-challenge")) + otherKey := append([]byte{}, keyID...) + otherKey[0] ^= 1 + cases := map[string]struct { + keyID, cdh []byte + policy Policy + now time.Time + }{ + "development refused": {keyID, cdh[:], Policy{AppIDs: []string{v.AppID}}, at}, + "another app": {keyID, cdh[:], Policy{AppIDs: []string{"OTHERTEAM.com.example"}, AllowDevelopment: true}, at}, + "another challenge": {keyID, otherHash[:], dev, at}, + "another key id": {otherKey, cdh[:], dev, at}, + "expired credential": {keyID, cdh[:], dev, at.AddDate(0, 1, 0)}, + "no accepted app ids": {keyID, cdh[:], Policy{AllowDevelopment: true}, at}, + "truncated attestation": {keyID, cdh[:], dev, at}, + } + for name, tc := range cases { + object := att + if name == "truncated attestation" { + object = att[:len(att)/2] + } + if _, err := VerifyAttestation(object, tc.keyID, tc.cdh, tc.policy, tc.now); !errors.Is(err, ErrInvalid) { + t.Errorf("%s: err = %v, want ErrInvalid", name, err) + } + } +} + +func TestVerifyAssertion(t *testing.T) { + v, _, _, pub, as, _ := loadVectors(t) + data := []byte(v.AssertionClientData) + + counter, err := VerifyAssertion(as, data, pub, v.AppID, 0, true) + if err != nil { + t.Fatalf("device assertion refused: %v", err) + } + if counter == 0 { + t.Fatal("counter did not advance") + } + + if _, err := VerifyAssertion(as, data, pub, v.AppID, counter, true); !errors.Is(err, ErrInvalid) { + t.Errorf("replayed counter: err = %v", err) + } + if _, err := VerifyAssertion(as, []byte(`{"challenge":"other"}`), pub, v.AppID, 0, true); !errors.Is(err, ErrInvalid) { + t.Errorf("other client data: err = %v", err) + } + if _, err := VerifyAssertion(as, data, pub, "OTHERTEAM.com.example", 0, true); !errors.Is(err, ErrInvalid) { + t.Errorf("other app id: err = %v", err) + } + tampered := append([]byte{}, as...) + tampered[len(tampered)-1] ^= 1 + if _, err := VerifyAssertion(tampered, data, pub, v.AppID, 0, true); !errors.Is(err, ErrInvalid) { + t.Errorf("tampered assertion: err = %v", err) + } +} + +func TestValidationCategory(t *testing.T) { + ad := &authenticatorData{extensions: map[string]any{}} + for category, want := range map[uint64]bool{2: true, 3: false, 4: true, 5: false, 10: false, 0: false} { + ad.extensions[extValidationCategory] = category + if got := ad.checkCategory(false) == nil; got != want { + t.Errorf("category %d accepted = %v", category, got) + } + } + ad.extensions[extValidationCategory] = uint64(3) + if err := ad.checkCategory(true); err != nil { + t.Errorf("development build refused with development allowed: %v", err) + } + delete(ad.extensions, extValidationCategory) + if err := ad.checkCategory(false); err != nil { + t.Errorf("no category reported: %v", err) + } +} diff --git a/internal/pkg/appattest/apple_app_attestation_root_ca.pem b/internal/pkg/appattest/apple_app_attestation_root_ca.pem new file mode 100644 index 000000000..4cff2277b --- /dev/null +++ b/internal/pkg/appattest/apple_app_attestation_root_ca.pem @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw +JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK +QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa +Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv +biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y +bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh +NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au +Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/ +MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw +CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn +53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV +oyFraWVIyd/dganmrduC1bmTBGwD +-----END CERTIFICATE----- diff --git a/internal/pkg/appattest/testdata/device_vectors.json b/internal/pkg/appattest/testdata/device_vectors.json new file mode 100644 index 000000000..90f06880a --- /dev/null +++ b/internal/pkg/appattest/testdata/device_vectors.json @@ -0,0 +1,10 @@ +{ + "app_id": "35MFYY2JY5.co.chiff.attestation-test", + "key_id": "AcP/pnpoNVPIJYZOvmIvWzDvmxkFoQCE4Uu7Nk6WiAA=", + "attestation": "o2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAuswggLnMIICbaADAgECAgYBeNT03AYwCgYIKoZIzj0EAwIwTzEjMCEGA1UEAwwaQXBwbGUgQXBwIEF0dGVzdGF0aW9uIENBIDExEzARBgNVBAoMCkFwcGxlIEluYy4xEzARBgNVBAgMCkNhbGlmb3JuaWEwHhcNMjEwNDE0MDk1NTIwWhcNMjEwNDE3MDk1NTIwWjCBkTFJMEcGA1UEAwxAMDFjM2ZmYTY3YTY4MzU1M2M4MjU4NjRlYmU2MjJmNWIzMGVmOWIxOTA1YTEwMDg0ZTE0YmJiMzY0ZTk2ODgwMDEaMBgGA1UECwwRQUFBIENlcnRpZmljYXRpb24xEzARBgNVBAoMCkFwcGxlIEluYy4xEzARBgNVBAgMCkNhbGlmb3JuaWEwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ3xAT6K7-PvPTucIBXPV-oDE9sw6IvfbQ6-Sw5TnzRyIDJWrQilyYl6OZzrxvaKwlmVOm2AolWAfklu1lBxTCCo4HxMIHuMAwGA1UdEwEB_wQCMAAwDgYDVR0PAQH_BAQDAgTwMH4GCSqGSIb3Y2QIBQRxMG-kAwIBCr-JMAMCAQG_iTEDAgEAv4kyAwIBAb-JMwMCAQG_iTQmBCQzNU1GWVkySlk1LmNvLmNoaWZmLmF0dGVzdGF0aW9uLXRlc3SlBgQEc2tzIL-JNgMCAQW_iTcDAgEAv4k5AwIBAL-JOgMCAQAwGQYJKoZIhvdjZAgHBAwwCr-KeAYEBDE0LjQwMwYJKoZIhvdjZAgCBCYwJKEiBCCOPSSk1ZLu7Zc9Zd2TmGO7tY5ktIclyAclmfTBJdpmjjAKBggqhkjOPQQDAgNoADBlAjEAzHk20GzLdZlaaJXKchriZkmJWhfTCgQHRpn3D6Y7Coit7UQABhIABVh6D4qwPysZAjAFDGuGqb796A9H-1UVCgui5ufZnWZHl1SVT-6iobxfS9av2ahGkLF8hYQXVT3pofxZAkcwggJDMIIByKADAgECAhAJusXhvEAa2dRTlbw4GghUMAoGCCqGSM49BAMDMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlvbiBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTIwMDMxODE4Mzk1NVoXDTMwMDMxMzAwMDAwMFowTzEjMCEGA1UEAwwaQXBwbGUgQXBwIEF0dGVzdGF0aW9uIENBIDExEzARBgNVBAoMCkFwcGxlIEluYy4xEzARBgNVBAgMCkNhbGlmb3JuaWEwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAASuWzegd015sjWPQOfR8iYm8cJf7xeALeqzgmpZh0_40q0VJXiaomYEGRJItjy5ZwaemNNjvV43D7-gjjKegHOphed0bqNZovZvKdsyr0VeIRZY1WevniZ-smFNwhpmzpmjZjBkMBIGA1UdEwEB_wQIMAYBAf8CAQAwHwYDVR0jBBgwFoAUrJEQUzO9vmhB_6cMqeX66uXliqEwHQYDVR0OBBYEFD7jXRwEGanJtDH4hHTW4eFXcuObMA4GA1UdDwEB_wQEAwIBBjAKBggqhkjOPQQDAwNpADBmAjEAu76IjXONBQLPvP1mbQlXUDW81ocsP4QwSSYp7dH5FOh5mRya6LWu-NOoVDP3tg0GAjEAqzjt0MyB7QCkUsO6RPmTY2VT_swpfy60359evlpKyraZXEuCDfkEOG94B7tYlDm3Z3JlY2VpcHRZDl0wgAYJKoZIhvcNAQcCoIAwgAIBATEPMA0GCWCGSAFlAwQCAQUAMIAGCSqGSIb3DQEHAaCAJIAEggPoMYIEGDAsAgECAgEBBCQzNU1GWVkySlk1LmNvLmNoaWZmLmF0dGVzdGF0aW9uLXRlc3QwggL1AgEDAgEBBIIC6zCCAucwggJtoAMCAQICBgF41PTcBjAKBggqhkjOPQQDAjBPMSMwIQYDVQQDDBpBcHBsZSBBcHAgQXR0ZXN0YXRpb24gQ0EgMTETMBEGA1UECgwKQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMTA0MTQwOTU1MjBaFw0yMTA0MTcwOTU1MjBaMIGRMUkwRwYDVQQDDEAwMWMzZmZhNjdhNjgzNTUzYzgyNTg2NGViZTYyMmY1YjMwZWY5YjE5MDVhMTAwODRlMTRiYmIzNjRlOTY4ODAwMRowGAYDVQQLDBFBQUEgQ2VydGlmaWNhdGlvbjETMBEGA1UECgwKQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABDfEBPorv4-89O5wgFc9X6gMT2zDoi99tDr5LDlOfNHIgMlatCKXJiXo5nOvG9orCWZU6bYCiVYB-SW7WUHFMIKjgfEwge4wDAYDVR0TAQH_BAIwADAOBgNVHQ8BAf8EBAMCBPAwfgYJKoZIhvdjZAgFBHEwb6QDAgEKv4kwAwIBAb-JMQMCAQC_iTIDAgEBv4kzAwIBAb-JNCYEJDM1TUZZWTJKWTUuY28uY2hpZmYuYXR0ZXN0YXRpb24tdGVzdKUGBARza3Mgv4k2AwIBBb-JNwMCAQC_iTkDAgEAv4k6AwIBADAZBgkqhkiG92NkCAcEDDAKv4p4BgQEMTQuNDAzBgkqhkiG92NkCAIEJjAkoSIEII49JKTVku7tlz1l3ZOYY7u1jmS0hyXIByWZ9MEl2maOMAoGCCqGSM49BAMCA2gAMGUCMQDMeTbQbMt1mVpolcpyGuJmSYlaF9MKBAdGmfcPpjsKiK3tRAAGEgAFWHoPirA_KxkCMAUMa4apvv3oD0f7VRUKC6Lm59mdZkeXVJVP7qKhvF9L1q_ZqEaQsXyFhBdVPemh_DAoAgEEAgEBBCBsbdptlEbTsu5ktHjBTEiDsfbajKOKz4hxgskGW0mjojBgAgEFAgEBBFgxZzZKcm5JdXg5eHFzWDFzSDQ1ekUwUzVvWGJCM0Njenp3aVpZOXJxSkMxc2ZWa3J0T3ZIRk92UXF5Wjg1NE80Yk5zOEloWkV1eVRzNmZPQ01VMmtlZz09MA4CAQYCAQEEBkFUVEVTVDAPAgEHAgEBBAdzYW5kYm94MCACAQwCAQEEGDIwMjEtMAQ0NC0xNVQwOTo1NToyMC4yMDdaMCACARUCAQEEGDIwMjEtMDctMTRUMDk6NTU6MjAuMjA3WgAAAAAAAKCAMIIDrTCCA1SgAwIBAgIQWTNWreVZgs9EQjes30UbUzAKBggqhkjOPQQDAjB8MTAwLgYDVQQDDCdBcHBsZSBBcHBsaWNhdGlvbiBJbnRlZ3JhdGlvbiBDQSA1IC0gRzExJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzAeFw0yMDA1MTkxNzQ3MzFaFw0yMTA2MTgxNzQ3MzFaMFoxNjA0BgNVBAMMLUFwcGxpY2F0aW9uIEF0dGVzdGF0aW9uIEZyYXVkIFJlY2VpcHQgU2lnbmluZzETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR_6RU0bMOKe5g8k9HQQ1_Yq9pWcATTLFiGZVGVerR498sq-LpF9_p46sYsSeT5zcCEtQMU8QIz2pt2-kQqK7hyo4IB2DCCAdQwDAYDVR0TAQH_BAIwADAfBgNVHSMEGDAWgBTZF_5LZ5A4S5L0287VV4AUC489yTBDBggrBgEFBQcBAQQ3MDUwMwYIKwYBBQUHMAGGJ2h0dHA6Ly9vY3NwLmFwcGxlLmNvbS9vY3NwMDMtYWFpY2E1ZzEwMTCCARwGA1UdIASCARMwggEPMIIBCwYJKoZIhvdjZAUBMIH9MIHDBggrBgEFBQcCAjCBtgyBs1JlbGlhbmNlIG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBjb25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZpY2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMDUGCCsGAQUFBwIBFilodHRwOi8vd3d3LmFwcGxlLmNvbS9jZXJ0aWZpY2F0ZWF1dGhvcml0eTAdBgNVHQ4EFgQUaR7HD0fs443ddTdE8-nhWmwQViUwDgYDVR0PAQH_BAQDAgeAMA8GCSqGSIb3Y2QMDwQCBQAwCgYIKoZIzj0EAwIDRwAwRAIgJRgWXF4pnFn2hTmtXduZ9jc-9g7NCEWp_Xca1iQtLCICIF0qmypfq6NjgWWNGED3r0gL12uhlNg0IIf01pNbtRuuMIIC-TCCAn-gAwIBAgIQVvuD1Cv_jcM3mSO1Wq5uvTAKBggqhkjOPQQDAzBnMRswGQYDVQQDDBJBcHBsZSBSb290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzAeFw0xOTAzMjIxNzUzMzNaFw0zNDAzMjIwMDAwMDBaMHwxMDAuBgNVBAMMJ0FwcGxlIEFwcGxpY2F0aW9uIEludGVncmF0aW9uIENBIDUgLSBHMTEmMCQGA1UECwwdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEks5jvX2GsasoCjsc4a_7BJSAkaz2Md-myyg1b0RL4SHlV90SjY26gnyVvkn6vjPKrs0EGfEvQyX69L6zy4N-uqOB9zCB9DAPBgNVHRMBAf8EBTADAQH_MB8GA1UdIwQYMBaAFLuw3qFYM4iapIqZ3r6966_ayySrMEYGCCsGAQUFBwEBBDowODA2BggrBgEFBQcwAYYqaHR0cDovL29jc3AuYXBwbGUuY29tL29jc3AwMy1hcHBsZXJvb3RjYWczMDcGA1UdHwQwMC4wLKAqoCiGJmh0dHA6Ly9jcmwuYXBwbGUuY29tL2FwcGxlcm9vdGNhZzMuY3JsMB0GA1UdDgQWBBTZF_5LZ5A4S5L0287VV4AUC489yTAOBgNVHQ8BAf8EBAMCAQYwEAYKKoZIhvdjZAYCAwQCBQAwCgYIKoZIzj0EAwMDaAAwZQIxAI1vpp-h4OTsW05zipJ_PXhTmI_02h9YHsN1Sv44qEwqgxoaqg2mZG3huZPo0VVM7QIwZzsstOHoNwd3y9XsdqgaOlU7PzVqyMXmkrDhYb6ASWnkXyupbOERAqrMYdk4t3NKMIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwSQXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcNMTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBSb290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtfTjjTuxxEtX_1H7YyYl3J6YRbTzBPEVoA_VhYDKX1DyxNB0cTddqXl5dvMVztK517IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966_ayySrMA8GA1UdEwEB_wQFMAMBAf8wDgYDVR0PAQH_BAQDAgEGMAoGCCqGSM49BAMDA2gAMGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN-yRhHFD_3meoyhpmvOwgPUnPWTxnS4at-qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm-YhidDkLF1vLUagM6BgD56KyKAAAMYH9MIH6AgEBMIGQMHwxMDAuBgNVBAMMJ0FwcGxlIEFwcGxpY2F0aW9uIEludGVncmF0aW9uIENBIDUgLSBHMTEmMCQGA1UECwwdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTAhBZM1at5VmCz0RCN6zfRRtTMA0GCWCGSAFlAwQCAQUAMAoGCCqGSM49BAMCBEcwRQIgUEClatNpJhJevokCcdbzCvmLPTGKgCpqTcAqo75reeACIQD6mKXj7_E__f78hraVFpg1Bgu44k8zimIrwFpp_5YogAAAAAAAAGhhdXRoRGF0YVikfO8rVdpyQQi6kSeW9nX_AL5x1S2uJo-miNNMptJ_cHRAAAAAAGFwcGF0dGVzdGRldmVsb3AAIAHD_6Z6aDVTyCWGTr5iL1sw75sZBaEAhOFLuzZOlogApQECAyYgASFYIDfEBPorv4-89O5wgFc9X6gMT2zDoi99tDr5LDlOfNHIIlgggMlatCKXJiXo5nOvG9orCWZU6bYCiVYB-SW7WUHFMII", + "attestation_client_data": "attestation-test", + "attested_at": "2021-04-14T09:55:20Z", + "public_key": "0437c404fa2bbf8fbcf4ee7080573d5fa80c4f6cc3a22f7db43af92c394e7cd1c880c95ab422972625e8e673af1bda2b096654e9b602895601f925bb5941c53082", + "assertion": "omlzaWduYXR1cmVYRzBFAiEAyC5S3pcvtSpmTfNSd8aJRJCQ6PbN7Dnv_oPkZNMLeIwCIBmxCHXKYyGswzp_LwOxoL18puHooxudXWqDgtTvRomdcWF1dGhlbnRpY2F0b3JEYXRhWCV87ytV2nJBCLqRJ5b2df8AvnHVLa4mj6aI00ym0n9wdEAAAAAD", + "assertion_client_data": "{\"challenge\":\"assertion-test\"}" +} \ No newline at end of file diff --git a/internal/pkg/captcha/turnstile.go b/internal/pkg/captcha/turnstile.go index f3f8363c0..0326efd3d 100644 --- a/internal/pkg/captcha/turnstile.go +++ b/internal/pkg/captcha/turnstile.go @@ -60,6 +60,9 @@ func NewTurnstileFromConfig(cfg TurnstileConfig) *Turnstile { return &Turnstile{cfg: cfg} } +// Required reports whether this deployment checks for a human at all. +func (t *Turnstile) Required() bool { return t != nil && !t.cfg.Disabled } + func (t *Turnstile) Verify(ctx context.Context, token, remoteIP string) *errx.Error { if t.cfg.Disabled { return nil diff --git a/internal/repository/pg_app_attest.go b/internal/repository/pg_app_attest.go new file mode 100644 index 000000000..1b35ebe86 --- /dev/null +++ b/internal/repository/pg_app_attest.go @@ -0,0 +1,83 @@ +package repository + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// AppAttestKey is a native-app key that passed Apple's attestation. +type AppAttestKey struct { + KeyID string + PublicKey []byte + AppID string + Environment string + Counter uint32 + Receipt []byte + CreatedAt time.Time + LastUsedAt *time.Time +} + +// AppAttestRepository stores attested App Attest keys and their counters. +type AppAttestRepository interface { + // Insert stores a newly attested key and reports false when the id is taken. + Insert(ctx context.Context, key AppAttestKey) (bool, error) + // Get returns the key, or nil when it was never registered. + Get(ctx context.Context, keyID string) (*AppAttestKey, error) + // AdvanceCounter stores counter only when it is above the stored one, so two + // requests carrying the same assertion cannot both pass. + AdvanceCounter(ctx context.Context, keyID string, counter uint32) (bool, error) +} + +type appAttestRepository struct { + db *pgxpool.Pool +} + +func NewAppAttestRepository(db *pgxpool.Pool) AppAttestRepository { + return &appAttestRepository{db: db} +} + +func (r *appAttestRepository) Insert(ctx context.Context, key AppAttestKey) (bool, error) { + tag, err := r.db.Exec(ctx, ` + INSERT INTO app_attest_keys (key_id, public_key, app_id, environment, counter, receipt) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (key_id) DO NOTHING`, + key.KeyID, key.PublicKey, key.AppID, key.Environment, int64(key.Counter), key.Receipt) + if err != nil { + return false, err + } + return tag.RowsAffected() == 1, nil +} + +func (r *appAttestRepository) Get(ctx context.Context, keyID string) (*AppAttestKey, error) { + var key AppAttestKey + var counter int64 + err := r.db.QueryRow(ctx, ` + SELECT key_id, public_key, app_id, environment, counter, receipt, created_at, last_used_at + FROM app_attest_keys + WHERE key_id = $1`, keyID). + Scan(&key.KeyID, &key.PublicKey, &key.AppID, &key.Environment, &counter, &key.Receipt, &key.CreatedAt, &key.LastUsedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + key.Counter = uint32(counter) + return &key, nil +} + +func (r *appAttestRepository) AdvanceCounter(ctx context.Context, keyID string, counter uint32) (bool, error) { + tag, err := r.db.Exec(ctx, ` + UPDATE app_attest_keys + SET counter = $2, last_used_at = now() + WHERE key_id = $1 AND counter < $2`, + keyID, int64(counter)) + if err != nil { + return false, err + } + return tag.RowsAffected() == 1, nil +} diff --git a/internal/repository/pg_contact.go b/internal/repository/pg_contact.go index 7ee9a91ee..0ba3acb85 100644 --- a/internal/repository/pg_contact.go +++ b/internal/repository/pg_contact.go @@ -1522,6 +1522,26 @@ type leadRowJSON struct { Hold *models.LeadHold `json:"hold"` } +// contactOutreachSelect is a search row's engagement across every campaign, +// the same counts and times the contact 360 reports. One indexed aggregate per +// returned row, so it costs a page, not the organization. +var contactOutreachSelect = `( + SELECT json_build_object( + 'sent', COUNT(*) FILTER (WHERE a.sent_at IS NOT NULL AND ` + progressIsEmailStep("a") + `), + 'opened', COUNT(*) FILTER (WHERE a.opened_at IS NOT NULL AND NOT a.opened_machine), + 'clicked', COUNT(*) FILTER (WHERE a.clicked_at IS NOT NULL), + 'replied', COUNT(*) FILTER (WHERE a.replied_at IS NOT NULL), + 'bounced', COUNT(*) FILTER (WHERE a.bounced_at IS NOT NULL), + 'last_sent_at', MAX(a.sent_at) FILTER (WHERE ` + progressIsEmailStep("a") + `), + 'last_opened_at', MAX(a.opened_at) FILTER (WHERE NOT a.opened_machine), + 'last_clicked_at', MAX(a.clicked_at), + 'last_replied_at', MAX(a.replied_at), + 'last_bounced_at', MAX(a.bounced_at) + ) + FROM campaign_contact_progress a + WHERE a.contact_id = c.id +)` + func (r *contactRepository) Search( ctx context.Context, orgID string, @@ -1748,6 +1768,7 @@ func (r *contactRepository) Search( ), '[]'::json ) AS categories, %s AS lead_progress, + `+contactOutreachSelect+` AS outreach, %s AS sort_value FROM contacts c %s @@ -1806,6 +1827,7 @@ func (r *contactRepository) Search( var campaignsJSON []byte var categoriesJSON []byte var leadProgressJSON []byte + var outreachJSON []byte var sortValue *string if err := rows.Scan( @@ -1815,7 +1837,7 @@ func (r *contactRepository) Search( &c.VerificationStatus, &c.VerificationReason, &c.IsCatchAll, &c.VerificationCheckedAt, &c.VerificationSource, &c.VerificationProvider, &c.VerificationSubStatus, &c.VerificationConfidence, &c.VerificationRequestedAt, &c.MailHost, &c.ESPProvider, - &campaignsJSON, &categoriesJSON, &leadProgressJSON, + &campaignsJSON, &categoriesJSON, &leadProgressJSON, &outreachJSON, &sortValue, ); err != nil { db.CaptureError(err, "", nil, "scan") @@ -1907,6 +1929,14 @@ func (r *contactRepository) Search( } } + c.Outreach = &models.ContactOutreach{} + if len(outreachJSON) > 0 { + if err := json.Unmarshal(outreachJSON, c.Outreach); err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + } + if len(campaignsJSON) > 0 { var campaigns []struct { ID string `json:"id"` diff --git a/ios/DEVELOPMENT.md b/ios/DEVELOPMENT.md index 582e3d681..e1b69819c 100644 --- a/ios/DEVELOPMENT.md +++ b/ios/DEVELOPMENT.md @@ -34,14 +34,17 @@ Local service map: 2. Pick the **Warmbly** scheme and an iOS 26 simulator. 3. Run (Cmd-R). -On a simulator the defaults already point at `http://localhost:8080`, and `Info.plist` allows local (non-HTTPS) networking via `NSAllowsLocalNetworking`, so no ATS changes are needed. +Every build starts on Warmbly Cloud, so open the gear on the sign-in screen once and pick **Local development**; the choice persists. `Info.plist` allows local (non-HTTPS) networking via `NSAllowsLocalNetworking`, so no ATS changes are needed. ## First run -The app opens on an animated welcome screen; **Get started** begins account creation, **I already have an account** goes to sign-in. The gear button (top-right on the welcome screen, and on the email step's card) opens **Connection settings**: +The app opens on an animated welcome screen; **Get started** begins account creation, **I already have an account** goes to sign-in. The gear button (top-right on sign-in) opens the **Server** sheet, which offers three choices: -- **Server origin** — bare origin, no path. Default `http://localhost:8080`. The app appends `/v1` itself. -- **Turnstile token** — pre-filled with the dev bypass token `warmbly-local-turnstile-bypass`. The dev backend accepts it so you don't need a real captcha widget. (Production is captcha-gated and needs a real token.) +- **Warmbly Cloud**: the default, `https://api.warmbly.com`. +- **Self-hosted**: any origin you type, bare with no path. The app appends `/v1` itself and adds `https://` when the scheme is missing (`http://` for localhost and LAN addresses). +- **Local development** (debug builds): `http://localhost:8080`, with the dev bypass token `warmbly-local-turnstile-bypass`, because the simulator can't use App Attest. + +Under the choices, the sheet reads the server's public `GET /v1/auth/config` and reports whether it answers. A self-hosted server that asks for a captcha but takes no App Attest proof (`app_attest` false) is marked "Email sign-in off": the app never renders Turnstile, so email and password sign-in there needs `APP_ATTEST_APP_IDS` on the server, or a dev backend's bypass token under **Advanced**. Apple, Google and passkey sign-in work regardless. Then sign in with a seeded account: @@ -70,11 +73,10 @@ First-time social sign-ins auto-provision the user, workspace, and trial server- ## Pointing at another host (staging, self-hosted) -The server is an in-app runtime setting, not a build-time constant, so one binary serves everyone: the hosted SaaS, a self-hosted instance, or a local stack. Users switch hosts from the login screen's Connection settings; nobody rebuilds the app to reach their own infrastructure. +The server is an in-app runtime setting, not a build-time constant, so one binary serves everyone: the hosted SaaS, a self-hosted instance, or a local stack. Users switch hosts from the Server sheet on the sign-in screen; nobody rebuilds the app to reach their own infrastructure. -- Shipped (Release) builds default the origin to `https://api.warmbly.com`, so ordinary customers just log in and never open the setting. -- Debug builds default to `http://localhost:8080` for the native dev stack. -- Enter any other origin (a staging box, a self-hosted instance) in Connection settings; it persists in `UserDefaults`. "Reset to default" returns to the build's default. +- Every build defaults the origin to Warmbly Cloud (`https://api.warmbly.com`), so ordinary customers just log in and never open the setting. +- Pick **Local development** for the native dev stack, or **Self-hosted** for any other origin (a staging box, a self-hosted instance); the choice persists in `UserDefaults`. Picking Warmbly Cloud again clears the bypass token and realtime override. - App Transport Security: a remote/custom host must be `https://`. Plain `http://` is allowed only to localhost and LAN/reserved addresses (dev and on-device testing). See the [README caveat](README.md). ## Running on a physical device @@ -99,12 +101,37 @@ Because there is no iOS SDK on CI-less machines, the fast local signal for Swift cd ios && find Warmbly -name '*.swift' -print0 | xargs -0 swiftc -parse ``` -A real typecheck happens when you build in Xcode. Conventions to keep the code consistent: +A real typecheck happens when you build in Xcode. + +### Scripted simulator runs + +Debug builds read launch environment variables so a script can open the app signed in on a given tab without tapping through sign-in (both are compiled out of Release builds): + +- `WARMBLY_DEBUG_TOKEN`: the JSON body returned by `POST /v1/auth/login/confirm`. It is written to the Keychain on launch, and the push permission prompt is skipped. +- `WARMBLY_DEBUG_TAB`: `home`, `inbox`, `campaigns`, `contacts` or `more`. +- `WARMBLY_DEBUG_HOME`: the Home hero style to render, `immersive` (the default), `card` or `fade`. +- `WARMBLY_DEBUG_SCROLL`: opens Home, the settings hub, a settings page or Analytics scrolled to the `center` or `bottom`, for screenshots below the hero. +- `WARMBLY_DEBUG_THREAD`: with `WARMBLY_DEBUG_TAB=inbox`, `1` opens the newest conversation once the list loads `end` also scrolls it to the bottom, where an inbox agent's drafted reply sits, and `edit` opens that draft in the reply composer. +- `WARMBLY_DEBUG_DRAWER`: `1` opens the drawer on launch (Inbox, Campaigns or Contacts, whichever tab is open, or Deals, Tasks and Meetings when one is opened from settings). +- `WARMBLY_DEBUG_CAMPAIGN`: with `WARMBLY_DEBUG_TAB=campaigns`, opens the campaign whose name contains the value (the first campaign when nothing matches). +- `WARMBLY_DEBUG_CONTACT`: with `WARMBLY_DEBUG_TAB=contacts`, opens the contact whose name or address contains the value. `WARMBLY_DEBUG_SORT` sets the contacts sort by its API name (`first_name`, `created_at`, ...). +- `WARMBLY_DEBUG_SETTINGS`: with `WARMBLY_DEBUG_TAB=more`, opens a settings page (`profile`, `notifications`, `security`, `billing`, `team`, `templates` or `activity`) or a full-screen browser (`analytics`, `mailboxes`, `deals`, `tasks` or `meetings`). `WARMBLY_DEBUG_NOTIFICATIONS=preferences` opens the notifications page on its preferences. `WARMBLY_DEBUG_ANALYTICS` picks the analytics page (`overview`, `deliverability`, `warmup` or `accounts`), and `WARMBLY_DEBUG_ANALYTICS_ACCOUNT=1` then opens a mailbox's health. `WARMBLY_DEBUG_TEMPLATE=1` opens the first template and `WARMBLY_DEBUG_AUDIT_ENTRY=1` the newest activity entry. With `mailboxes`, `WARMBLY_DEBUG_CONNECT` opens the add-a-mailbox flow on a step (`provider`, `google`, `twostep`, `apppassword`, `appconnect`, `account`, `imap`, `smtp`, `grants`, `users`, `vendors`, `vendorkey`, `vendormailboxes`, `file`, `filereview` or `running`); every step past `provider` uses sample data, including an admin grant, a vendor catalog and a file preview, and `home` opens the first step with that data. +- `WARMBLY_DEBUG_PHASE`: opens a screen from before the app, leaving the stored session alone: `launching` holds the splash, `signedout` shows sign-in, `onboarding` the first-run questions and `workspace` the create-a-workspace step. With `signedout`, `WARMBLY_DEBUG_AUTH` opens a later sign-in step (`signup`, `password`, `verify`, `twofa` or `reset`), or `server` to open the Server sheet; with `onboarding`, `WARMBLY_DEBUG_ONBOARDING` opens `referral`, `role` or `team`. +- `WARMBLY_DEBUG_ASSISTANT`: opens Remie on launch. `1` opens it empty, any other text is sent as the first message, and `demo` or `demo-approval` plays a scripted run (tool steps, a streamed answer and, for the second, an approval card) without calling the AI provider, which the local stack does not configure. + +`simctl` forwards variables prefixed with `SIMCTL_CHILD_`: + +``` +SIMCTL_CHILD_WARMBLY_DEBUG_TOKEN="$(cat token.json)" SIMCTL_CHILD_WARMBLY_DEBUG_TAB=inbox \ + xcrun simctl launch booted com.warmbly.app +xcrun simctl io booted screenshot inbox.png +``` Conventions to keep the code consistent: - New Codable models declare explicit `CodingKeys` (the decoder does not convert snake_case) and copy the Go json tags exactly. Prefer optional fields so a missing key never fails a whole screen. - Reach the app graph with `@Environment(AppEnvironment.self) private var env` → `env.api` (the `/v1` client), `env.session`, `env.realtime`, `env.badges`. - Every list: load with `.task`, reload on the matching `env.realtime.pulse(for:)`, and support `.refreshable`. Gate mutating actions with `env.session.can(.somePermission)`. - Copy is sentence case, terse, and avoids em dashes. +- Style with the `WTheme` tokens and `DesignSystem/` primitives, never raw `.white`/`.black` or system grouped backgrounds: `WTheme.canvas`/`raised`/`fill` surfaces, `hairline`/`hairlineStrong` borders, `ink`/`inkSecondary`/`inkTertiary` text, `InkButtonStyle` for the primary action, `WFont.meta` for meta (`WFont.code` only for real code), sentence case everywhere (no uppercased labels). List tabs use `GlassHeaderBar` and drawers `DrawerWorkspaceHeader`. Settings pages sit on `WTheme.groupedPage` with `groupedCard` cards (`SettingsPage`, `SettingsSection`, and `coverCard` from `SettingsCoverKit.swift`); a list that needs swipe actions is an `.insetGrouped` `List` with `CoverSectionHeader` headers instead. Sky (`WTheme.accent`) is for data and live state, not chrome. ## Troubleshooting @@ -116,8 +143,9 @@ A real typecheck happens when you build in Xcode. Conventions to keep the code c | Stuck on the workspace picker | Your account has 0 or several orgs. Create one or pick one; a fresh login always starts with no org selected and the app switches you in. | | Lists never update live | Realtime not connected. See [Verifying realtime works](#verifying-realtime-works). | | A screen is empty for a teammate account | Permission-gated. Some data (e.g. campaign analytics) is owner-only server-side; the app surfaces this rather than erroring. | -| Captcha error on login | The bypass token was cleared. Reset it to `warmbly-local-turnstile-bypass` in Connection settings. | +| Captcha error on login | The server verifies a captcha and got neither an App Attest proof nor a bypass token. In the simulator against a dev backend, set its `TURNSTILE_BYPASS_TOKEN` under Advanced; on a device, the server needs `APP_ATTEST_APP_IDS` naming the app (and `APP_ATTEST_ALLOW_DEVELOPMENT=true` for an Xcode build); a self-host can also run with `CAPTCHA_PROVIDER=none`. | +| "Couldn't verify this copy of the app" | The server refused the App Attest proof (`app_attest_failed`). A development-signed build needs `APP_ATTEST_ALLOW_DEVELOPMENT=true`, and `APP_ATTEST_APP_IDS` must name `.com.warmbly.app` for the team that signed it. | ## What stays on the web -By design, a few flows link out instead of being reimplemented on mobile: mailbox OAuth connect (Gmail/Outlook), campaign sequence editing, and plan changes/billing management. SMTP/IMAP mailbox connect does work natively. See the [README](README.md#known-limits) for the full list. +By design, a few flows link out instead of being reimplemented on mobile: setting up a new admin grant, campaign sequence editing, and plan changes/billing management. Mailbox connect works natively with app passwords, existing admin grants (connecting a grant's mailboxes asks for a recent sign-in through `POST /v1/auth/reauth`), inbox vendor keys, file and pasted imports (`POST /emails/imports/preview`, then `POST /emails/imports`) and Warmbly Cloud workspace mailboxes; per-mailbox Google or Microsoft sign-in is not offered. See the [README](README.md#known-limits) for the full list. diff --git a/ios/README.md b/ios/README.md index 6f2f81b48..e9b334fa0 100644 --- a/ios/README.md +++ b/ios/README.md @@ -1,6 +1,6 @@ # Warmbly iOS -Native SwiftUI companion app for the Warmbly dashboard. Covers the mobile-relevant dashboard surface: unified inbox (read, reply, snooze), mailbox accounts with warmup monitoring and controls, campaign monitoring with start/stop, contacts with the 360 view, analytics, deliverability, CRM (deals, tasks, meetings), templates, team management, notifications, and profile/billing views. Realtime by default: the app joins the Phoenix websocket and refreshes lists live off the same `AUDIT_CREATED` spine the web uses, including org presence (who is viewing/editing which record). +Native SwiftUI companion app for the Warmbly dashboard. Covers the mobile-relevant dashboard surface: unified inbox (read, reply, snooze, review the inbox agent's drafted replies and send them as written or refine them in the full composer with its AI tools, draft a reply from the thread with AI or ask Remie to summarize it, and the web's sidebar: Mail folders, Views, Mailboxes, Labels and Tags as sections that fold and move, with Favorites, hidden and reordered rows synced to the account through `unibox_rail`), mailbox accounts with warmup monitoring and controls (every mailbox shows its profile photo and the service that runs it, from Google Workspace and Microsoft 365 to Zoho or the vendor it was imported from), campaign monitoring with start/stop (each row shows how far through its leads it is, the campaign page splits its leads by outcome and charts sends, opens and replies over 7 to 90 days in the same scrubbable chart as Home, and the drawer filters by status, manages folders and sorts), contacts (each row shows the last thing that happened with them, rings the avatar on a fresh reply or click, groups under letters or dates by the sort, and swipes to label, add to a campaign, unsubscribe or delete) with the 360 view (an outreach journey from sent to replied and one-tap email, campaign and note actions), analytics (a scrubbable trend, rates against the previous period, a reply funnel, deliverability rates against their safe ranges, warmup against its ramp and where it landed, and every mailbox's health), CRM (a pipeline card with the win rate and each stage's value, deals you swipe to win; tasks with a ring for today and a check that ticks itself off, celebrating when the day is clear; meetings with a live countdown to the next call and a join button, grouped by day), templates (with their merge fields and an email-style preview), a live activity timeline of who changed what, and settings: a hub showing your workspace at a glance (mailbox health, the week's sends, open deals, tasks and meetings, members online) beside profile, notifications, security, billing and appearance, each its own page (a name card that saves in place, a password change with a strength meter, every signed-in device, sign-in protection, the plan with its price, renewal and what it includes, usage against the limits the server enforces, AI credit spend by day and by feature, the other plans side by side and a referral code to copy or share, the roster with who is online and pending invites, and an inbox of alerts grouped by day with its delivery switches). Realtime by default: the app joins the Phoenix websocket and refreshes lists live off the same `AUDIT_CREATED` spine the web uses, including org presence (who is viewing/editing which record). ## Requirements @@ -11,9 +11,7 @@ Native SwiftUI companion app for the Warmbly dashboard. Covers the mobile-releva 1. Open `ios/Warmbly.xcodeproj` in Xcode. 2. Select the Warmbly scheme and an iOS 26 simulator, then run. -3. On the login screen, tap the gear to check the connection settings: - - Server origin: `http://localhost:8080` by default (the app calls `/v1`). On a physical device, use your Mac's LAN address. - - Captcha: local dev backends accept the pre-filled Turnstile bypass token. Against production you must supply a real Turnstile token; password login is captcha-gated. +3. On the login screen, tap the gear and pick **Local development** (every build starts on Warmbly Cloud). That points the app at `http://localhost:8080` (it calls `/v1`) and fills in the captcha bypass token local backends accept, since the simulator can't use App Attest. On a physical device, choose **Self-hosted** and enter your Mac's LAN address instead. 4. Sign in with a seeded user (`make seed` creates `dev@warmbly.com` / `password123`) or register from the app. Password login is two-step: credentials, then an emailed 6-digit code (visible in Mailpit at `http://localhost:18025` during local dev). For a full local development walkthrough (running the stack, the first-run flow, physical devices, and troubleshooting), see [DEVELOPMENT.md](DEVELOPMENT.md). @@ -23,13 +21,12 @@ For a full local development walkthrough (running the stack, the first-run flow, - `Warmbly/Core/Networking/APIClient.swift`: async client for the `/v1` API. Bearer auth, single-flight token refresh (refresh tokens are single-use server-side), one retry on 401, `Idempotency-Key` on mutating calls that need it. - `Warmbly/Core/Session/SessionStore.swift`: login (password + OTP + optional TOTP), registration, org selection. The active org is server-side session state, so the store re-POSTs `/organization/switch/:id` once per launch, mirroring the web's OrgGate. - `Warmbly/Core/Realtime/`: a Phoenix Channels client speaking the V1 object-frame serializer (`vsn=1.0.0`) over `URLSessionWebSocketTask`, with 25s heartbeats, an 8s reply watchdog, resume via `last_seq`, and jittered reconnect backoff. `RealtimeService` maps events onto invalidation "pulses" that views observe, and tracks org presence. -- `Warmbly/DesignSystem/`: the brand translated to native iOS. Slate neutrals with sky as the single accent, status tones matching the web (emerald running, amber paused, rose issues), eyebrow labels, thin tabular stat numbers, presence avatars with amber/emerald rings. +- `Warmbly/DesignSystem/`: the marketing site's design language translated to native iOS. A clean canvas with hairline borders instead of shadows, near-black pill actions (`InkButtonStyle`, `OutlineButtonStyle`), medium-weight display type with tight tracking and the site's two-tone headings (`TwoToneTitle`), system-font meta text with tabular digits (`WFont.meta`; `WFont.code` only for real code), sentence-case section labels (`EyebrowLabel`), one Liquid Glass header for every list tab (`GlassHeaderBar`), one workspace header for every drawer (`DrawerWorkspaceHeader`), one scrubbable sends, opens and replies chart (`TrendChart`, with `CountUpText` and the staggered `reveal` entrance), sky kept for data and live state, status tones matching the web (emerald running, amber paused, rose issues), and the site's photo frames (`PhotoFrame`). Sign-in and onboarding sit on the brand backdrop (`SkyBackdrop`): the site's sheer sky photo in light mode, a dark sky in dark mode, with `WTheme.onSky` for what is drawn on it. The launch screen and splash follow the theme too. Every color is a light/dark pair in `WTheme`. - `Warmbly/Features/`: one directory per feature; each screen loads with `.task`, reloads on realtime pulses, and supports pull-to-refresh. The Xcode project uses `PBXFileSystemSynchronizedRootGroup`, so new Swift files under `Warmbly/` are picked up automatically; the pbxproj never needs editing for added files. ## Known limits -- Mailbox connect (OAuth/SMTP) and campaign sequence editing stay on the web; the app links out and says so. -- Sign in with Apple/Google is not offered because the backend exposes no social login endpoints. -- Light appearance first, matching the production web dashboard; colors are defined as dynamic pairs so a dark palette can land later. +- Adding a mailbox takes an app password (Gmail walks through creating one; Zoho, Yahoo, iCloud, Fastmail and other common hosts get their servers filled in from the address), an admin grant over a whole Workspace domain or Microsoft 365 organization, an inbox vendor's API key (InboxKit, Zapmail, Mailforge and the rest of the dashboard's catalog), a CSV, spreadsheet or pasted list, or a mailbox already on a linked Warmbly Cloud workspace. Bulk sources let you pick or preview the mailboxes first, then follow the import live. There is no per-mailbox Google or Microsoft sign-in, and setting up a new admin grant happens on the dashboard, since it needs the provider's admin console. Campaign sequence editing also stays on the web. +- The app follows the system appearance; dark mode uses zinc neutrals on true black. diff --git a/ios/Warmbly/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/Warmbly/Assets.xcassets/AccentColor.colorset/Contents.json index 40ac7eff5..aca065bc1 100644 --- a/ios/Warmbly/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/ios/Warmbly/Assets.xcassets/AccentColor.colorset/Contents.json @@ -1,38 +1,38 @@ { - "colors" : [ + "colors": [ { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0xC7", - "green" : "0x84", - "red" : "0x02" + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x2A", + "green": "0x17", + "red": "0x0F" } }, - "idiom" : "universal" + "idiom": "universal" }, { - "appearances" : [ + "appearances": [ { - "appearance" : "luminosity", - "value" : "dark" + "appearance": "luminosity", + "value": "dark" } ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0xF8", - "green" : "0xBD", - "red" : "0x38" + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xFA", + "green": "0xFA", + "red": "0xFA" } }, - "idiom" : "universal" + "idiom": "universal" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } -} +} \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Cloud1.imageset/cloud-1.png b/ios/Warmbly/Assets.xcassets/Cloud1.imageset/cloud-1.png deleted file mode 100644 index e2c857a66..000000000 Binary files a/ios/Warmbly/Assets.xcassets/Cloud1.imageset/cloud-1.png and /dev/null differ diff --git a/ios/Warmbly/Assets.xcassets/Cloud3.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Cloud3.imageset/Contents.json deleted file mode 100644 index 4ea00c035..000000000 --- a/ios/Warmbly/Assets.xcassets/Cloud3.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "cloud-3.png", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ios/Warmbly/Assets.xcassets/Cloud3.imageset/cloud-3.png b/ios/Warmbly/Assets.xcassets/Cloud3.imageset/cloud-3.png deleted file mode 100644 index 4774e2a6c..000000000 Binary files a/ios/Warmbly/Assets.xcassets/Cloud3.imageset/cloud-3.png and /dev/null differ diff --git a/ios/Warmbly/Assets.xcassets/Cloud4.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Cloud4.imageset/Contents.json deleted file mode 100644 index eb836bbe8..000000000 --- a/ios/Warmbly/Assets.xcassets/Cloud4.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "cloud-4.png", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ios/Warmbly/Assets.xcassets/Cloud4.imageset/cloud-4.png b/ios/Warmbly/Assets.xcassets/Cloud4.imageset/cloud-4.png deleted file mode 100644 index a2f0ec7c5..000000000 Binary files a/ios/Warmbly/Assets.xcassets/Cloud4.imageset/cloud-4.png and /dev/null differ diff --git a/ios/Warmbly/Assets.xcassets/Host-aol.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Host-aol.imageset/Contents.json new file mode 100644 index 000000000..67e5147db --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Host-aol.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "aol.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Host-aol.imageset/aol.png b/ios/Warmbly/Assets.xcassets/Host-aol.imageset/aol.png new file mode 100644 index 000000000..781fae5c7 Binary files /dev/null and b/ios/Warmbly/Assets.xcassets/Host-aol.imageset/aol.png differ diff --git a/ios/Warmbly/Assets.xcassets/Host-fastmail.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Host-fastmail.imageset/Contents.json new file mode 100644 index 000000000..04a6cf078 --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Host-fastmail.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "fastmail.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Host-fastmail.imageset/fastmail.png b/ios/Warmbly/Assets.xcassets/Host-fastmail.imageset/fastmail.png new file mode 100644 index 000000000..a1d054399 Binary files /dev/null and b/ios/Warmbly/Assets.xcassets/Host-fastmail.imageset/fastmail.png differ diff --git a/ios/Warmbly/Assets.xcassets/Host-gmx.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Host-gmx.imageset/Contents.json new file mode 100644 index 000000000..b6668b844 --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Host-gmx.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "gmx.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Host-gmx.imageset/gmx.png b/ios/Warmbly/Assets.xcassets/Host-gmx.imageset/gmx.png new file mode 100644 index 000000000..9736f7ca3 Binary files /dev/null and b/ios/Warmbly/Assets.xcassets/Host-gmx.imageset/gmx.png differ diff --git a/ios/Warmbly/Assets.xcassets/Host-godaddy.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Host-godaddy.imageset/Contents.json new file mode 100644 index 000000000..044947a6c --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Host-godaddy.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "godaddy.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Host-godaddy.imageset/godaddy.png b/ios/Warmbly/Assets.xcassets/Host-godaddy.imageset/godaddy.png new file mode 100644 index 000000000..1ffdbd85c Binary files /dev/null and b/ios/Warmbly/Assets.xcassets/Host-godaddy.imageset/godaddy.png differ diff --git a/ios/Warmbly/Assets.xcassets/Host-hostinger.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Host-hostinger.imageset/Contents.json new file mode 100644 index 000000000..3b033b42d --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Host-hostinger.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "hostinger.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Host-hostinger.imageset/hostinger.png b/ios/Warmbly/Assets.xcassets/Host-hostinger.imageset/hostinger.png new file mode 100644 index 000000000..6f69412e6 Binary files /dev/null and b/ios/Warmbly/Assets.xcassets/Host-hostinger.imageset/hostinger.png differ diff --git a/ios/Warmbly/Assets.xcassets/Host-icloud.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Host-icloud.imageset/Contents.json new file mode 100644 index 000000000..e6af0ef96 --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Host-icloud.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "icloud.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Host-icloud.imageset/icloud.png b/ios/Warmbly/Assets.xcassets/Host-icloud.imageset/icloud.png new file mode 100644 index 000000000..a48a23036 Binary files /dev/null and b/ios/Warmbly/Assets.xcassets/Host-icloud.imageset/icloud.png differ diff --git a/ios/Warmbly/Assets.xcassets/Host-ionos.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Host-ionos.imageset/Contents.json new file mode 100644 index 000000000..1d66a33a4 --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Host-ionos.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "ionos.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Host-ionos.imageset/ionos.svg b/ios/Warmbly/Assets.xcassets/Host-ionos.imageset/ionos.svg new file mode 100644 index 000000000..bfef3b38a --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Host-ionos.imageset/ionos.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Host-migadu.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Host-migadu.imageset/Contents.json new file mode 100644 index 000000000..35f430a22 --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Host-migadu.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "migadu.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Host-migadu.imageset/migadu.png b/ios/Warmbly/Assets.xcassets/Host-migadu.imageset/migadu.png new file mode 100644 index 000000000..d71e9f9a6 Binary files /dev/null and b/ios/Warmbly/Assets.xcassets/Host-migadu.imageset/migadu.png differ diff --git a/ios/Warmbly/Assets.xcassets/Host-namecheap.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Host-namecheap.imageset/Contents.json new file mode 100644 index 000000000..fe80870ce --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Host-namecheap.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "namecheap.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Host-namecheap.imageset/namecheap.png b/ios/Warmbly/Assets.xcassets/Host-namecheap.imageset/namecheap.png new file mode 100644 index 000000000..9c80657bd Binary files /dev/null and b/ios/Warmbly/Assets.xcassets/Host-namecheap.imageset/namecheap.png differ diff --git a/ios/Warmbly/Assets.xcassets/Host-ovh.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Host-ovh.imageset/Contents.json new file mode 100644 index 000000000..f3556ca05 --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Host-ovh.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "ovh.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Host-ovh.imageset/ovh.svg b/ios/Warmbly/Assets.xcassets/Host-ovh.imageset/ovh.svg new file mode 100644 index 000000000..8354a4199 --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Host-ovh.imageset/ovh.svg @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Host-proton.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Host-proton.imageset/Contents.json new file mode 100644 index 000000000..43e867429 --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Host-proton.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "proton.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Host-proton.imageset/proton.png b/ios/Warmbly/Assets.xcassets/Host-proton.imageset/proton.png new file mode 100644 index 000000000..36d5a714c Binary files /dev/null and b/ios/Warmbly/Assets.xcassets/Host-proton.imageset/proton.png differ diff --git a/ios/Warmbly/Assets.xcassets/Host-purelymail.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Host-purelymail.imageset/Contents.json new file mode 100644 index 000000000..6de5f5fae --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Host-purelymail.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "purelymail.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Host-purelymail.imageset/purelymail.png b/ios/Warmbly/Assets.xcassets/Host-purelymail.imageset/purelymail.png new file mode 100644 index 000000000..fcdf3503a Binary files /dev/null and b/ios/Warmbly/Assets.xcassets/Host-purelymail.imageset/purelymail.png differ diff --git a/ios/Warmbly/Assets.xcassets/Host-rackspace.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Host-rackspace.imageset/Contents.json new file mode 100644 index 000000000..ce4dafe9a --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Host-rackspace.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "rackspace.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Host-rackspace.imageset/rackspace.png b/ios/Warmbly/Assets.xcassets/Host-rackspace.imageset/rackspace.png new file mode 100644 index 000000000..e52547618 Binary files /dev/null and b/ios/Warmbly/Assets.xcassets/Host-rackspace.imageset/rackspace.png differ diff --git a/ios/Warmbly/Assets.xcassets/Host-yahoo.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Host-yahoo.imageset/Contents.json new file mode 100644 index 000000000..9681fdc3f --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Host-yahoo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "yahoo.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Host-yahoo.imageset/yahoo.png b/ios/Warmbly/Assets.xcassets/Host-yahoo.imageset/yahoo.png new file mode 100644 index 000000000..0f69dc99e Binary files /dev/null and b/ios/Warmbly/Assets.xcassets/Host-yahoo.imageset/yahoo.png differ diff --git a/ios/Warmbly/Assets.xcassets/Host-yandex.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Host-yandex.imageset/Contents.json new file mode 100644 index 000000000..dd3058b32 --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Host-yandex.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "yandex.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Host-yandex.imageset/yandex.png b/ios/Warmbly/Assets.xcassets/Host-yandex.imageset/yandex.png new file mode 100644 index 000000000..d1b575326 Binary files /dev/null and b/ios/Warmbly/Assets.xcassets/Host-yandex.imageset/yandex.png differ diff --git a/ios/Warmbly/Assets.xcassets/Host-zoho.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Host-zoho.imageset/Contents.json new file mode 100644 index 000000000..c8229a66c --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Host-zoho.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "zoho.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Host-zoho.imageset/zoho.png b/ios/Warmbly/Assets.xcassets/Host-zoho.imageset/zoho.png new file mode 100644 index 000000000..5a7645391 Binary files /dev/null and b/ios/Warmbly/Assets.xcassets/Host-zoho.imageset/zoho.png differ diff --git a/ios/Warmbly/Assets.xcassets/LaunchBackground.colorset/Contents.json b/ios/Warmbly/Assets.xcassets/LaunchBackground.colorset/Contents.json index d2b73c2a2..8289d0941 100644 --- a/ios/Warmbly/Assets.xcassets/LaunchBackground.colorset/Contents.json +++ b/ios/Warmbly/Assets.xcassets/LaunchBackground.colorset/Contents.json @@ -5,9 +5,27 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xF8", - "green" : "0xBD", - "red" : "0x38" + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x0B", + "green" : "0x09", + "red" : "0x09" } }, "idiom" : "universal" diff --git a/ios/Warmbly/Assets.xcassets/Cloud1.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/SheerSky.imageset/Contents.json similarity index 77% rename from ios/Warmbly/Assets.xcassets/Cloud1.imageset/Contents.json rename to ios/Warmbly/Assets.xcassets/SheerSky.imageset/Contents.json index 390226816..2940b1483 100644 --- a/ios/Warmbly/Assets.xcassets/Cloud1.imageset/Contents.json +++ b/ios/Warmbly/Assets.xcassets/SheerSky.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "cloud-1.png", + "filename" : "sheer-sky.jpg", "idiom" : "universal" } ], diff --git a/ios/Warmbly/Assets.xcassets/SheerSky.imageset/sheer-sky.jpg b/ios/Warmbly/Assets.xcassets/SheerSky.imageset/sheer-sky.jpg new file mode 100644 index 000000000..398218e36 Binary files /dev/null and b/ios/Warmbly/Assets.xcassets/SheerSky.imageset/sheer-sky.jpg differ diff --git a/ios/Warmbly/Assets.xcassets/Vendor-cheapinboxes.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Vendor-cheapinboxes.imageset/Contents.json new file mode 100644 index 000000000..83534b49e --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Vendor-cheapinboxes.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "cheapinboxes.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Vendor-cheapinboxes.imageset/cheapinboxes.png b/ios/Warmbly/Assets.xcassets/Vendor-cheapinboxes.imageset/cheapinboxes.png new file mode 100644 index 000000000..6c3f7e705 Binary files /dev/null and b/ios/Warmbly/Assets.xcassets/Vendor-cheapinboxes.imageset/cheapinboxes.png differ diff --git a/ios/Warmbly/Assets.xcassets/Vendor-inboxkit.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Vendor-inboxkit.imageset/Contents.json new file mode 100644 index 000000000..7f39dba4f --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Vendor-inboxkit.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "inboxkit.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Vendor-inboxkit.imageset/inboxkit.png b/ios/Warmbly/Assets.xcassets/Vendor-inboxkit.imageset/inboxkit.png new file mode 100644 index 000000000..fca618f9b Binary files /dev/null and b/ios/Warmbly/Assets.xcassets/Vendor-inboxkit.imageset/inboxkit.png differ diff --git a/ios/Warmbly/Assets.xcassets/Vendor-infraforge.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Vendor-infraforge.imageset/Contents.json new file mode 100644 index 000000000..7c5b86e32 --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Vendor-infraforge.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "infraforge.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Vendor-infraforge.imageset/infraforge.png b/ios/Warmbly/Assets.xcassets/Vendor-infraforge.imageset/infraforge.png new file mode 100644 index 000000000..a209b025a Binary files /dev/null and b/ios/Warmbly/Assets.xcassets/Vendor-infraforge.imageset/infraforge.png differ diff --git a/ios/Warmbly/Assets.xcassets/Vendor-maildoso.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Vendor-maildoso.imageset/Contents.json new file mode 100644 index 000000000..dccd1ad98 --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Vendor-maildoso.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "maildoso.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Vendor-maildoso.imageset/maildoso.png b/ios/Warmbly/Assets.xcassets/Vendor-maildoso.imageset/maildoso.png new file mode 100644 index 000000000..db52a612b Binary files /dev/null and b/ios/Warmbly/Assets.xcassets/Vendor-maildoso.imageset/maildoso.png differ diff --git a/ios/Warmbly/Assets.xcassets/Vendor-mailforge.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Vendor-mailforge.imageset/Contents.json new file mode 100644 index 000000000..800204c97 --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Vendor-mailforge.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "mailforge.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Vendor-mailforge.imageset/mailforge.png b/ios/Warmbly/Assets.xcassets/Vendor-mailforge.imageset/mailforge.png new file mode 100644 index 000000000..c0d2dac65 Binary files /dev/null and b/ios/Warmbly/Assets.xcassets/Vendor-mailforge.imageset/mailforge.png differ diff --git a/ios/Warmbly/Assets.xcassets/Vendor-scaledmail.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Vendor-scaledmail.imageset/Contents.json new file mode 100644 index 000000000..9a62b2a05 --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Vendor-scaledmail.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "scaledmail.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Vendor-scaledmail.imageset/scaledmail.png b/ios/Warmbly/Assets.xcassets/Vendor-scaledmail.imageset/scaledmail.png new file mode 100644 index 000000000..ca9e91ad5 Binary files /dev/null and b/ios/Warmbly/Assets.xcassets/Vendor-scaledmail.imageset/scaledmail.png differ diff --git a/ios/Warmbly/Assets.xcassets/Vendor-zapmail.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/Vendor-zapmail.imageset/Contents.json new file mode 100644 index 000000000..0c9941bc8 --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/Vendor-zapmail.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "zapmail.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Warmbly/Assets.xcassets/Vendor-zapmail.imageset/zapmail.png b/ios/Warmbly/Assets.xcassets/Vendor-zapmail.imageset/zapmail.png new file mode 100644 index 000000000..589d1a8fa Binary files /dev/null and b/ios/Warmbly/Assets.xcassets/Vendor-zapmail.imageset/zapmail.png differ diff --git a/ios/Warmbly/Core/AppConfig.swift b/ios/Warmbly/Core/AppConfig.swift index 8a9a3b78f..679372074 100644 --- a/ios/Warmbly/Core/AppConfig.swift +++ b/ios/Warmbly/Core/AppConfig.swift @@ -2,18 +2,18 @@ import Foundation /// Server configuration, persisted in UserDefaults (thread-safe) so it can be /// read from any isolation domain without shared mutable state. Values are -/// editable from the login screen's connection sheet. +/// editable from the sign-in screen's Server sheet. enum AppConfig { - /// Shipped builds default to the hosted SaaS API; debug builds default to - /// the local dev backend. Self-hosters override the origin in-app, so one - /// binary serves the SaaS, a self-hosted instance, or a local stack. - #if DEBUG - static let defaultOrigin = "http://localhost:8080" - static let defaultTurnstileToken = "warmbly-local-turnstile-bypass" - #else - static let defaultOrigin = "https://api.warmbly.com" + static let cloudOrigin = "https://api.warmbly.com" + static let localOrigin = "http://localhost:8080" + /// The captcha value local dev backends accept in place of a widget token. + static let localTurnstileToken = "warmbly-local-turnstile-bypass" + + /// Every build signs in to Warmbly Cloud until someone picks another + /// server, so one binary serves the SaaS, a self-hosted instance or a + /// local stack. + static let defaultOrigin = cloudOrigin static let defaultTurnstileToken = "" - #endif /// Bare origin, no path. The `/v1` prefix is applied in APIClient, /// mirroring the web's `API_BASE_URL = ${API_URL}/v1`. @@ -43,6 +43,18 @@ enum AppConfig { set { UserDefaults.standard.set(newValue, forKey: "last_org_id") } } + /// The web dashboard for a path: the hosted app beside the API, else the + /// same origin a self-hosted instance serves it from. + static func dashboardURL(_ path: String) -> URL? { + var origin = serverOrigin.trimmingCharacters(in: .whitespaces) + if origin.contains("://api.") { + origin = origin.replacingOccurrences(of: "://api.", with: "://app.") + } else if origin.hasSuffix("localhost:8080") { + origin = origin.replacingOccurrences(of: "localhost:8080", with: "localhost:5173") + } + return URL(string: origin + path) + } + static var apiBaseURL: URL? { URL(string: serverOrigin.trimmingCharacters(in: .whitespaces))?.appending(path: "v1") } diff --git a/ios/Warmbly/Core/Models/CoreModels.swift b/ios/Warmbly/Core/Models/CoreModels.swift index 5a330ede0..472c47e0d 100644 --- a/ios/Warmbly/Core/Models/CoreModels.swift +++ b/ios/Warmbly/Core/Models/CoreModels.swift @@ -344,8 +344,10 @@ struct OrgCounts: Codable, Sendable { var totalContacts: Int? var totalMembers: Int? var emailAccounts: Int? + var emailsSentToday: Int? enum CodingKeys: String, CodingKey { + case emailsSentToday = "emails_sent_today" case totalCampaigns = "total_campaigns" case activeCampaigns = "active_campaigns" case totalContacts = "total_contacts" diff --git a/ios/Warmbly/Core/Networking/APIClient.swift b/ios/Warmbly/Core/Networking/APIClient.swift index 96a173c7f..7a7f4d4f5 100644 --- a/ios/Warmbly/Core/Networking/APIClient.swift +++ b/ios/Warmbly/Core/Networking/APIClient.swift @@ -22,6 +22,12 @@ actor APIClient { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 30 session = URLSession(configuration: config) + #if DEBUG + // Lets scripted simulator runs start signed in (SIMCTL_CHILD_WARMBLY_DEBUG_TOKEN). + if let raw = ProcessInfo.processInfo.environment["WARMBLY_DEBUG_TOKEN"], !raw.isEmpty { + KeychainStore.set(raw, for: Self.tokenKey) + } + #endif if let raw = KeychainStore.get(Self.tokenKey), let stored = try? Self.decoder.decode(AuthToken.self, from: Data(raw.utf8)) { token = stored @@ -286,6 +292,41 @@ actor APIClient { try await request(.delete, path, bodyData: try Self.encoder.encode(body)) } + /// A file part of a multipart upload. + struct UploadFile { + var field: String + var filename: String + var mimeType: String + var data: Data + } + + /// POSTs `multipart/form-data`: text fields plus an optional file. + func postMultipart(_ path: String, fields: [String: String], file: UploadFile? = nil, timeout: TimeInterval = 60) async throws -> T { + let boundary = "warmbly-\(UUID().uuidString)" + var body = Data() + func append(_ string: String) { body.append(Data(string.utf8)) } + for (name, value) in fields.sorted(by: { $0.key < $1.key }) { + append("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\"\r\n\r\n\(value)\r\n") + } + if let file { + append("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(file.field)\"; filename=\"\(file.filename)\"\r\nContent-Type: \(file.mimeType)\r\n\r\n") + body.append(file.data) + append("\r\n") + } + append("--\(boundary)--\r\n") + + var request = try buildRequest(.post, path, query: [:], body: body, idempotencyKey: nil) + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = timeout + let (data, http) = try await execute(request, authorized: true) + try validate(data, http) + do { + return try Self.decoder.decode(T.self, from: data) + } catch { + throw APIError.decoding(error) + } + } + // MARK: - Server-sent events /// POSTs and streams the response as SSE, yielding each `data:` payload. diff --git a/ios/Warmbly/Core/Push/PushManager.swift b/ios/Warmbly/Core/Push/PushManager.swift index 4355172de..173dfa824 100644 --- a/ios/Warmbly/Core/Push/PushManager.swift +++ b/ios/Warmbly/Core/Push/PushManager.swift @@ -51,6 +51,10 @@ final class PushManager: NSObject { } private func requestAndRegister() async { + #if DEBUG + // Scripted simulator runs have no one to answer the permission prompt. + if ProcessInfo.processInfo.environment["WARMBLY_DEBUG_TOKEN"] != nil { return } + #endif let center = UNUserNotificationCenter.current() var status = await center.notificationSettings().authorizationStatus if status == .notDetermined { diff --git a/ios/Warmbly/Core/Realtime/RealtimeService.swift b/ios/Warmbly/Core/Realtime/RealtimeService.swift index 167f1f7ea..2f75d07e3 100644 --- a/ios/Warmbly/Core/Realtime/RealtimeService.swift +++ b/ios/Warmbly/Core/Realtime/RealtimeService.swift @@ -361,6 +361,7 @@ final class RealtimeService { } if name == "EMAIL_UPDATED" || name == "EMAIL_DELETED" { return [.unibox, .analytics] } if name == "EMAIL_REPLIED" { return [.campaigns, .analytics, .unibox] } + if name == "AI_DRAFT_READY" { return [.unibox] } if name == "EMAIL_SENT" || name == "EMAIL_OPENED" || name == "EMAIL_CLICKED" || name == "EMAIL_FAILED" || name == "EMAIL_BOUNCED" || name.contains("TASK_PROGRESS") { return [.campaigns, .analytics] diff --git a/ios/Warmbly/Core/Security/AppAttestClient.swift b/ios/Warmbly/Core/Security/AppAttestClient.swift new file mode 100644 index 000000000..2e955f156 --- /dev/null +++ b/ios/Warmbly/Core/Security/AppAttestClient.swift @@ -0,0 +1,109 @@ +import CryptoKit +import DeviceCheck +import Foundation + +/// Proves an email sign-in comes from this app on a real device (App Attest), +/// which the server accepts in place of a captcha. One key per server. +actor AppAttestClient { + /// The `app_attest` object sent with a protected auth call. + struct Proof: Encodable, Sendable { + let keyID: String + let assertion: String + let challenge: String + + enum CodingKeys: String, CodingKey { + case keyID = "key_id" + case assertion, challenge + } + } + + /// The request a proof is made for; the server checks it matches. + enum Action: String, Sendable { + case login + case register + case resetPassword = "reset_password" + } + + enum Failure: LocalizedError { + case unsupported + + var errorDescription: String? { + "This device can't verify the app. Sign in with Apple or Google instead." + } + } + + private struct Challenge: Decodable { let challenge: String } + + private struct KeyBody: Encodable { + let keyID: String + let attestation: String + let challenge: String + + enum CodingKeys: String, CodingKey { + case keyID = "key_id" + case attestation, challenge + } + } + + private let api: APIClient + private var registering: Task? + + init(api: APIClient) { + self.api = api + } + + static var isSupported: Bool { DCAppAttestService.shared.isSupported } + + private static var storageKey: String { "app_attest_key." + AppConfig.serverOrigin } + + /// Signs a fresh challenge for action with this install's key, registering it first if needed. + func proof(for action: Action) async throws -> Proof { + guard Self.isSupported else { throw Failure.unsupported } + let keyID = try await registeredKey() + let challenge = try await fetchChallenge() + let clientData = Data("warmbly-app-attest/v1\n\(action.rawValue)\n\(challenge)".utf8) + do { + let assertion = try await DCAppAttestService.shared.generateAssertion(keyID, clientDataHash: Self.hash(clientData)) + return Proof(keyID: keyID, assertion: assertion.base64EncodedString(), challenge: challenge) + } catch let error as DCError where error.code == .invalidKey { + // The system dropped the key (a restore onto another device); the next proof makes a new one. + forgetKey() + throw error + } + } + + /// Drops the stored key, so the next proof registers a new one. + func forgetKey() { + UserDefaults.standard.removeObject(forKey: Self.storageKey) + } + + private func registeredKey() async throws -> String { + if let keyID = UserDefaults.standard.string(forKey: Self.storageKey) { return keyID } + if let registering { return try await registering.value } + let task = Task { try await self.register() } + registering = task + defer { registering = nil } + let keyID = try await task.value + UserDefaults.standard.set(keyID, forKey: Self.storageKey) + return keyID + } + + private func register() async throws -> String { + let service = DCAppAttestService.shared + let keyID = try await service.generateKey() + let challenge = try await fetchChallenge() + let attestation = try await service.attestKey(keyID, clientDataHash: Self.hash(Data(challenge.utf8))) + let body = KeyBody(keyID: keyID, attestation: attestation.base64EncodedString(), challenge: challenge) + let _: EmptyBody = try await api.post("auth/app-attest/keys", body: body, authorized: false) + return keyID + } + + private func fetchChallenge() async throws -> String { + let response: Challenge = try await api.post("auth/app-attest/challenge", authorized: false) + return response.challenge + } + + private static func hash(_ data: Data) -> Data { + Data(SHA256.hash(data: data)) + } +} diff --git a/ios/Warmbly/Core/Session/SessionStore.swift b/ios/Warmbly/Core/Session/SessionStore.swift index 71d74dd68..9f10a2cee 100644 --- a/ios/Warmbly/Core/Session/SessionStore.swift +++ b/ios/Warmbly/Core/Session/SessionStore.swift @@ -29,6 +29,7 @@ enum LoginOutcome { @Observable final class SessionStore { let api: APIClient + private let appAttest: AppAttestClient private(set) var phase: SessionPhase = .launching /// True when the last sign-out was forced by an expired/revoked session @@ -46,6 +47,7 @@ final class SessionStore { init(api: APIClient) { self.api = api + self.appAttest = AppAttestClient(api: api) // The outer task is one-shot registration (strong self is fine); the // stored handler captures weakly to avoid an APIClient<->store cycle. Task { [api, self] in @@ -68,6 +70,27 @@ final class SessionStore { // MARK: - Launch func bootstrap() async { + #if DEBUG + // Scripted runs can open a signed-out or first-run screen; the stored session is left alone. + switch ProcessInfo.processInfo.environment["WARMBLY_DEBUG_PHASE"] { + case "launching": + return + case "signedout": + phase = .loggedOut + return + case "onboarding": + try? await loadIdentity() + phase = .onboarding + return + case "workspace": + try? await loadIdentity() + memberships = [] + phase = .selectOrg + return + default: + break + } + #endif guard await api.hasToken else { phase = .loggedOut return @@ -178,15 +201,88 @@ final class SessionStore { // MARK: - Login flow func startLogin(email: String, password: String) async throws -> LoginOutcome { - let body = [ - "email": email, - "password": password, - "turnstile": AppConfig.turnstileToken, - ] - let result: AuthSession = try await api.post("auth/login", body: body, authorized: false) + let result: AuthSession = try await verified(.login) { check in + try await self.api.post("auth/login", body: PasswordAuthBody(email: email, password: password, check: check), authorized: false) + } return try await resolve(result) } + // MARK: - Human check + + /// What stands in for a captcha on the email paths: nothing, the dev stack's bypass token, or an App Attest proof. + struct HumanCheck: Sendable { + var turnstile: String? + var appAttest: AppAttestClient.Proof? + } + + enum HumanCheckError: LocalizedError { + case emailSignInOff + + var errorDescription: String? { + "This server only allows email sign-in on the web. Sign in with Apple, Google or a passkey instead." + } + } + + private struct DeploymentChecks: Decodable { + var captcha: Bool? + var appAttest: Bool? + + enum CodingKeys: String, CodingKey { + case captcha + case appAttest = "app_attest" + } + } + + private struct PasswordAuthBody: Encodable { + var email: String + var password: String + var turnstile: String? + var appAttest: AppAttestClient.Proof? + + init(email: String, password: String, check: HumanCheck) { + self.email = email + self.password = password + turnstile = check.turnstile + appAttest = check.appAttest + } + + enum CodingKeys: String, CodingKey { + case email, password, turnstile + case appAttest = "app_attest" + } + } + + private struct ResetBody: Encodable { + var email: String + var turnstile: String? + var appAttest: AppAttestClient.Proof? + + enum CodingKeys: String, CodingKey { + case email, turnstile + case appAttest = "app_attest" + } + } + + private func humanCheck(_ action: AppAttestClient.Action) async throws -> HumanCheck { + let bypass = AppConfig.turnstileToken.trimmingCharacters(in: .whitespaces) + if !bypass.isEmpty { return HumanCheck(turnstile: bypass) } + let checks: DeploymentChecks? = try? await api.get("auth/config", authorized: false) + guard let checks, checks.captcha == true else { return HumanCheck() } + guard checks.appAttest == true else { throw HumanCheckError.emailSignInOff } + return HumanCheck(appAttest: try await appAttest.proof(for: action)) + } + + /// Runs a captcha-gated call, registering this install's key again once if the server lost it. + private func verified(_ action: AppAttestClient.Action, _ send: (HumanCheck) async throws -> T) async throws -> T { + let check = try await humanCheck(action) + do { + return try await send(check) + } catch let error as APIError where error.code == "app_attest_key_unknown" && check.appAttest != nil { + await appAttest.forgetKey() + return try await send(try await humanCheck(action)) + } + } + /// Maps a start-of-login response onto an outcome, completing the sign-in /// when the backend already resolved it. private func resolve(_ result: AuthSession) async throws -> LoginOutcome { @@ -207,11 +303,7 @@ final class SessionStore { } func confirmLogin(session: String, code: String) async throws -> LoginOutcome { - let body = [ - "session": session, - "code": code, - "turnstile": AppConfig.turnstileToken, - ] + let body = ["session": session, "code": code] let result: LoginResult = try await api.post("auth/login/confirm", body: body, authorized: false) if result.twoFARequired == true, let pending = result.pendingToken { return .needsTwoFA(pendingToken: pending) @@ -276,28 +368,22 @@ final class SessionStore { /// Returns the session to confirm, or nil when the deployment has email /// verification off and the account already exists. func startRegister(email: String, password: String) async throws -> String? { - let body = [ - "email": email, - "password": password, - "turnstile": AppConfig.turnstileToken, - ] - let result: AuthSession = try await api.post("auth/register", body: body, authorized: false) + let result: AuthSession = try await verified(.register) { check in + try await self.api.post("auth/register", body: PasswordAuthBody(email: email, password: password, check: check), authorized: false) + } return result.needsCode ? result.session : nil } /// Registration confirm returns 204 (no tokens); chain into the login flow after. func confirmRegister(session: String, code: String) async throws { - let body = [ - "session": session, - "code": code, - "turnstile": AppConfig.turnstileToken, - ] + let body = ["session": session, "code": code] let _: EmptyBody = try await api.post("auth/register/confirm", body: body, authorized: false) } func requestPasswordReset(email: String) async throws { - let body = ["email": email, "turnstile": AppConfig.turnstileToken] - let _: EmptyBody = try await api.post("auth/reset-password", body: body, authorized: false) + let _: EmptyBody = try await verified(.resetPassword) { check in + try await self.api.post("auth/reset-password", body: ResetBody(email: email, turnstile: check.turnstile, appAttest: check.appAttest), authorized: false) + } } // MARK: - Logout diff --git a/ios/Warmbly/DesignSystem/Air.swift b/ios/Warmbly/DesignSystem/Air.swift index f753d1811..cc4257bde 100644 --- a/ios/Warmbly/DesignSystem/Air.swift +++ b/ios/Warmbly/DesignSystem/Air.swift @@ -17,18 +17,14 @@ extension EnvironmentValues { // MARK: - Cards -/// The in-app surface: a soft rounded card on the grouped background. +/// The in-app surface: a soft filled panel, no outline and no shadow. struct AirCardModifier: ViewModifier { var padding: CGFloat = 16 func body(content: Content) -> some View { content .padding(padding) - .background( - Color(.secondarySystemGroupedBackground), - in: RoundedRectangle(cornerRadius: 22, style: .continuous) - ) - .shadow(color: .black.opacity(0.045), radius: 14, y: 5) + .background(WTheme.surface, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) } } @@ -47,22 +43,23 @@ struct AirSectionHeader: View { var body: some View { HStack(alignment: .firstTextBaseline) { Text(title) - .font(.title3.weight(.bold)) + .displayFont(20) + .foregroundStyle(WTheme.ink) Spacer() if let actionTitle, let action { Button(action: action) { - HStack(spacing: 3) { + HStack(spacing: 4) { Text(actionTitle) - Image(systemName: "chevron.right") - .font(.system(size: 11, weight: .semibold)) + Image(systemName: "arrow.right") + .font(.system(size: 11, weight: .medium)) } - .font(.subheadline.weight(.medium)) - .foregroundStyle(WTheme.accent) + .font(.system(size: 13.5, weight: .medium)) + .foregroundStyle(WTheme.inkSecondary) } .buttonStyle(.plain) } } - .padding(.horizontal, 4) + .padding(.horizontal, 2) } } @@ -76,10 +73,10 @@ struct IconTile: View { var body: some View { Image(systemName: symbol) - .font(.system(size: size * 0.44, weight: .semibold)) + .font(.system(size: size * 0.42, weight: .medium)) .foregroundStyle(tone.color) .frame(width: size, height: size) - .background(tone.background, in: RoundedRectangle(cornerRadius: size * 0.32, style: .continuous)) + .background(tone.background, in: RoundedRectangle(cornerRadius: size * 0.3, style: .continuous)) } } @@ -126,47 +123,42 @@ struct HealthRing: View { } } -// MARK: - Hero stat chip (white-on-sky) +// MARK: - Hero stat -/// Glass stat chip used on sky hero headers, matching the auth showcase glass. +/// An open stat for screen headers: a tinted glyph and label over a big +/// number, no tile around it. struct AirStatChip: View { let value: String let label: String var symbol: String? + var tint: Color = WTheme.accent var body: some View { - VStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: 5) { HStack(spacing: 5) { if let symbol { Image(systemName: symbol) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.white.opacity(0.85)) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(tint) + } else { + Circle().fill(tint).frame(width: 6, height: 6) } - Text(value) - .font(.system(size: 21, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white) - .contentTransition(.numericText()) + Text(label) + .font(.system(size: 12.5, weight: .medium)) + .foregroundStyle(WTheme.inkSecondary) + .lineLimit(1) + .minimumScaleFactor(0.8) } - Text(label) - .font(.system(size: 11.5, weight: .medium)) - .foregroundStyle(.white.opacity(0.72)) + Text(value) + .font(.system(size: 26, weight: .semibold, design: .rounded)) + .tracking(-0.6) + .monospacedDigit() + .foregroundStyle(WTheme.ink) + .contentTransition(.numericText()) + .lineLimit(1) + .minimumScaleFactor(0.7) } .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 14) - .padding(.vertical, 11) - .background(.white.opacity(0.14), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder( - LinearGradient( - colors: [.white.opacity(0.38), .white.opacity(0.06)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ), - lineWidth: 1 - ) - ) } } @@ -194,7 +186,7 @@ struct AirUnderlineTabBar: View { } } .padding(.horizontal, 8) - .overlay(alignment: .bottom) { Divider() } + .overlay(alignment: .bottom) { Rectangle().fill(WTheme.hairline).frame(height: 1) } .sensoryFeedback(.selection, trigger: selection) } @@ -208,11 +200,11 @@ struct AirUnderlineTabBar: View { VStack(spacing: 0) { HStack(spacing: 5) { Image(systemName: tab.icon) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(selected ? WTheme.accent : Color.secondary) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(selected ? WTheme.ink : WTheme.inkTertiary) Text(tab.title) - .font(.system(size: 13.5, weight: selected ? .semibold : .regular)) - .foregroundStyle(selected ? Color.primary : .secondary) + .font(.system(size: 13.5, weight: selected ? .medium : .regular)) + .foregroundStyle(selected ? WTheme.ink : WTheme.inkSecondary) .lineLimit(1) .minimumScaleFactor(0.8) } @@ -221,11 +213,11 @@ struct AirUnderlineTabBar: View { ZStack { if selected { Capsule() - .fill(WTheme.accent) - .frame(height: 3) + .fill(WTheme.ink) + .frame(height: 2) .matchedGeometryEffect(id: "air-underline", in: underlineNS) } else { - Color.clear.frame(height: 3) + Color.clear.frame(height: 2) } } .padding(.horizontal, 10) @@ -236,74 +228,210 @@ struct AirUnderlineTabBar: View { } } -/// Detail-screen scaffold: sky hero fixed at the top (behind a transparent -/// nav bar), a rounded white sheet whose underline tab bar stays pinned, and -/// the tab content swiping horizontally underneath (Gmail-profile style). Tab -/// contents keep their own Lists, scrolling, and swipe actions. The sheet is -/// clipped so scrolling content never bleeds over its rounded corners. +/// Detail-screen scaffold: a clean header on the canvas, then a pinned +/// underline tab bar with the tab content swiping underneath. Tab contents +/// keep their own Lists, scrolling, and swipe actions. struct AirDetailScaffold: View { let tabs: [AirTabItem] @Binding var selection: String @ViewBuilder var hero: Hero @ViewBuilder var content: Content - private var sheetShape: UnevenRoundedRectangle { - UnevenRoundedRectangle(topLeadingRadius: 26, topTrailingRadius: 26, style: .continuous) - } - var body: some View { VStack(spacing: 0) { + // The hero keeps its full height; the paged content takes the rest. hero .frame(maxWidth: .infinity, alignment: .leading) - VStack(spacing: 0) { - AirUnderlineTabBar(tabs: tabs, selection: $selection) - content - } - .clipShape(sheetShape) - .background( - sheetShape - .fill(Color(.systemBackground)) - .ignoresSafeArea(edges: .bottom) - .shadow(color: .black.opacity(0.12), radius: 18, y: -4) - ) + .fixedSize(horizontal: false, vertical: true) + AirUnderlineTabBar(tabs: tabs, selection: $selection) + content } - .background(alignment: .top) { - AirSkyWash().ignoresSafeArea(edges: .top) - } - .toolbarBackground(.hidden, for: .navigationBar) - .toolbarColorScheme(.dark, for: .navigationBar) + .background(WTheme.canvas) + .toolbarBackground(WTheme.canvas, for: .navigationBar) } } -/// Lightweight sky slice for detail heros: the brand radial with a soft bloom, -/// cheaper than the full animated SkyBackdrop. +/// Header backdrop for heros and drawers: the canvas with a faint sky haze +/// and dot grid along the top edge, so a header reads as a place without +/// a slab of colour behind it. struct AirSkyWash: View { var body: some View { - ZStack { + ZStack(alignment: .top) { + WTheme.canvas + DotGrid(spacing: 18, opacity: 0.05) + .mask(LinearGradient(colors: [.black, .clear], startPoint: .top, endPoint: .bottom)) RadialGradient( - stops: [ - .init(color: Color(hex: 0x7DD3FC), location: 0), - .init(color: Color(hex: 0x38BDF8), location: 0.34), - .init(color: Color(hex: 0x0EA5E9), location: 0.66), - .init(color: Color(hex: 0x0284C7), location: 1), - ], - center: UnitPoint(x: 0.55, y: 0.05), + colors: [WTheme.accent.opacity(0.09), .clear], + center: UnitPoint(x: 0.85, y: 0), startRadius: 0, - endRadius: 560 - ) - RadialGradient( - stops: [ - .init(color: Color(hex: 0xBAE6FD).opacity(0.55), location: 0), - .init(color: .clear, location: 0.6), - ], - center: UnitPoint(x: 0.6, y: 0.1), - startRadius: 0, - endRadius: 420 + endRadius: 320 ) } } } +// MARK: - Site primitives + +/// The site's dot-grid texture (`bg-dotgrid`), in the current ink. +struct DotGrid: View { + var spacing: CGFloat = 22 + var opacity: Double = 0.08 + var color: Color = WTheme.ink + + var body: some View { + Canvas { ctx, size in + var path = Path() + var y: CGFloat = spacing / 2 + while y < size.height { + var x: CGFloat = spacing / 2 + while x < size.width { + path.addEllipse(in: CGRect(x: x - 0.75, y: y - 0.75, width: 1.5, height: 1.5)) + x += spacing + } + y += spacing + } + ctx.fill(path, with: .color(color.opacity(opacity))) + } + .allowsHitTesting(false) + } +} + +/// A full-width hairline rule. +struct Hairline: View { + var body: some View { + Rectangle().fill(WTheme.hairline).frame(height: 1) + } +} + +/// The site's two-tone heading: a grey lead, then the point in ink. +struct TwoToneTitle: View { + var lead: String? + let title: String + var size: CGFloat = 30 + var leadColor: Color = WTheme.inkTertiary + var titleColor: Color = WTheme.ink + + var body: some View { + Group { + if let lead { + Text("\(Text(lead).foregroundStyle(leadColor)) \(Text(title).foregroundStyle(titleColor))") + } else { + Text(title).foregroundStyle(titleColor) + } + } + .displayFont(size) + .fixedSize(horizontal: false, vertical: true) + } +} + +/// A live/state dot with the web's ping halo. +struct LiveDot: View { + var color: Color = WTheme.positive + var size: CGFloat = 7 + var pulsing = true + + var body: some View { + Circle() + .fill(color) + .frame(width: size, height: size) + .modifier(PingEffect(active: pulsing, color: color)) + } +} + +/// One of the site's photo frames: a soft texture with UI floating on it. +struct PhotoFrame: View { + var photo = "SheerSky" + var cornerRadius: CGFloat = 22 + @ViewBuilder var content: Content + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + content + .background { + Image(photo) + .resizable() + .scaledToFill() + .overlay(WTheme.night.opacity(colorScheme == .dark ? 0.45 : 0)) + .allowsHitTesting(false) + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .strokeBorder(WTheme.ink.opacity(0.06), lineWidth: 1) + ) + } +} + +// MARK: - Buttons + +enum ButtonSize { + case small, regular, large + + var height: CGFloat { + switch self { + case .small: 34 + case .regular: 44 + case .large: 54 + } + } + + var font: Font { + switch self { + case .small: .system(size: 13.5, weight: .medium) + case .regular: .system(size: 15, weight: .medium) + case .large: .system(size: 16.5, weight: .medium) + } + } + + var padding: CGFloat { + switch self { + case .small: 14 + case .regular: 18 + case .large: 22 + } + } +} + +/// The site's primary action: a near-black pill (white in dark mode). +struct InkButtonStyle: ButtonStyle { + var size: ButtonSize = .regular + var fullWidth = false + @Environment(\.isEnabled) private var isEnabled + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(size.font) + .foregroundStyle(WTheme.inkInverse) + .padding(.horizontal, size.padding) + .frame(maxWidth: fullWidth ? .infinity : nil) + .frame(height: size.height) + .background(WTheme.ink.opacity(isEnabled ? 1 : 0.22), in: Capsule()) + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .opacity(configuration.isPressed ? 0.86 : 1) + .animation(.spring(response: 0.28, dampingFraction: 0.75), value: configuration.isPressed) + .animation(.easeOut(duration: 0.18), value: isEnabled) + } +} + +/// The site's secondary action: a hairline pill on the canvas. +struct OutlineButtonStyle: ButtonStyle { + var size: ButtonSize = .regular + var fullWidth = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(size.font) + .foregroundStyle(WTheme.ink) + .padding(.horizontal, size.padding) + .frame(maxWidth: fullWidth ? .infinity : nil) + .frame(height: size.height) + .background(configuration.isPressed ? WTheme.fill : WTheme.raised, in: Capsule()) + .overlay(Capsule().strokeBorder(WTheme.hairlineStrong, lineWidth: 1)) + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .animation(.spring(response: 0.28, dampingFraction: 0.75), value: configuration.isPressed) + } +} + // MARK: - Web handoff /// Points at the web dashboard for work that needs a big screen (sequence @@ -315,10 +443,10 @@ struct WebHandoffBanner: View { var body: some View { HStack(alignment: .top, spacing: 11) { Image(systemName: "macbook") - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(WTheme.accent) + .font(.system(size: 14, weight: .regular)) + .foregroundStyle(WTheme.ink) .frame(width: 30, height: 30) - .background(Tone.sky.background, in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + .background(WTheme.fill, in: RoundedRectangle(cornerRadius: 9, style: .continuous)) VStack(alignment: .leading, spacing: 2) { Text("Best on a big screen") .font(.footnote.weight(.semibold)) @@ -330,10 +458,11 @@ struct WebHandoffBanner: View { Spacer(minLength: 0) } .padding(12) - .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .background(WTheme.raised, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder(Tone.sky.color.opacity(0.18), lineWidth: 1) + .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [3, 3])) + .foregroundStyle(WTheme.hairlineStrong) ) } } diff --git a/ios/Warmbly/DesignSystem/Appearance.swift b/ios/Warmbly/DesignSystem/Appearance.swift new file mode 100644 index 000000000..32b8bd090 --- /dev/null +++ b/ios/Warmbly/DesignSystem/Appearance.swift @@ -0,0 +1,34 @@ +import SwiftUI + +/// The user's light/dark choice, stored on the device and applied at the root. +enum AppearancePreference: String, CaseIterable, Identifiable { + case system, light, dark + + static let storageKey = "appearance" + + var id: String { rawValue } + + var title: String { + switch self { + case .system: "System" + case .light: "Light" + case .dark: "Dark" + } + } + + var symbol: String { + switch self { + case .system: "circle.lefthalf.filled" + case .light: "sun.max" + case .dark: "moon" + } + } + + var colorScheme: ColorScheme? { + switch self { + case .system: nil + case .light: .light + case .dark: .dark + } + } +} diff --git a/ios/Warmbly/DesignSystem/Components.swift b/ios/Warmbly/DesignSystem/Components.swift index 2e43beacf..bcd971b91 100644 --- a/ios/Warmbly/DesignSystem/Components.swift +++ b/ios/Warmbly/DesignSystem/Components.swift @@ -32,8 +32,8 @@ struct WarmblyWordmark: View { .fill(Color.primary) .frame(width: markSize, height: markSize * (764 / 746)) Text("Warmbly") - .font(.system(size: markSize * 0.82, weight: .heavy, design: .default)) - .tracking(-0.4) + .font(.system(size: markSize * 0.86, weight: .semibold)) + .tracking(-markSize * 0.03) } } } @@ -51,21 +51,14 @@ enum Tone { case .rose: WTheme.negative case .sky: WTheme.accent case .slate: WTheme.paused - case .indigo: Color.dynamic(light: 0x4338CA, dark: 0x818CF8) - case .orange: Color.dynamic(light: 0xEA580C, dark: 0xFB923C) + case .indigo: Color.dynamic(light: 0x5B5BF0, dark: 0x8C8FFA) + case .orange: Color.dynamic(light: 0xF2700C, dark: 0xFF9142) } } + /// A soft tint of the tone, for pills, tiles and notices. var background: Color { - switch self { - case .emerald: Color.dynamic(light: 0xECFDF5, dark: 0x064E3B) - case .amber: Color.dynamic(light: 0xFFFBEB, dark: 0x78350F) - case .rose: Color.dynamic(light: 0xFFF1F2, dark: 0x881337) - case .sky: Color.dynamic(light: 0xF0F9FF, dark: 0x0C4A6E) - case .slate: Color.dynamic(light: 0xF1F5F9, dark: 0x1E293B) - case .indigo: Color.dynamic(light: 0xEEF2FF, dark: 0x312E81) - case .orange: Color.dynamic(light: 0xFFF7ED, dark: 0x7C2D12) - } + color.opacity(self == .slate ? 0.12 : 0.11) } } @@ -78,10 +71,9 @@ struct EyebrowLabel: View { init(_ text: String) { self.text = text } var body: some View { - Text(text.uppercased()) - .font(.system(size: 10, weight: .medium)) - .tracking(1.4) - .foregroundStyle(.tertiary) + Text(text) + .font(.system(size: 13.5, weight: .semibold)) + .foregroundStyle(WTheme.inkTertiary) } } @@ -106,23 +98,13 @@ struct StatusPill: View { } } -/// The web's `animate-ping` halo behind live dots. +/// Kept for call sites; state dots are static now, a ping halo reads as noise. struct PingEffect: ViewModifier { let active: Bool let color: Color - @State private var animating = false func body(content: Content) -> some View { - content.background { - if active { - Circle() - .fill(color.opacity(0.45)) - .scaleEffect(animating ? 2.4 : 1) - .opacity(animating ? 0 : 0.6) - .animation(.easeOut(duration: 1.1).repeatForever(autoreverses: false), value: animating) - .onAppear { animating = true } - } - } + content } } @@ -136,9 +118,10 @@ struct StatCell: View { VStack(alignment: .leading, spacing: 4) { EyebrowLabel(label) Text(value) - .font(.system(size: 24, weight: .light, design: .default)) + .font(.system(size: 26, weight: .regular)) + .tracking(-0.9) .monospacedDigit() - .foregroundStyle(tone?.color ?? Color.primary) + .foregroundStyle(tone?.color ?? WTheme.ink) .contentTransition(.numericText()) } .frame(maxWidth: .infinity, alignment: .leading) @@ -302,23 +285,32 @@ extension View { struct EmptyStateView: View { let title: String let message: String + var symbol: String = "tray" var ctaTitle: String? = nil var cta: (() -> Void)? = nil var body: some View { - VStack(spacing: 8) { + VStack(spacing: 0) { + Image(systemName: symbol) + .font(.system(size: 18, weight: .regular)) + .foregroundStyle(WTheme.inkSecondary) + .frame(width: 46, height: 46) + .background(WTheme.raised, in: RoundedRectangle(cornerRadius: 13, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 13, style: .continuous).strokeBorder(WTheme.hairlineStrong, lineWidth: 1)) + .padding(.bottom, 14) Text(title) - .font(.system(size: 14, weight: .medium)) + .displayFont(17) + .foregroundStyle(WTheme.ink) Text(message) - .font(.system(size: 12)) - .foregroundStyle(.secondary) + .font(.system(size: 14)) + .foregroundStyle(WTheme.inkSecondary) .multilineTextAlignment(.center) - .frame(maxWidth: 260) + .frame(maxWidth: 270) + .padding(.top, 5) if let ctaTitle, let cta { Button(ctaTitle, action: cta) - .buttonStyle(.borderedProminent) - .controlSize(.small) - .padding(.top, 6) + .buttonStyle(InkButtonStyle(size: .small)) + .padding(.top, 18) } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -332,26 +324,29 @@ struct ErrorStateView: View { let retry: () async -> Void var body: some View { - VStack(spacing: 10) { + VStack(spacing: 0) { Image(systemName: "exclamationmark.triangle") - .font(.system(size: 18)) + .font(.system(size: 17)) .foregroundStyle(WTheme.negative) - .frame(width: 36, height: 36) - .background(Tone.rose.background, in: RoundedRectangle(cornerRadius: 8)) + .frame(width: 46, height: 46) + .background(Tone.rose.background, in: RoundedRectangle(cornerRadius: 13, style: .continuous)) + .padding(.bottom, 14) Text(title) - .font(.system(size: 14, weight: .medium)) + .displayFont(17) + .foregroundStyle(WTheme.ink) if let message { Text(message) - .font(.system(size: 12)) - .foregroundStyle(.secondary) + .font(.system(size: 14)) + .foregroundStyle(WTheme.inkSecondary) .multilineTextAlignment(.center) .frame(maxWidth: 280) + .padding(.top, 5) } Button("Try again") { Task { await retry() } } - .buttonStyle(.bordered) - .controlSize(.small) + .buttonStyle(OutlineButtonStyle(size: .small)) + .padding(.top, 18) } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.vertical, 48) @@ -410,7 +405,7 @@ struct SkeletonRows: View { } Spacer(minLength: 0) } - .foregroundStyle(Color(.systemGray5)) + .foregroundStyle(WTheme.fill) .padding(.horizontal, 16) .padding(.vertical, 10) .opacity(1.0 - Double(index) * (0.7 / Double(max(rows - 1, 1)))) @@ -443,6 +438,40 @@ enum WFormat { return formatter.localizedString(for: date, relativeTo: Date()) } + /// "now", "12m", "3h", "2d", "5w", then the date: for tight row trailers. + static func ago(_ date: Date, now: Date = .now) -> String { + let seconds = max(0, now.timeIntervalSince(date)) + switch seconds { + case ..<60: return "now" + case ..<3600: return "\(Int(seconds / 60))m" + case ..<86400: return "\(Int(seconds / 3600))h" + case ..<(86400 * 7): return "\(Int(seconds / 86400))d" + case ..<(86400 * 35): return "\(Int(seconds / (86400 * 7)))w" + default: return date.formatted(.dateTime.month(.abbreviated).day()) + } + } + + /// The spelled-out `ago`, counted the same way so a row and its detail agree. + /// An address's local part as words, so an avatar gets two initials. + static func mailboxName(_ email: String?) -> String { + guard let email, !email.isEmpty else { return "?" } + let local = email.split(separator: "@").first.map(String.init) ?? email + return local.split(whereSeparator: { $0 == "." || $0 == "_" || $0 == "-" || $0 == "+" }).joined(separator: " ") + } + + static func agoLong(_ date: Date, now: Date = .now) -> String { + let seconds = max(0, now.timeIntervalSince(date)) + func unit(_ n: Int, _ name: String) -> String { "\(n) \(name)\(n == 1 ? "" : "s") ago" } + switch seconds { + case ..<60: return "just now" + case ..<3600: return unit(Int(seconds / 60), "minute") + case ..<86400: return unit(Int(seconds / 3600), "hour") + case ..<(86400 * 7): return unit(Int(seconds / 86400), "day") + case ..<(86400 * 35): return unit(Int(seconds / (86400 * 7)), "week") + default: return "on " + date.formatted(.dateTime.month(.abbreviated).day()) + } + } + static func percent(_ numerator: Int, of denominator: Int) -> String { guard denominator > 0 else { return "0%" } return String(format: "%.0f%%", Double(numerator) / Double(denominator) * 100) diff --git a/ios/Warmbly/DesignSystem/DrawerWorkspaceHeader.swift b/ios/Warmbly/DesignSystem/DrawerWorkspaceHeader.swift new file mode 100644 index 000000000..16df44afb --- /dev/null +++ b/ios/Warmbly/DesignSystem/DrawerWorkspaceHeader.swift @@ -0,0 +1,59 @@ +import SwiftUI + +/// Every drawer's top: the workspace you are in, and a menu to switch it. +struct DrawerWorkspaceHeader: View { + let topInset: CGFloat + + @Environment(AppEnvironment.self) private var env + @State private var showWorkspaces = false + + private var orgName: String { env.session.currentOrg?.name ?? "Workspace" } + + var body: some View { + Menu { + ForEach(env.session.memberships) { membership in + Button { + Task { try? await env.session.switchOrganization(membership.organizationID) } + } label: { + if membership.organizationID == env.session.currentOrgID { + Label(membership.organization?.name ?? "Workspace", systemImage: "checkmark") + } else { + Text(membership.organization?.name ?? "Workspace") + } + } + } + Divider() + Button { + showWorkspaces = true + } label: { + Label("Manage workspaces", systemImage: "square.stack") + } + } label: { + HStack(spacing: 10) { + WAvatar( + name: orgName, + imageURL: env.session.currentOrg?.avatarURL, + seed: env.session.currentOrgID ?? "org", + size: 30 + ) + Text(orgName) + .font(.system(size: 17, weight: .semibold)) + .tracking(-0.2) + .foregroundStyle(WTheme.ink) + .lineLimit(1) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(WTheme.inkTertiary) + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .frame(height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.horizontal, 12) + .padding(.top, topInset + 8) + .padding(.bottom, 2) + .sheet(isPresented: $showWorkspaces) { MoreWorkspaceSwitcherSheet() } + } +} diff --git a/ios/Warmbly/DesignSystem/GlassHeaderBar.swift b/ios/Warmbly/DesignSystem/GlassHeaderBar.swift new file mode 100644 index 000000000..19b541ca8 --- /dev/null +++ b/ios/Warmbly/DesignSystem/GlassHeaderBar.swift @@ -0,0 +1,190 @@ +import SwiftUI + +/// The tab header every list screen shares: a glass menu button, the scope as +/// a title with one line under it, and a search button that morphs into the +/// field. `trailing` holds the screen's own glass buttons (new campaign, …). +struct GlassHeaderBar: View { + let title: String + var subtitle: Text? + @Binding var query: String + var searchPrompt: String + var searchFocused: FocusState.Binding + var menuLabel = "Open menu" + let onMenu: () -> Void + @ViewBuilder var trailing: () -> Trailing + + @State private var searchOpen = false + @Namespace private var glass + + var body: some View { + GlassEffectContainer(spacing: 10) { + HStack(spacing: 10) { + if searchOpen { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(WTheme.inkSecondary) + TextField(text: $query) { + Text(searchPrompt).foregroundStyle(WTheme.inkTertiary) + } + .font(.system(size: 16)) + .foregroundStyle(WTheme.ink) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.search) + .focused(searchFocused) + if !query.isEmpty { + Button { + query = "" + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 16)) + .foregroundStyle(WTheme.inkTertiary) + } + .buttonStyle(.plain) + .accessibilityLabel("Clear search") + } + } + .padding(.horizontal, 15) + .frame(height: 44) + .glassEffect(.regular.interactive(), in: Capsule()) + .glassEffectID("search", in: glass) + + GlassCircleButton(symbol: "xmark", label: "Close search") { close() } + .glassEffectID("menu", in: glass) + } else { + GlassCircleButton(symbol: "line.3.horizontal", label: menuLabel, action: onMenu) + .glassEffectID("menu", in: glass) + + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(.system(size: 19, weight: .semibold)) + .tracking(-0.3) + .foregroundStyle(WTheme.ink) + .lineLimit(1) + .contentTransition(.opacity) + if let subtitle { + subtitle + .font(.system(size: 13)) + .foregroundStyle(WTheme.inkSecondary) + .monospacedDigit() + .lineLimit(1) + .contentTransition(.numericText()) + } + } + .padding(.leading, 2) + + Spacer(minLength: 6) + PresenceAvatars(maxShown: 3) + GlassCircleButton(symbol: "magnifyingglass", label: "Search") { open() } + .glassEffectID("search", in: glass) + trailing() + } + } + } + .padding(.horizontal, 16) + .padding(.top, 6) + .padding(.bottom, 8) + .animation(.spring(response: 0.42, dampingFraction: 0.86), value: searchOpen) + .onAppear { + // A search still in the field reopens it. + if !query.isEmpty { searchOpen = true } + } + } + + private func open() { + withAnimation(.spring(response: 0.42, dampingFraction: 0.86)) { searchOpen = true } + Task { + try? await Task.sleep(for: .milliseconds(120)) + searchFocused.wrappedValue = true + } + } + + private func close() { + query = "" + searchFocused.wrappedValue = false + withAnimation(.spring(response: 0.42, dampingFraction: 0.86)) { searchOpen = false } + } +} + +extension GlassHeaderBar where Trailing == EmptyView { + init( + title: String, + subtitle: Text? = nil, + query: Binding, + searchPrompt: String, + searchFocused: FocusState.Binding, + menuLabel: String = "Open menu", + onMenu: @escaping () -> Void + ) { + self.init( + title: title, + subtitle: subtitle, + query: query, + searchPrompt: searchPrompt, + searchFocused: searchFocused, + menuLabel: menuLabel, + onMenu: onMenu, + trailing: { EmptyView() } + ) + } +} + +/// A 44pt Liquid Glass circle with one symbol. +struct GlassCircleButton: View { + let symbol: String + let label: String + var tint: Color = WTheme.ink + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: symbol) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(tint) + .frame(width: 44, height: 44) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .glassEffect(.regular.interactive(), in: Circle()) + .accessibilityLabel(label) + } +} + +/// The screen's primary action in the header: an ink circle with one symbol. +struct HeaderPrimaryButton: View { + let symbol: String + let label: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: symbol) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(WTheme.inkInverse) + .frame(width: 44, height: 44) + .background(WTheme.ink, in: Circle()) + .contentShape(Circle()) + } + .buttonStyle(TapScaleStyle()) + .accessibilityLabel(label) + } +} + +/// Under the header while searching: how many results matched. +struct SearchResultsLine: View { + let count: Int + var more = false + + var body: some View { + Text("\(count)\(more ? "+" : "") \(count == 1 ? "result" : "results")") + .font(.system(size: 13.5, weight: .medium)) + .monospacedDigit() + .foregroundStyle(WTheme.inkSecondary) + .contentTransition(.numericText()) + .padding(.horizontal, 20) + .padding(.top, 4) + .padding(.bottom, 6) + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/ios/Warmbly/DesignSystem/MailboxAvatar.swift b/ios/Warmbly/DesignSystem/MailboxAvatar.swift new file mode 100644 index 000000000..3d46a40ef --- /dev/null +++ b/ios/Warmbly/DesignSystem/MailboxAvatar.swift @@ -0,0 +1,128 @@ +import SwiftUI + +/// Who runs a mailbox, as the app names and draws it: the inbox vendor it was +/// imported from, else its detected mail host, else how it was connected. +/// Mirrors `web/src/lib/brands.ts` and `mailboxSource.ts`. +struct MailboxBrand: Hashable, Sendable { + enum Mark: Hashable, Sendable { + case google, microsoft, asset(String), server + } + + let mark: Mark + /// "Google Workspace", "Zoho Mail", "via Zapmail", "SMTP/IMAP". + let label: String + + init(provider: String?, mailHost: String?, authMethod: String? = nil, vendor: String? = nil) { + let host = (mailHost ?? "").lowercased() + let vendor = (vendor ?? "").lowercased() + if !vendor.isEmpty { + mark = Self.vendorLabels[vendor] != nil ? .asset("Vendor-\(vendor)") : Self.mark(host: host, provider: provider) + label = "via \(Self.vendorLabels[vendor] ?? vendor)" + return + } + mark = Self.mark(host: host, provider: provider) + if authMethod == "delegated" { + label = mark == .microsoft ? "Microsoft 365" : "Google Workspace" + } else if let name = Self.hostLabels[host] { + label = name + } else { + label = switch provider { + case "gmail": "Gmail" + case "outlook": "Outlook" + case "smtp_imap", nil, "": "SMTP/IMAP" + default: provider ?? "Mailbox" + } + } + } + + private static func mark(host: String, provider: String?) -> Mark { + switch host { + case "google_workspace", "gmail": return .google + case "microsoft365", "outlook": return .microsoft + default: + if hostAssets.contains(host) { return .asset("Host-\(host)") } + } + switch provider { + case "gmail": return .google + case "outlook": return .microsoft + default: return .server + } + } + + private static let hostAssets: Set = [ + "aol", "fastmail", "gmx", "godaddy", "hostinger", "icloud", "ionos", "migadu", + "namecheap", "ovh", "proton", "purelymail", "rackspace", "yahoo", "yandex", "zoho", + ] + + private static let hostLabels: [String: String] = [ + "google_workspace": "Google Workspace", "gmail": "Gmail", + "microsoft365": "Microsoft 365", "outlook": "Outlook.com", + "zoho": "Zoho Mail", "yahoo": "Yahoo Mail", "aol": "AOL Mail", "icloud": "iCloud Mail", + "fastmail": "Fastmail", "godaddy": "GoDaddy", "namecheap": "Namecheap", "ionos": "IONOS", + "hostinger": "Hostinger", "ovh": "OVHcloud", "migadu": "Migadu", "purelymail": "Purelymail", + "rackspace": "Rackspace", "yandex": "Yandex Mail", "gmx": "GMX", "proton": "Proton Mail", + ] + + private static let vendorLabels: [String: String] = [ + "inboxkit": "InboxKit", "zapmail": "Zapmail", "mailforge": "Mailforge", "infraforge": "Infraforge", + "maildoso": "Maildoso", "cheapinboxes": "Cheap Inboxes", "scaledmail": "ScaledMail", + ] +} + +/// A service's logo, drawn without a frame. +struct ProviderMark: View { + let mark: MailboxBrand.Mark + var size: CGFloat = 16 + + var body: some View { + Group { + switch mark { + case .google: + Image("GoogleLogo").resizable().scaledToFit() + case .microsoft: + Image("OutlookLogo").resizable().scaledToFit() + case let .asset(name): + Image(name).resizable().scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: size * 0.18, style: .continuous)) + case .server: + Image(systemName: "server.rack") + .font(.system(size: size * 0.78, weight: .medium)) + .foregroundStyle(WTheme.inkSecondary) + } + } + .frame(width: size, height: size) + .accessibilityHidden(true) + } +} + +/// A mailbox's face: its profile photo (initials until one exists) with the +/// service that runs it riding the corner. +struct MailboxAvatar: View { + let email: String + var avatarURL: String? + let brand: MailboxBrand + var seed: String = "" + var size: CGFloat = 44 + /// Matches the badge's ring to whatever the avatar sits on. + var ring: Color = WTheme.groupedCard + + var body: some View { + let badge = max(17, size * 0.44) + WAvatar( + name: WFormat.mailboxName(email), + imageURL: avatarURL?.isEmpty == false ? avatarURL : nil, + seed: seed.isEmpty ? email : seed, + size: size + ) + .overlay(alignment: .bottomTrailing) { + ProviderMark(mark: brand.mark, size: badge * 0.62) + .frame(width: badge, height: badge) + .background(.white, in: Circle()) + .overlay(Circle().strokeBorder(Color.black.opacity(0.06), lineWidth: 0.5)) + .padding(1.5) + .background(ring, in: Circle()) + .offset(x: size * 0.16, y: size * 0.12) + } + .accessibilityLabel("\(email), \(brand.label)") + } +} diff --git a/ios/Warmbly/DesignSystem/TabBar.swift b/ios/Warmbly/DesignSystem/TabBar.swift new file mode 100644 index 000000000..426a5337f --- /dev/null +++ b/ios/Warmbly/DesignSystem/TabBar.swift @@ -0,0 +1,222 @@ +import SwiftUI + +// MARK: - Visibility + +/// Screens that need the whole height (an open drawer, selection mode) hide +/// the tab bar through this, keyed per screen so one can't unhide another's. +@MainActor +@Observable +final class TabBarState { + private(set) var hiddenBy: Set = [] + private(set) var keyboardVisible = false + + var isHidden: Bool { !hiddenBy.isEmpty || keyboardVisible } + + func set(_ hidden: Bool, by key: String) { + if hidden { hiddenBy.insert(key) } else { hiddenBy.remove(key) } + } + + func setKeyboard(_ visible: Bool) { + keyboardVisible = visible + } + + /// A screen's own round action, replacing the default while it's on screen. + private(set) var actions: [String: TabBarAction] = [:] + private var actionOrder: [String] = [] + + var screenAction: TabBarAction? { + actionOrder.last.flatMap { actions[$0] } + } + + func setAction(_ action: TabBarAction?, by key: String) { + actionOrder.removeAll { $0 == key } + if let action { + actions[key] = action + actionOrder.append(key) + } else { + actions[key] = nil + } + } +} + +private struct TabBarHiddenModifier: ViewModifier { + @Environment(TabBarState.self) private var state: TabBarState? + let hidden: Bool + @State private var key = UUID().uuidString + + func body(content: Content) -> some View { + content + .onAppear { apply(hidden) } + .onChange(of: hidden) { _, value in apply(value) } + .onDisappear { apply(false) } + } + + private func apply(_ value: Bool) { + withAnimation(.spring(response: 0.34, dampingFraction: 0.86)) { + state?.set(value, by: key) + } + } +} + +private struct TabBarActionModifier: ViewModifier { + @Environment(TabBarState.self) private var state: TabBarState? + let action: TabBarAction + let enabled: Bool + @State private var key = UUID().uuidString + + func body(content: Content) -> some View { + content + .onAppear { apply(enabled) } + .onChange(of: enabled) { _, value in apply(value) } + .onDisappear { apply(false) } + } + + private func apply(_ value: Bool) { + withAnimation(.spring(response: 0.34, dampingFraction: 0.86)) { + state?.setAction(value ? action : nil, by: key) + } + } +} + +extension View { + /// Puts this screen's primary action in the tab bar's round button while on screen. + func tabBarAction(_ title: String, symbol: String, enabled: Bool = true, perform: @escaping () -> Void) -> some View { + modifier(TabBarActionModifier(action: TabBarAction(title: title, symbol: symbol, perform: perform), enabled: enabled)) + } + + /// Hides the app's tab bar while `hidden` is true and this view is on screen. + func tabBarHidden(_ hidden: Bool) -> some View { + modifier(TabBarHiddenModifier(hidden: hidden)) + } +} + +// MARK: - Bar + +struct WTabItem: Identifiable { + let tab: AppTab + let title: String + let symbol: String + var badge: Int = 0 + + var id: AppTab { tab } +} + +/// The app's own tab bar, Linear-style on Liquid Glass: a compact glass +/// capsule floating off the bottom edge, icon-only and monochrome, the active +/// tab on a soft tile that slides between items, and one round glass action +/// beside it that the bar merges with as they meet. +struct WTabBar: View { + let items: [WTabItem] + @Binding var selection: AppTab + var action: TabBarAction? + var onReselect: (AppTab) -> Void = { _ in } + + @Namespace private var activeNS + @Namespace private var glassNS + + var body: some View { + GlassEffectContainer(spacing: 14) { + HStack(spacing: 10) { + HStack(spacing: 2) { + ForEach(items) { item in + button(item) + } + } + .padding(5) + .glassEffect(.regular.interactive(), in: Capsule()) + .glassEffectID("bar", in: glassNS) + + if let action { + Button(action: action.perform) { + Group { + if action.mark { + RemieMark(size: 26) + .transition(.scale(scale: 0.5).combined(with: .opacity)) + } else { + Image(systemName: action.symbol) + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(WTheme.ink) + .transition(.scale(scale: 0.5).combined(with: .opacity)) + } + } + .frame(width: 56, height: 56) + .contentShape(Circle()) + } + .buttonStyle(TabPressStyle()) + .glassEffect(.regular.interactive(), in: Circle()) + .glassEffectID("action", in: glassNS) + .accessibilityLabel(action.title) + } + } + } + .padding(.horizontal, 18) + .padding(.bottom, 4) + .sensoryFeedback(.selection, trigger: selection) + } + + private func button(_ item: WTabItem) -> some View { + let selected = item.tab == selection + return Button { + if selected { + onReselect(item.tab) + } else { + withAnimation(.spring(response: 0.32, dampingFraction: 0.82)) { + selection = item.tab + } + } + } label: { + Image(systemName: selected ? "\(item.symbol).fill" : item.symbol) + .font(.system(size: 18, weight: selected ? .medium : .regular)) + .foregroundStyle(selected ? WTheme.ink : WTheme.inkTertiary) + .contentTransition(.symbolEffect(.replace)) + .frame(maxWidth: .infinity) + .frame(height: 46) + .background { + if selected { + Capsule() + .fill(WTheme.ink.opacity(0.08)) + .matchedGeometryEffect(id: "active", in: activeNS) + } + } + .overlay(alignment: .top) { badge(item.badge) } + .contentShape(Capsule()) + } + .buttonStyle(TabPressStyle()) + .accessibilityLabel(item.title) + .accessibilityValue(item.badge > 0 ? "\(item.badge) unread" : "") + .accessibilityAddTraits(selected ? .isSelected : []) + } + + @ViewBuilder + private func badge(_ count: Int) -> some View { + if count > 0 { + Text(count > 99 ? "99+" : "\(count)") + .font(WFont.meta(9.5, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 4.5) + .frame(minWidth: 16, minHeight: 16) + .background(WTheme.accent, in: Capsule()) + .overlay(Capsule().strokeBorder(.white.opacity(0.9), lineWidth: 1.5)) + .offset(x: 12, y: 4) + .contentTransition(.numericText()) + .transition(.scale.combined(with: .opacity)) + } + } +} + +/// The round control floating beside the tab bar. +struct TabBarAction { + let title: String + let symbol: String + /// Shows Remie's mark instead of the symbol. + var mark = false + let perform: () -> Void +} + +private struct TabPressStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.92 : 1) + .animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed) + } +} diff --git a/ios/Warmbly/DesignSystem/Theme.swift b/ios/Warmbly/DesignSystem/Theme.swift index 6a4a8b9af..a4a0ff58f 100644 --- a/ios/Warmbly/DesignSystem/Theme.swift +++ b/ios/Warmbly/DesignSystem/Theme.swift @@ -24,21 +24,58 @@ extension Color { } } -/// Warmbly brand palette, mapped from the web dashboard's tailwind tokens -/// (slate neutrals + sky accent) onto dynamic iOS colors. +/// Warmbly brand palette: the marketing site's ink-on-white (slate neutrals, +/// near-black actions, hairlines) with sky kept for data and live state. +/// Dark mode uses zinc on true black. enum WTheme { + // Neutrals + static let canvas = Color.dynamic(light: 0xFFFFFF, dark: 0x09090B) + static let surface = Color.dynamic(light: 0xF6F6F7, dark: 0x141416) + static let raised = Color.dynamic(light: 0xFFFFFF, dark: 0x18181B) + static let fill = Color.dynamic(light: 0xF0F0F2, dark: 0x1F1F23) + static let ink = Color.dynamic(light: 0x111113, dark: 0xF7F7F8) + static let inkInverse = Color.dynamic(light: 0xFFFFFF, dark: 0x09090B) + static let inkSecondary = Color.dynamic(light: 0x6E6E76, dark: 0xA1A1A9) + static let inkTertiary = Color.dynamic(light: 0xA3A3AB, dark: 0x6B6B73) + static let hairline = Color.dynamic(light: 0xEFEFF1, dark: 0x1F1F23) + static let hairlineStrong = Color.dynamic(light: 0xE4E4E7, dark: 0x2A2A2F) + + // iOS grouped settings: a grey page with white cards (dark: black page, grey cards). + static let groupedPage = Color.dynamic(light: 0xF2F2F7, dark: 0x09090B) + static let groupedCard = Color.dynamic(light: 0xFFFFFF, dark: 0x1A1A1D) + + /// The dark-mode brand backdrop for launch, sign-in and onboarding. + static let night = Color(hex: 0x09090B) + + /// Ink on the sign-in sky: dark on the light photo, white on the night. + static let onSky = Color.dynamic(light: 0x111827, dark: 0xFFFFFF) + + /// A glassy tile on the sky; the pale photo needs more white to show one. + static func skyTile(_ opacity: Double) -> Color { + Color(UIColor { $0.userInterfaceStyle == .dark + ? UIColor.white.withAlphaComponent(opacity) + : UIColor.white.withAlphaComponent(min(0.8, 0.35 + opacity * 2.5)) }) + } + + /// A tile's edge on the sky: white on the night, a faint ink line on the photo. + static func skyEdge(_ opacity: Double) -> Color { + Color(UIColor { $0.userInterfaceStyle == .dark + ? UIColor.white.withAlphaComponent(opacity) + : UIColor(red: 0.07, green: 0.09, blue: 0.15, alpha: 0.08) }) + } + // Accent (sky) - static let accent = Color.dynamic(light: 0x0284C7, dark: 0x38BDF8) - static let accentDeep = Color.dynamic(light: 0x0369A1, dark: 0x0EA5E9) - static let accentSoft = Color.dynamic(light: 0xE0F2FE, dark: 0x0C4A6E) + static let accent = Color.dynamic(light: 0x0A8DE6, dark: 0x3DB8FF) + static let accentDeep = Color.dynamic(light: 0x0670C2, dark: 0x0EA5E9) + static let accentSoft = Color.dynamic(light: 0xE6F4FE, dark: 0x0C2233) // Status - static let positive = Color.dynamic(light: 0x059669, dark: 0x34D399) // emerald - static let warning = Color.dynamic(light: 0xD97706, dark: 0xFBBF24) // amber - static let negative = Color.dynamic(light: 0xDC2626, dark: 0xF87171) // red - static let info = Color.dynamic(light: 0x0284C7, dark: 0x38BDF8) // sky - static let special = Color.dynamic(light: 0x7C3AED, dark: 0xA78BFA) // violet - static let paused = Color.dynamic(light: 0x64748B, dark: 0x94A3B8) // slate + static let positive = Color.dynamic(light: 0x12A150, dark: 0x32D583) // green + static let warning = Color.dynamic(light: 0xE88A00, dark: 0xFFB020) // amber + static let negative = Color.dynamic(light: 0xEB3B4E, dark: 0xFF6170) // red + static let info = Color.dynamic(light: 0x0A8DE6, dark: 0x3DB8FF) // sky + static let special = Color.dynamic(light: 0x7A5AF8, dark: 0xA48AFB) // violet + static let paused = Color.dynamic(light: 0x8E8E96, dark: 0x8E8E96) // grey // Presence conventions from the web app: viewing = emerald, editing/replying = amber. static let presenceViewing = positive @@ -47,17 +84,49 @@ enum WTheme { /// Deterministic avatar tint per user id, mirroring the web avatar stack. static func avatarColor(for id: String) -> Color { let palette: [Color] = [ - .dynamic(light: 0x0284C7, dark: 0x38BDF8), - .dynamic(light: 0x7C3AED, dark: 0xA78BFA), - .dynamic(light: 0x059669, dark: 0x34D399), - .dynamic(light: 0xD97706, dark: 0xFBBF24), - .dynamic(light: 0xDB2777, dark: 0xF472B6), - .dynamic(light: 0x4F46E5, dark: 0x818CF8), - .dynamic(light: 0xEA580C, dark: 0xFB923C), - .dynamic(light: 0x0D9488, dark: 0x2DD4BF), + .dynamic(light: 0x0A8DE6, dark: 0x2FA8F5), + .dynamic(light: 0x7A5AF8, dark: 0x8B6CF9), + .dynamic(light: 0x12A150, dark: 0x22B26A), + .dynamic(light: 0xF2A100, dark: 0xF2A100), + .dynamic(light: 0xEC4899, dark: 0xF05FA9), + .dynamic(light: 0x5B6CF9, dark: 0x6D7CFA), + .dynamic(light: 0xF97316, dark: 0xFB8435), + .dynamic(light: 0x0FB5A6, dark: 0x1CC2B3), ] var hash = 5381 for byte in id.utf8 { hash = ((hash << 5) &+ hash) &+ Int(byte) } return palette[abs(hash) % palette.count] } } + +/// Type scale: medium-weight display with tight tracking (the site's h1/h2), +/// monospaced meta for labels, ids and timestamps. +enum WFont { + static func display(_ size: CGFloat, weight: Font.Weight = .medium) -> Font { + .system(size: size, weight: weight) + } + + /// Meta text (times, counts, small labels): the system face with tabular + /// digits, a step up from the old monospaced sizes so call sites keep theirs. + static func meta(_ size: CGFloat, weight: Font.Weight = .regular) -> Font { + .system(size: size + 1.5, weight: weight).monospacedDigit() + } + + /// Real code and data: tool arguments, code blocks. + static func code(_ size: CGFloat, weight: Font.Weight = .regular) -> Font { + .system(size: size, weight: weight, design: .monospaced) + } + + /// Tracking that keeps large display type tight, as on the site. + static func tracking(for size: CGFloat) -> CGFloat { + size >= 20 ? -size * 0.035 : -size * 0.01 + } +} + +extension View { + /// Display text at `size`: medium weight, tight tracking. + func displayFont(_ size: CGFloat, weight: Font.Weight = .medium) -> some View { + font(WFont.display(size, weight: weight)) + .tracking(WFont.tracking(for: size)) + } +} diff --git a/ios/Warmbly/DesignSystem/TrendChart.swift b/ios/Warmbly/DesignSystem/TrendChart.swift new file mode 100644 index 000000000..8801d7ca2 --- /dev/null +++ b/ios/Warmbly/DesignSystem/TrendChart.swift @@ -0,0 +1,259 @@ +import Charts +import SwiftUI + +/// One UTC day of sending, the unit every trend is reported in. +struct TrendDay: Identifiable { + let date: Date + var sent = 0 + var opens = 0 + var clicks = 0 + var replies = 0 + + var id: Date { date } + + /// A continuous run of `count` UTC days ending today, zero where `values` + /// (keyed `yyyy-MM-dd`) has no entry, since trends only list active days. + static func lastDays(_ count: Int, values: [String: TrendDay]) -> [TrendDay] { + let today = utc.startOfDay(for: .now) + return (0 ..< count).map { offset in + let date = utc.date(byAdding: .day, value: offset - (count - 1), to: today)! + let value = values[key.string(from: date)] + return TrendDay( + date: date, + sent: value?.sent ?? 0, + opens: value?.opens ?? 0, + clicks: value?.clicks ?? 0, + replies: value?.replies ?? 0 + ) + } + } + + static let utc: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + }() + + static let key: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = utc + formatter.timeZone = utc.timeZone + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() +} + +/// A number that counts up to its value. +struct CountUpText: View, Animatable { + var value: Double + var format: (Double) -> String + + var animatableData: Double { + get { value } + set { value = newValue } + } + + var body: some View { + Text(format(value)) + } +} + +// MARK: - Trend + +/// Sent, opens and replies in one chart you can scrub: drag across it and the +/// readout shows every metric for that day, a tick per day. The lines draw in +/// on arrival and again whenever the range changes. +struct TrendChart: View { + let days: [TrendDay] + var shown: Bool + var caption = "Last 14 days" + + enum Metric: String, CaseIterable, Identifiable { + case sent = "Sent" + case opens = "Opens" + case replies = "Replies" + + var id: String { rawValue } + + var color: Color { + switch self { + case .sent: WTheme.accent + case .opens: WTheme.special + case .replies: WTheme.positive + } + } + + func value(_ day: TrendDay) -> Int { + switch self { + case .sent: day.sent + case .opens: day.opens + case .replies: day.replies + } + } + } + + @State private var selected: Date? + @State private var drawn: CGFloat = 0 + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + /// Days are UTC buckets, so they are named in UTC. + private static let dayFormat: Date.FormatStyle = { + var style = Date.FormatStyle.dateTime.weekday(.wide).month(.abbreviated).day() + style.timeZone = TimeZone(identifier: "UTC")! + return style + }() + + private var selectedDay: TrendDay? { + guard let selected else { return nil } + var utc = Calendar(identifier: .gregorian) + utc.timeZone = TimeZone(identifier: "UTC")! + return days.first { utc.isDate($0.date, inSameDayAs: selected) } + } + + private var peak: Double { + Double(days.map { max($0.sent, $0.opens, $0.replies) }.max() ?? 0) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + readout + chart + } + .sensoryFeedback(.selection, trigger: selectedDay?.date) + .onAppear { if shown { draw(delay: 0.25) } } + .onChange(of: shown) { _, now in if now { draw(delay: 0.25) } } + .onChange(of: days.first?.date) { + guard shown else { return } + selected = nil + drawn = 0 + draw(delay: 0.05) + } + } + + private func draw(delay: Double) { + if reduceMotion { drawn = 1; return } + withAnimation(.easeOut(duration: 1.1).delay(delay)) { drawn = 1 } + } + + private var lastDay: TrendDay? { days.last } + + private var readout: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 0) { + ForEach(Metric.allCases) { metric in + let value = selectedDay.map(metric.value) ?? days.reduce(0) { $0 + metric.value($1) } + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Circle().fill(metric.color).frame(width: 7, height: 7) + Text(metric.rawValue) + .font(.system(size: 13.5, weight: .medium)) + .foregroundStyle(WTheme.inkSecondary) + } + Text("\(value)") + .font(.system(size: 26, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(WTheme.ink) + .contentTransition(.numericText(value: Double(value))) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + Text(selectedDay.map { Self.dayFormat.format($0.date) } ?? caption) + .font(.system(size: 13.5)) + .foregroundStyle(WTheme.inkTertiary) + .contentTransition(.opacity) + } + .animation(.snappy(duration: 0.25), value: selectedDay?.date) + .animation(.snappy(duration: 0.4), value: days.first?.date) + } + + private var chart: some View { + Chart { + ForEach(days) { day in + AreaMark( + x: .value("Day", day.date, unit: .day), + y: .value("Count", day.sent), + series: .value("Metric", "Sent area") + ) + .interpolationMethod(.monotone) + .foregroundStyle(LinearGradient( + colors: [Metric.sent.color.opacity(0.22), Metric.sent.color.opacity(0.01)], + startPoint: .top, + endPoint: .bottom + )) + } + ForEach(Metric.allCases) { metric in + ForEach(days) { day in + LineMark( + x: .value("Day", day.date, unit: .day), + y: .value("Count", metric.value(day)), + series: .value("Metric", metric.rawValue) + ) + .interpolationMethod(.monotone) + .foregroundStyle(metric.color) + .lineStyle(StrokeStyle(lineWidth: metric == .sent ? 2.5 : 2, lineCap: .round, lineJoin: .round)) + } + } + if selectedDay == nil, let today = lastDay, drawn >= 1 { + // Today is still being written: a soft ping where the line ends. + PointMark( + x: .value("Day", today.date, unit: .day), + y: .value("Count", today.sent) + ) + .symbol { LiveDot(color: Metric.sent.color, size: 8) } + } + if let day = selectedDay { + RuleMark(x: .value("Day", day.date, unit: .day)) + .foregroundStyle(WTheme.ink.opacity(0.16)) + .lineStyle(StrokeStyle(lineWidth: 1.5)) + ForEach(Metric.allCases) { metric in + PointMark( + x: .value("Day", day.date, unit: .day), + y: .value("Count", metric.value(day)) + ) + .symbolSize(90) + .foregroundStyle(metric.color) + } + } + } + .chartXSelection(value: $selected) + .chartXAxis { + AxisMarks(values: .stride(by: .day, count: max(1, days.count / 4))) { _ in + AxisValueLabel(format: .dateTime.month(.abbreviated).day(), verticalSpacing: 10) + .font(.system(size: 11.5)) + .foregroundStyle(WTheme.inkTertiary) + } + } + .chartYAxis(.hidden) + .chartYScale(domain: 0 ... max(1, peak * 1.15)) + .chartLegend(.hidden) + .chartPlotStyle { plot in plot.padding(.top, 8) } + .frame(height: 180) + .mask(alignment: .leading) { + GeometryReader { proxy in + Rectangle() + .frame(width: proxy.size.width * drawn) + } + } + } +} + +/// Section entrance: rises in after the hero, one beat apart. +struct Reveal: ViewModifier { + let shown: Bool + let index: Int + + func body(content: Content) -> some View { + content + .opacity(shown ? 1 : 0) + .offset(y: shown ? 0 : 18) + .animation(.spring(response: 0.6, dampingFraction: 0.86).delay(0.08 + Double(index) * 0.06), value: shown) + } +} + +extension View { + func reveal(_ shown: Bool, _ index: Int) -> some View { + modifier(Reveal(shown: shown, index: index)) + } +} diff --git a/ios/Warmbly/Features/Analytics/AnalyticsAccountDetailView.swift b/ios/Warmbly/Features/Analytics/AnalyticsAccountDetailView.swift index 11c8e488d..400536117 100644 --- a/ios/Warmbly/Features/Analytics/AnalyticsAccountDetailView.swift +++ b/ios/Warmbly/Features/Analytics/AnalyticsAccountDetailView.swift @@ -1,38 +1,42 @@ import SwiftUI -/// Per-mailbox health detail, backed by GET analytics/accounts/:id. -/// Seeded with the list row so the push renders instantly. One flat plain -/// list: eyebrow captions as bare rows, bare stat columns, hairlines. +/// One mailbox's health, backed by GET analytics/accounts/:id and seeded with +/// the list row so the push renders at once: its score and issues, today's +/// sending, its warmup ramp and standing, where warmup landed, and errors. struct AnalyticsAccountDetailView: View { @Environment(AppEnvironment.self) private var env let account: AccountHealthRow @State private var detail: AccountHealthRow? @State private var errorMessage: String? + @State private var shown = false private var current: AccountHealthRow { detail ?? account } private var presenceKey: String { "mailbox:\(account.id)" } var body: some View { - List { - headerSection - healthSection - usageSection - warmupSection - errorsSection - Color.clear - .frame(height: 24) - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - .listRowBackground(Color(.systemBackground)) + SettingsPage(title: "Mailbox health", spacing: 16) { + heroCard.reveal(shown, 0) + if let errorMessage, detail == nil { + AnalyticsSectionError(text: errorMessage).coverCard() + } + if let usage = current.dailyUsage { + todayCard(usage).reveal(shown, 1) + } + if current.warmupStatus?.enabled == true || current.warmupHealth?.state != nil { + warmupCard.reveal(shown, 2) + } + if let placement = current.warmupPlacement, (placement.delivered ?? 0) > 0 { + placementCard(placement).reveal(shown, 3) + } + if let errors = current.errors, !errors.isEmpty { + errorsCard(errors).reveal(shown, 4) + } + } + .task { + shown = true + await load() } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .environment(\.defaultMinListRowHeight, 0) - .background(Color(.systemBackground)) - .navigationTitle(current.email ?? "Mailbox") - .navigationBarTitleDisplayMode(.inline) - .task { await load() } .refreshable { await load() } .onChange(of: env.realtime.pulse(for: .emailAccounts)) { Task { await load() } @@ -43,198 +47,277 @@ struct AnalyticsAccountDetailView: View { private func load() async { do { let fresh: AccountHealthRow = try await env.api.get("analytics/accounts/\(account.id)") - detail = fresh + withAnimation(.snappy) { detail = fresh } errorMessage = nil } catch { errorMessage = error.localizedDescription } } - // MARK: Sections + // MARK: Hero - @ViewBuilder - private var headerSection: some View { - HStack(spacing: 12) { - IconTile(symbol: "envelope", tone: AnalyticsToneMap.health(current.health?.status), size: 42) - VStack(alignment: .leading, spacing: 3) { - Text(current.email ?? "mailbox") - .font(.body.weight(.semibold)) - .lineLimit(1) - HStack(spacing: 4) { - if let provider = current.provider, !provider.isEmpty { - Text(provider) - } - if let synced = current.lastSyncedAt { - Text("· synced \(WFormat.relative(synced))") - } - if current.inCampaign == true { - Text("· in campaign") + private var heroCard: some View { + let tone = AnalyticsToneMap.health(current.health?.status) + let score = current.health?.score ?? 0 + let issues = current.health?.issues ?? [] + return VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 14) { + MailboxAvatar(email: current.email ?? "", avatarURL: account.avatarURL, brand: account.resolvedBrand, seed: current.id, size: 52) + VStack(alignment: .leading, spacing: 3) { + Text(current.email ?? "Mailbox") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(WTheme.ink) + .lineLimit(1) + .truncationMode(.middle) + Text(subtitle) + .font(.system(size: 13.5)) + .foregroundStyle(WTheme.inkSecondary) + .lineLimit(1) + } + Spacer(minLength: 4) + ResourceViewers(resource: presenceKey) + } + Hairline() + HStack(spacing: 18) { + SweepRing(fraction: Double(score) / 100, color: tone.color, shown: shown, lineWidth: 9) { + CountUpText(value: shown ? Double(score) : 0, format: { "\(Int($0.rounded()))" }) + .font(.system(size: 28, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(WTheme.ink) + .animation(.easeOut(duration: 1.1).delay(0.15), value: shown) + } + .frame(width: 92, height: 92) + VStack(alignment: .leading, spacing: 6) { + StatusPill(text: healthLabel, tone: tone) + Text(issues.isEmpty ? "Nothing needs attention" : "\(issues.count) \(issues.count == 1 ? "thing" : "things") to look at") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(WTheme.ink) + Text("Health score out of 100") + .font(.system(size: 13)) + .foregroundStyle(WTheme.inkTertiary) + } + Spacer(minLength: 0) + } + if !issues.isEmpty { + VStack(alignment: .leading, spacing: 10) { + ForEach(issues, id: \.self) { issue in + HStack(alignment: .top, spacing: 10) { + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(WTheme.warning) + .padding(.top, 1) + Text(issue) + .font(.system(size: 14.5)) + .foregroundStyle(WTheme.ink) + .fixedSize(horizontal: false, vertical: true) + } } } - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Tone.amber.background, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } - Spacer() - ResourceViewers(resource: presenceKey) } - .padding(.top, 16) - .padding(.bottom, 6) - .analyticsPlainRow(separator: .hidden) - if let errorMessage, detail == nil { - AnalyticsSectionError(text: errorMessage) - .analyticsPlainRow(separator: .hidden) + .coverCard() + } + + private var subtitle: String { + var parts = [account.providerLabel] + if let synced = current.lastSyncedAt { parts.append("synced \(WFormat.ago(synced)) ago") } + if current.inCampaign == true { parts.append("in a campaign") } + return parts.joined(separator: " · ") + } + + private var healthLabel: String { + switch current.health?.status { + case "healthy": "Healthy" + case "warning": "Warning" + case "error": "Needs fixing" + default: "Unknown" } } - @ViewBuilder - private var healthSection: some View { - AnalyticsSectionCaption("Health") - HStack(alignment: .top, spacing: 14) { - AnalyticsMiniStat( - label: "Status", - value: current.health?.status ?? "unknown", - tone: AnalyticsToneMap.health(current.health?.status) - ) - VStack(alignment: .leading, spacing: 4) { - EyebrowLabel("Score") - HealthRing( - score: current.health?.score, - tone: AnalyticsToneMap.health(current.health?.status), - size: 38 - ) - } - .frame(maxWidth: .infinity, alignment: .leading) - AnalyticsMiniStat(label: "Sync status", value: current.status ?? "–") - } - .padding(.vertical, 6) - .analyticsPlainRow(separator: .hidden) - ForEach(current.health?.issues ?? [], id: \.self) { issue in - HStack(alignment: .top, spacing: 8) { - Circle() - .fill(WTheme.warning) - .frame(width: 5, height: 5) - .padding(.top, 6) - Text(issue) - .font(.subheadline) - .foregroundStyle(.primary) - } - .padding(.vertical, 4) - .analyticsPlainRow(separator: .hidden) - } - } + // MARK: Today - @ViewBuilder - private var usageSection: some View { - if let usage = current.dailyUsage { - AnalyticsSectionCaption("Today's usage") - AnalyticsUsageBarRow( - label: "Campaign", - sent: usage.campaignSent ?? 0, - limit: usage.campaignLimit ?? 0, - tint: WTheme.accent - ) - .analyticsPlainRow(separator: .hidden) + private func todayCard(_ usage: AnalyticsAccountUsage) -> some View { + VStack(alignment: .leading, spacing: 16) { + CardTitle("Today", caption: "Sends against this mailbox's daily limits") + usageRow("Campaign", symbol: "paperplane.fill", sent: usage.campaignSent ?? 0, limit: usage.campaignLimit ?? 0, color: WTheme.accent, delay: 0.2) if let warmupLimit = usage.warmupLimit, warmupLimit > 0 { - AnalyticsUsageBarRow( - label: "Warmup", - sent: usage.warmupSent ?? 0, - limit: warmupLimit, - tint: Tone.orange.color - ) - .analyticsPlainRow(separator: .hidden) + usageRow("Warmup", symbol: "flame.fill", sent: usage.warmupSent ?? 0, limit: warmupLimit, color: Tone.orange.color, delay: 0.3) } } + .coverCard() } - @ViewBuilder - private var warmupSection: some View { - if current.warmupStatus != nil || current.warmupHealth != nil { - AnalyticsSectionCaption("Warmup", icon: "flame", iconTone: .orange) - if let status = current.warmupStatus { - HStack(spacing: 14) { - AnalyticsMiniStat(label: "Volume", value: "\(status.currentVolume ?? 0)") - AnalyticsMiniStat(label: "Target", value: "\(status.targetVolume ?? 0)") - AnalyticsMiniStat(label: "Max", value: "\(status.maxVolume ?? 0)") - } - .padding(.vertical, 6) - .analyticsPlainRow(separator: .hidden) - HStack(spacing: 14) { - AnalyticsMiniStat(label: "Reply rate", value: "\(status.replyRate ?? 0)%") - AnalyticsMiniStat(label: "Days active", value: "\(status.daysActive ?? 0)") - AnalyticsMiniStat( - label: "State", - value: status.paused == true ? "paused" : (status.enabled == true ? "warming" : "off"), - tone: status.paused == true ? .amber : (status.enabled == true ? .orange : .slate) - ) - } - .padding(.vertical, 6) - .analyticsPlainRow(separator: .hidden) + private func usageRow(_ label: String, symbol: String, sent: Int, limit: Int, color: Color, delay: Double) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: symbol) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(color) + .frame(width: 18) + Text(label) + .font(.system(size: 15)) + .foregroundStyle(WTheme.ink) + Spacer() + Text("\(Text("\(sent)").foregroundStyle(WTheme.ink).fontWeight(.semibold)) of \(limit)") + .font(.system(size: 14)) + .monospacedDigit() + .foregroundStyle(WTheme.inkSecondary) } - if let health = current.warmupHealth { - HStack(spacing: 8) { - StatusPill(text: health.state ?? "unknown", tone: AnalyticsToneMap.warmupState(health.state)) - if let spam = health.spamScore { - Text("spam \(spam)") - .font(.footnote) - .monospacedDigit() - .foregroundStyle(.secondary) - } - if let blockedUntil = health.blockedUntil { - Text("blocked until \(blockedUntil.formatted(date: .abbreviated, time: .shortened))") - .font(.footnote) - .monospacedDigit() - .foregroundStyle(WTheme.negative) - } + GrowBar(fraction: limit > 0 ? Double(sent) / Double(limit) : 0, color: color, shown: shown, delay: delay, height: 8) + .padding(.leading, 26) + } + .accessibilityElement(children: .combine) + } + + // MARK: Warmup + + private var warmupCard: some View { + let status = current.warmupStatus + let ceiling = max(1, status?.maxVolume ?? 40) + let today = min(ceiling, status?.targetVolume ?? 0) + let paused = status?.paused == true + let health = current.warmupHealth + return VStack(alignment: .leading, spacing: 16) { + CardTitle(title: "Warmup", caption: paused ? "Paused" : "Day \(status?.daysActive ?? 0) of the ramp") { + if let state = health?.state { + StatusPill(text: Self.standing(state), tone: AnalyticsToneMap.warmupState(state)) + } + } + VStack(alignment: .leading, spacing: 8) { + WarmupBar(progress: shown ? Double(today) / Double(ceiling) : 0, paused: paused) + .frame(height: 10) + HStack { + Text("\(Text("\(today)").foregroundStyle(WTheme.ink).fontWeight(.semibold)) a day now") Spacer() + Text("ceiling \(ceiling)") } - .padding(.vertical, 4) - .analyticsPlainRow(separator: .hidden) - if let reason = health.reason, !reason.isEmpty { - Text(reason) - .font(.footnote) - .foregroundStyle(.secondary) - .padding(.vertical, 2) - .analyticsPlainRow(separator: .hidden) - } + .font(.system(size: 13)) + .monospacedDigit() + .foregroundStyle(WTheme.inkSecondary) } + HStack(spacing: 0) { + AnalyticsMiniStat(label: "Sent today", value: "\(status?.currentVolume ?? 0)") + AnalyticsMiniStat(label: "Reply rate", value: "\(status?.replyRate ?? 0)%") + AnalyticsMiniStat(label: "Days", value: "\(status?.daysActive ?? 0)") + } + if let reason = health?.reason, !reason.isEmpty { + Text(reason) + .font(.system(size: 13.5)) + .foregroundStyle(WTheme.inkSecondary) + .fixedSize(horizontal: false, vertical: true) + } + if let until = health?.blockedUntil { + Label("Held until \(until.formatted(.dateTime.month(.abbreviated).day().hour().minute()))", systemImage: "clock") + .font(.system(size: 13.5, weight: .medium)) + .foregroundStyle(WTheme.negative) + } + } + .coverCard() + } + + static func standing(_ state: String) -> String { + switch state { + case "healthy": "Good standing" + case "watch": "Watched" + case "throttled": "Slowed down" + case "quarantined": "Quarantined" + case "blocked": "Blocked" + default: state.prefix(1).uppercased() + state.dropFirst() } } - @ViewBuilder - private var errorsSection: some View { - let errors = current.errors ?? [] - if !errors.isEmpty { - AnalyticsSectionCaption("Errors") - ForEach(errors) { item in + // MARK: Placement + + private func placementCard(_ placement: AnalyticsPlacement) -> some View { + let delivered = max(1, placement.delivered ?? 1) + let parts: [(String, Int, Color)] = [ + ("Inbox", placement.inbox ?? 0, WTheme.positive), + ("Tabs", placement.tabs ?? 0, WTheme.accent), + ("Spam", placement.spam ?? 0, WTheme.negative), + ] + let rate = placement.inboxRate ?? 0 + return VStack(alignment: .leading, spacing: 14) { + CardTitle(title: "Where warmup landed", caption: "\(placement.delivered ?? 0) verified deliveries, last \(placement.windowDays ?? 7) days") { + Text(String(format: "%.0f%%", rate)) + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(rate >= 90 ? WTheme.positive : rate >= 80 ? WTheme.warning : WTheme.negative) + } + GeometryReader { proxy in + HStack(spacing: 3) { + ForEach(Array(parts.enumerated()), id: \.offset) { index, part in + if part.1 > 0 { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(part.2) + .frame(width: shown ? max(6, (proxy.size.width - 6) * CGFloat(part.1) / CGFloat(delivered)) : 0) + .animation(.spring(response: 0.9, dampingFraction: 0.8).delay(0.25 + Double(index) * 0.1), value: shown) + } + } + Spacer(minLength: 0) + } + } + .frame(height: 12) + HStack(spacing: 0) { + ForEach(Array(parts.enumerated()), id: \.offset) { _, part in + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Circle().fill(part.2).frame(width: 7, height: 7) + Text(part.0) + .font(.system(size: 13)) + .foregroundStyle(WTheme.inkSecondary) + } + Text("\(part.1)") + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(WTheme.ink) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .coverCard() + } + + // MARK: Errors + + private func errorsCard(_ errors: [AnalyticsAccountError]) -> some View { + VStack(alignment: .leading, spacing: 4) { + CardTitle("Errors").padding(.bottom, 8) + ForEach(Array(errors.enumerated()), id: \.element.id) { index, item in + if index > 0 { Hairline().padding(.leading, 46) } HStack(alignment: .top, spacing: 12) { - IconTile(symbol: "exclamationmark.triangle", tone: severityTone(item.severity), size: 34) + SettingsIcon(symbol: "exclamationmark.triangle.fill", color: severityTone(item.severity).color, size: 34) VStack(alignment: .leading, spacing: 3) { Text(item.title ?? item.errorCode ?? "Error") - .font(.body.weight(.medium)) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(WTheme.ink) if let message = item.message, !message.isEmpty { Text(message) - .font(.subheadline) - .foregroundStyle(.secondary) + .font(.system(size: 13.5)) + .foregroundStyle(WTheme.inkSecondary) + .fixedSize(horizontal: false, vertical: true) } if let action = item.actionRequired, !action.isEmpty { Text(action) - .font(.footnote.weight(.medium)) + .font(.system(size: 13.5, weight: .medium)) .foregroundStyle(WTheme.warning) } } - Spacer() + Spacer(minLength: 4) if let created = item.createdAt { - Text(WFormat.relative(created)) - .font(.footnote) + Text(WFormat.ago(created)) + .font(.system(size: 13)) .monospacedDigit() - .foregroundStyle(.secondary) + .foregroundStyle(WTheme.inkTertiary) } } - .padding(.vertical, 8) - .analyticsPlainRow(separatorLeading: 62) + .padding(.vertical, 10) } } + .coverCard() } private func severityTone(_ raw: String?) -> Tone { @@ -245,28 +328,3 @@ struct AnalyticsAccountDetailView: View { } } } - -/// "42 of 50 / day" with a thin progress track. -struct AnalyticsUsageBarRow: View { - let label: String - let sent: Int - let limit: Int - let tint: Color - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(label) - .font(.subheadline.weight(.medium)) - Spacer() - Text(limit > 0 ? "\(sent) of \(limit) / day" : "\(sent) sent") - .font(.footnote) - .monospacedDigit() - .foregroundStyle(.secondary) - } - ProgressView(value: limit > 0 ? min(1, Double(sent) / Double(limit)) : 0) - .tint(tint) - } - .padding(.vertical, 6) - } -} diff --git a/ios/Warmbly/Features/Analytics/AnalyticsModels.swift b/ios/Warmbly/Features/Analytics/AnalyticsModels.swift index 7cdf69fe2..12ea4047a 100644 --- a/ios/Warmbly/Features/Analytics/AnalyticsModels.swift +++ b/ios/Warmbly/Features/Analytics/AnalyticsModels.swift @@ -60,6 +60,34 @@ struct AnalyticsDataEnvelope: Codable, Sendable { var data: [T]? } +/// Sends and engagement summed over a run of days. +struct AnalyticsTotals: Equatable, Sendable { + var sent = 0 + var opens = 0 + var clicks = 0 + var replies = 0 + + var openRate: Double? { sent > 0 ? Double(opens) / Double(sent) * 100 : nil } + var clickRate: Double? { sent > 0 ? Double(clicks) / Double(sent) * 100 : nil } + var replyRate: Double? { sent > 0 ? Double(replies) / Double(sent) * 100 : nil } + + /// The `days` UTC days ending `days` ago, summed from a longer trend. + static func before(days: Int, in trend: [AnalyticsTrendPoint]) -> AnalyticsTotals { + let today = TrendDay.utc.startOfDay(for: .now) + guard let end = TrendDay.utc.date(byAdding: .day, value: -days, to: today), + let start = TrendDay.utc.date(byAdding: .day, value: -(days * 2 - 1), to: today) else { return .init() } + var totals = AnalyticsTotals() + for point in trend { + guard let day = AnalyticsDay.parse(point.date.map { String($0.prefix(10)) }), day >= start, day <= end else { continue } + totals.sent += point.sent ?? 0 + totals.opens += point.opens ?? 0 + totals.clicks += point.clicks ?? 0 + totals.replies += point.replies ?? 0 + } + return totals + } +} + // MARK: - Dashboard (GET analytics/dashboard) struct DashboardAnalytics: Codable, Sendable { @@ -226,6 +254,8 @@ struct DeliverabilitySummary: Codable, Sendable { var intentOutOfOffice: Int? var intentQuestion: Int? var intentNeutral: Int? + var intentAutomated: Int? + var score: Int? var emailsSent: Int? var bounceRate: Double? var complaintRate: Double? @@ -258,6 +288,8 @@ struct DeliverabilitySummary: Codable, Sendable { case intentOutOfOffice = "intent_out_of_office" case intentQuestion = "intent_question" case intentNeutral = "intent_neutral" + case intentAutomated = "intent_automated" + case score case emailsSent = "emails_sent" case bounceRate = "bounce_rate" case complaintRate = "complaint_rate" @@ -399,7 +431,15 @@ struct AccountHealthRow: Codable, Sendable, Identifiable { var dailyUsage: AnalyticsAccountUsage? var warmupStatus: AnalyticsWarmupStatusInfo? var warmupHealth: AnalyticsWarmupHealthInfo? + var warmupPlacement: AnalyticsPlacement? var inCampaign: Bool? + /// Joined from the mailbox list by the store; this endpoint does not carry them. + var brand: MailboxBrand? + var avatarURL: String? + + var providerLabel: String { resolvedBrand.label } + + var resolvedBrand: MailboxBrand { brand ?? MailboxBrand(provider: provider, mailHost: nil) } enum CodingKeys: String, CodingKey { case id, email, provider, status, health, errors @@ -407,6 +447,7 @@ struct AccountHealthRow: Codable, Sendable, Identifiable { case dailyUsage = "daily_usage" case warmupStatus = "warmup_status" case warmupHealth = "warmup_health" + case warmupPlacement = "warmup_placement" case inCampaign = "in_campaign" } } @@ -478,6 +519,25 @@ struct AnalyticsWarmupStatusInfo: Codable, Sendable { } } +/// Where verified warmup deliveries landed at the big three providers. +struct AnalyticsPlacement: Codable, Sendable { + var windowDays: Int? + var minSample: Int? + var delivered: Int? + var inbox: Int? + var tabs: Int? + var spam: Int? + var inboxRate: Double? + var band: String? + + enum CodingKeys: String, CodingKey { + case delivered, inbox, tabs, spam, band + case windowDays = "window_days" + case minSample = "min_sample" + case inboxRate = "inbox_rate" + } +} + struct AnalyticsWarmupHealthInfo: Codable, Sendable { /// "healthy" | "watch" | "throttled" | "quarantined" | "blocked" var state: String? diff --git a/ios/Warmbly/Features/Analytics/AnalyticsRootView.swift b/ios/Warmbly/Features/Analytics/AnalyticsRootView.swift index bda529491..9b297bc8e 100644 --- a/ios/Warmbly/Features/Analytics/AnalyticsRootView.swift +++ b/ios/Warmbly/Features/Analytics/AnalyticsRootView.swift @@ -1,5 +1,4 @@ import SwiftUI -import Charts // MARK: - Scopes @@ -14,7 +13,7 @@ enum AnalyticsScope: String, CaseIterable, Identifiable { case .overview: "Overview" case .deliverability: "Deliverability" case .warmup: "Warmup" - case .accounts: "Accounts" + case .accounts: "Mailboxes" } } @@ -37,65 +36,32 @@ enum AnalyticsScope: String, CaseIterable, Identifiable { } } -// MARK: - Trend metric - -/// Chart metric, driven by tapping the stat strip cells (bounces are not in -/// the daily trend payload, so the bounced cell is display-only). -enum AnalyticsTrendMetric: String, CaseIterable { - case sent, opens, clicks, replies - - var title: String { +extension AnalyticsPeriod { + var label: String { switch self { - case .sent: "Sent" - case .opens: "Opens" - case .clicks: "Clicks" - case .replies: "Replies" + case .week: "7 days" + case .month: "30 days" + case .quarter: "90 days" } } - - var color: Color { - switch self { - case .sent: WTheme.accent - case .opens: WTheme.positive - case .clicks: Tone.indigo.color - case .replies: Tone.emerald.color - } - } - - func value(_ point: AnalyticsTrendPoint) -> Int { - switch self { - case .sent: point.sent ?? 0 - case .opens: point.opens ?? 0 - case .clicks: point.clicks ?? 0 - case .replies: point.replies ?? 0 - } - } -} - -struct AnalyticsChartPoint: Identifiable { - let id: Int - let day: Date - let value: Int } // MARK: - View -/// Analytics browser, presented as a full-screen cover from Home or More -/// (like the mailboxes and CRM browsers): a sky hero with the period picker -/// and headline chips, a slide-in drawer of sections, and the selected -/// section as a flat full-bleed list on a rounded white sheet. Owns its -/// NavigationStack; dismissal goes through `onClose` (the environment -/// DismissAction is unreliable in this app's cover contexts). +/// Analytics browser, presented full screen from Home or Settings: a glass +/// header with the drawer of sections, the period and campaign scope, and the +/// selected section as cards on the grouped page. Owns its NavigationStack; +/// dismissal goes through `onClose`. struct AnalyticsRootView: View { var onClose: () -> Void = {} @Environment(AppEnvironment.self) private var env @State private var store = AnalyticsStore() @State private var scope: AnalyticsScope = .overview - @State private var metric: AnalyticsTrendMetric = .sent @State private var sidebarOpen = false @State private var sidebarDrag: CGFloat = 0 @State private var filterOpen = false + @State private var shown = false private static let sidebarWidth: CGFloat = 300 @@ -113,27 +79,17 @@ struct AnalyticsRootView: View { private var noAccess: some View { VStack(spacing: 0) { - HStack { - Spacer() - Button { - onClose() - } label: { - Image(systemName: "xmark") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(.primary) - .frame(width: 44, height: 44) - .background(Color(.secondarySystemBackground), in: Circle()) - } - .buttonStyle(TapScaleStyle()) - .accessibilityLabel("Close analytics") - } - .padding(.horizontal, 12) - .padding(.top, 4) - EmptyStateView( + CoverHeader(title: "Analytics", onClose: onClose) + Spacer() + CoverEmptyState( + symbol: "lock.fill", + color: WTheme.inkSecondary, title: "No access", - message: "You need the view analytics permission to see this page." + message: "Ask a workspace admin for the view analytics permission." ) + Spacer() } + .background(WTheme.groupedPage) .toolbarVisibility(.hidden, for: .navigationBar) } @@ -153,10 +109,16 @@ struct AnalyticsRootView: View { drawer(topInset: geo.safeAreaInsets.top) } } - // Own chrome: the sky hero runs to the top edge and carries the - // hamburger + close buttons. .toolbarVisibility(.hidden, for: .navigationBar) - .task { await store.load(env.api) } + .task { + #if DEBUG + if let raw = ProcessInfo.processInfo.environment["WARMBLY_DEBUG_ANALYTICS"], let debug = AnalyticsScope(rawValue: raw) { + scope = debug + } + #endif + await store.load(env.api) + shown = true + } .onChange(of: env.realtime.pulse(for: .analytics)) { Task { await store.load(env.api) } } @@ -172,8 +134,6 @@ struct AnalyticsRootView: View { } } .sensoryFeedback(.selection, trigger: scope) - .sensoryFeedback(.selection, trigger: store.period) - .sensoryFeedback(.selection, trigger: metric) .sensoryFeedback(.impact(weight: .light), trigger: sidebarOpen) } @@ -181,17 +141,43 @@ struct AnalyticsRootView: View { private var mainPane: some View { VStack(spacing: 0) { - hero - sheet + CoverHeader( + title: scope == .overview ? "Analytics" : scope.title, + subtitle: scope == .accounts + ? "\(store.accounts.count) \(store.accounts.count == 1 ? "mailbox" : "mailboxes")" + : "Last \(store.period.label)", + onMenu: openSidebar, + onClose: onClose + ) + ScrollView { + VStack(alignment: .leading, spacing: 16) { + controls + Group { + switch scope { + case .overview: overview + case .deliverability: AnalyticsDeliverabilityPage(store: store, shown: shown) + case .warmup: AnalyticsWarmupPage(store: store, shown: shown) + case .accounts: AnalyticsAccountsPage(store: store, shown: shown) + } + } + .id(scope) + .transition(.asymmetric( + insertion: .opacity.combined(with: .offset(y: 14)), + removal: .opacity + )) + } + .padding(.horizontal, 16) + .padding(.top, 4) + .padding(.bottom, 60) + } + .scrollIndicators(.hidden) + .defaultScrollAnchor(SettingsDebug.anchor) + .refreshable { await store.load(env.api) } } - .background(alignment: .top) { - AirSkyWash().ignoresSafeArea(edges: .top) - } - .background(Color(.systemBackground)) + .background(WTheme.groupedPage) .simultaneousGesture( DragGesture(minimumDistance: 25) .onEnded { value in - // Gmail's edge swipe: open the drawer from the left edge. if !sidebarOpen, value.startLocation.x < 44, value.translation.width > 70 { openSidebar() } @@ -199,421 +185,317 @@ struct AnalyticsRootView: View { ) } - // MARK: Hero - - private var hero: some View { - VStack(alignment: .leading, spacing: 14) { - topRow - periodPicker - campaignFilterChip - heroStats - } - .padding(.horizontal, 16) - .padding(.top, 2) - .padding(.bottom, 18) - .frame(maxWidth: .infinity, alignment: .leading) - } - - private var topRow: some View { - HStack(spacing: 12) { - skyCircleButton("line.3.horizontal", label: "Open analytics menu") { - openSidebar() - } - HStack(spacing: 8) { - WarmblyLogo() - .fill(.white) - .frame(width: 21, height: 21 * (764 / 746)) - Text("Analytics") - .font(.system(size: 20, weight: .heavy)) - .tracking(-0.4) - .foregroundStyle(.white) - .fixedSize() - } - Spacer() - PresenceAvatars() - skyCircleButton("xmark", label: "Close analytics", size: 15) { - onClose() - } - } - } - - private func skyCircleButton( - _ symbol: String, - label: String, - size: CGFloat = 17, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - Image(systemName: symbol) - .font(.system(size: size, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 40, height: 40) - .background(.white.opacity(0.16), in: Circle()) - } - .buttonStyle(TapScaleStyle()) - .accessibilityLabel(label) - } - - /// White-glass capsule row bound to the store period; every load reacts. - private var periodPicker: some View { - HStack(spacing: 7) { - ForEach(AnalyticsPeriod.allCases) { period in - let selected = store.period == period - Button { - withAnimation(.snappy) { store.period = period } - } label: { - Text(period.rawValue) - .font(.footnote.weight(selected ? .semibold : .medium)) - .monospacedDigit() - .foregroundStyle(selected ? .white : .white.opacity(0.7)) - .padding(.horizontal, 13) - .padding(.vertical, 6) - .background(.white.opacity(selected ? 0.22 : 0.10), in: Capsule()) - } - .buttonStyle(TapScaleStyle()) - .accessibilityLabel("Last \(period.days) days") - .accessibilityAddTraits(selected ? .isSelected : []) - } - } - .padding(.horizontal, 4) - } + // MARK: Controls private var scopeLabel: String { store.scopeLabel { id in env.session.user?.folders?.first { $0.id == id }?.name } } - /// Opens the campaign filter; the overview's campaign numbers follow it. - private var campaignFilterChip: some View { + private var controls: some View { + VStack(alignment: .leading, spacing: 10) { + if scope != .accounts { + PillSwitcher( + options: AnalyticsPeriod.allCases, + selection: Binding(get: { store.period }, set: { store.period = $0 }), + label: \.label + ) + } + if scope == .overview { + filterChip + } + } + } + + private var filterChip: some View { let scoped = !store.campaignFilter.isEmpty - return HStack(spacing: 7) { + return HStack(spacing: 8) { Button { filterOpen = true } label: { - HStack(spacing: 6) { + HStack(spacing: 7) { Image(systemName: "megaphone.fill") - .font(.system(size: 11, weight: .semibold)) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(scoped ? WTheme.accent : WTheme.inkSecondary) Text(scopeLabel) - .font(.footnote.weight(.semibold)) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(WTheme.ink) .lineLimit(1) Image(systemName: "chevron.down") - .font(.system(size: 9, weight: .bold)) + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(WTheme.inkTertiary) } - .foregroundStyle(.white) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(.white.opacity(scoped ? 0.26 : 0.12), in: Capsule()) + .padding(.horizontal, 13) + .frame(height: 34) + .background(scoped ? WTheme.accentSoft : WTheme.groupedCard, in: Capsule()) } .buttonStyle(TapScaleStyle()) .accessibilityLabel("Campaign filter: \(scopeLabel)") - // Deliverability, warmup and accounts are not campaign data, so the filter skips them. - if scoped, scope != .overview { - Text("Headline numbers only") - .font(.caption.weight(.medium)) - .foregroundStyle(.white.opacity(0.75)) - .lineLimit(1) - } if scoped { Button { withAnimation(.snappy) { store.campaignFilter = AnalyticsCampaignFilter() } } label: { Image(systemName: "xmark") - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(.white) - .frame(width: 26, height: 26) - .background(.white.opacity(0.12), in: Circle()) + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(WTheme.inkSecondary) + .frame(width: 34, height: 34) + .background(WTheme.groupedCard, in: Circle()) } .buttonStyle(TapScaleStyle()) + .transition(.scale.combined(with: .opacity)) .accessibilityLabel("Show all campaigns") } + Spacer(minLength: 0) } - .padding(.horizontal, 4) - } - - private var heroStats: some View { - let stats = store.dashboard?.overallStats - return HStack(spacing: 10) { - AirStatChip( - value: stats.map { WFormat.compact($0.totalEmailsSent ?? 0) } ?? "–", - label: "Sent", - symbol: "paperplane.fill" - ) - AirStatChip( - value: stats?.openRate.map { String(format: "%.0f%%", $0) } ?? "–", - label: "Open rate", - symbol: "envelope.open.fill" - ) - AirStatChip( - value: stats.map { WFormat.compact($0.totalReplies ?? 0) } ?? "–", - label: "Replies", - symbol: "arrowshape.turn.up.left.fill" - ) - } - .padding(.horizontal, 4) - } - - // MARK: Sheet - - private var sheetShape: UnevenRoundedRectangle { - UnevenRoundedRectangle(topLeadingRadius: 26, topTrailingRadius: 26, style: .continuous) - } - - private var sheet: some View { - sectionList - .frame(maxWidth: .infinity, maxHeight: .infinity) - .clipShape(sheetShape) - .background( - sheetShape - .fill(Color(.systemBackground)) - .ignoresSafeArea(edges: .bottom) - .shadow(color: .black.opacity(0.12), radius: 18, y: -4) - ) - } - - @ViewBuilder - private var sectionList: some View { - switch scope { - case .overview: overviewList.transition(.opacity) - case .deliverability: deliverabilityList.transition(.opacity) - case .warmup: warmupList.transition(.opacity) - case .accounts: accountsList.transition(.opacity) - } - } - - /// Full-bleed flat row (the row content manages its own gutters). - private func fullBleedRow(@ViewBuilder _ content: () -> Content) -> some View { - content() - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - .listRowBackground(Color(.systemBackground)) - } - - /// Breathing room after the last row of each section. - private var sectionFooter: some View { - Color.clear - .frame(height: 24) - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - .listRowBackground(Color(.systemBackground)) - } - - /// Tertiary trailing text for the scope caption rows. - private var periodCaption: some View { - Text("last \(store.period.rawValue)") - .font(.caption.weight(.medium)) - .monospacedDigit() - .foregroundStyle(.tertiary) - .contentTransition(.numericText()) + .animation(.snappy, value: scoped) } // MARK: Overview @ViewBuilder - private var overviewList: some View { + private var overview: some View { if store.dashboard != nil { - List { - AnalyticsSectionCaption("Overview", top: 18) { periodCaption } - fullBleedRow { statStrip } - fullBleedRow { activeRow } - fullBleedRow { chartSection } - AnalyticsTopCampaignsSection(campaigns: store.dashboard?.topCampaigns ?? []) - AnalyticsActivitySection(items: store.dashboard?.recentActivity ?? []) - sectionFooter + VStack(alignment: .leading, spacing: 16) { + trendCard.reveal(shown, 0) + kpiCard.reveal(shown, 1) + funnelCard.reveal(shown, 2) + if !(store.dashboard?.topCampaigns ?? []).isEmpty { + campaignsCard.reveal(shown, 3) + } + if !(store.dashboard?.recentActivity ?? []).isEmpty { + activityCard.reveal(shown, 4) + } } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .environment(\.defaultMinListRowHeight, 0) - .refreshable { await store.load(env.api) } } else if let error = store.dashboardError { ErrorStateView(title: "Couldn't load analytics", message: error) { await store.load(env.api) } + .padding(.top, 40) } else { - ScrollView { SkeletonRows(rows: 10) } + AnalyticsLoadingCards() } } - // MARK: Deliverability - - private var deliverabilityList: some View { - List { - AnalyticsDeliverabilitySection( - summary: store.deliverability, - error: store.deliverabilityError, - captionTop: 18 + private var trendDays: [TrendDay] { + var values: [String: TrendDay] = [:] + for point in store.dashboard?.dailyTrend ?? [] { + guard let raw = point.date else { continue } + values[String(raw.prefix(10))] = TrendDay( + date: .now, + sent: point.sent ?? 0, + opens: point.opens ?? 0, + clicks: point.clicks ?? 0, + replies: point.replies ?? 0 ) - sectionFooter } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .environment(\.defaultMinListRowHeight, 0) - .refreshable { await store.load(env.api) } + return TrendDay.lastDays(store.period.days, values: values) } - // MARK: Warmup - - private var warmupList: some View { - List { - AnalyticsWarmupSection( - warmup: store.warmup, - error: store.warmupError, - periodLabel: store.period.rawValue, - captionTop: 18 - ) - sectionFooter - } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .environment(\.defaultMinListRowHeight, 0) - .refreshable { await store.load(env.api) } + private var trendCard: some View { + TrendChart(days: trendDays, shown: shown, caption: "Last \(store.period.label), drag to see a day") + .coverCard() } - // MARK: Accounts + // MARK: KPIs - private var accountsList: some View { - List { - AnalyticsAccountsSection( - accounts: store.accounts, - counts: store.dashboard?.accountHealth, - error: store.accountsError, - isLoading: store.isLoading, - captionTop: 18 - ) - sectionFooter - } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .environment(\.defaultMinListRowHeight, 0) - .refreshable { await store.load(env.api) } + private struct KPI { + let label: String + let value: Double + let format: (Double) -> String + let delta: Double? + let points: Bool + var lowerIsBetter = false + let series: [Double] + let tint: Color } - // MARK: Stat strip - - private var statStrip: some View { + private var current: AnalyticsTotals { let stats = store.dashboard?.overallStats - let sent = stats?.totalEmailsSent ?? 0 - let opens = stats?.totalOpens ?? 0 - let machine = stats?.machineOpens ?? 0 - let clicks = stats?.totalClicks ?? 0 - let replies = stats?.totalReplies ?? 0 - let bounces = stats?.totalBounces ?? 0 - let bounceRate = stats?.bounceRate ?? 0 + return AnalyticsTotals( + sent: stats?.totalEmailsSent ?? 0, + opens: stats?.totalOpens ?? 0, + clicks: stats?.totalClicks ?? 0, + replies: stats?.totalReplies ?? 0 + ) + } - var openCaption = WFormat.percent(opens, of: sent) - if machine > 0 { openCaption += " · \(WFormat.compact(machine)) auto" } + private var kpis: [KPI] { + let now = current + let before = store.previous + let days = trendDays + func rate(_ part: (TrendDay) -> Int) -> [Double] { + days.map { $0.sent > 0 ? Double(part($0)) / Double($0.sent) * 100 : 0 } + } + let pct: (Double) -> String = { String(format: "%.1f%%", $0) } + let stats = store.dashboard?.overallStats + return [ + KPI(label: "Sent", value: Double(now.sent), format: { WFormat.compact(Int($0.rounded())) }, + delta: before.flatMap { DeltaLabel.percent(now.sent, $0.sent) }, points: false, + series: days.map { Double($0.sent) }, tint: WTheme.accent), + KPI(label: "Open rate", value: stats?.openRate ?? 0, format: pct, + delta: DeltaLabel.points(stats?.openRate, before?.openRate), points: true, + series: rate(\.opens), tint: WTheme.special), + KPI(label: "Reply rate", value: stats?.replyRate ?? 0, format: pct, + delta: DeltaLabel.points(stats?.replyRate, before?.replyRate), points: true, + series: rate(\.replies), tint: WTheme.positive), + KPI(label: "Click rate", value: stats?.clickRate ?? 0, format: pct, + delta: DeltaLabel.points(stats?.clickRate, before?.clickRate), points: true, + series: rate(\.clicks), tint: Tone.orange.color), + ] + } - return ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 0) { - statButton(.sent, label: "Sent", value: WFormat.compact(sent), caption: "last \(store.period.rawValue)") - stripDivider - statButton(.opens, label: "Opened", value: WFormat.compact(opens), caption: openCaption) - stripDivider - statButton(.clicks, label: "Clicked", value: WFormat.compact(clicks), caption: WFormat.percent(clicks, of: sent)) - stripDivider - statButton(.replies, label: "Replied", value: WFormat.compact(replies), caption: WFormat.percent(replies, of: sent)) - stripDivider - AnalyticsStatCell( - label: "Bounced", - value: WFormat.compact(bounces), - caption: WFormat.percent(bounces, of: sent), - tone: bounceRate >= 5 ? .rose : (bounceRate >= 2 ? .amber : nil) - ) + private var kpiCard: some View { + let items = kpis + return VStack(alignment: .leading, spacing: 0) { + ForEach(0 ..< 2, id: \.self) { row in + HStack(spacing: 0) { + kpiCell(items[row * 2], index: row * 2) + .padding(.trailing, 16) + Rectangle().fill(WTheme.hairline).frame(width: 1) + kpiCell(items[row * 2 + 1], index: row * 2 + 1) + .padding(.leading, 16) + } + .fixedSize(horizontal: false, vertical: true) + if row == 0 { Hairline() } } - // Cells carry 14pt internal padding; 6 more lands the first - // column on the shared 20pt gutter. - .padding(.horizontal, 6) + Hairline().padding(.top, 2) + factsLine + .padding(.top, 14) } + .padding(.horizontal, 18) + .padding(.vertical, 6) + .padding(.bottom, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(WTheme.groupedCard, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) } - private func statButton(_ target: AnalyticsTrendMetric, label: String, value: String, caption: String) -> some View { - Button { - withAnimation(.snappy) { metric = target } - } label: { - AnalyticsStatCell(label: label, value: value, caption: caption, selected: metric == target) + private func kpiCell(_ kpi: KPI, index: Int) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Circle().fill(kpi.tint).frame(width: 6, height: 6) + Text(kpi.label) + .font(.system(size: 13.5, weight: .medium)) + .foregroundStyle(WTheme.inkSecondary) + .lineLimit(1) + } + HStack(alignment: .firstTextBaseline, spacing: 6) { + CountUpText(value: shown ? kpi.value : 0, format: kpi.format) + .font(.system(size: 28, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(WTheme.ink) + .lineLimit(1) + .minimumScaleFactor(0.7) + .animation(.easeOut(duration: 1).delay(Double(index) * 0.05), value: shown) + .animation(.easeOut(duration: 0.6), value: kpi.value) + Spacer(minLength: 0) + if store.previous != nil { + DeltaLabel(change: kpi.delta, points: kpi.points, lowerIsBetter: kpi.lowerIsBetter) + .lineLimit(1) + .fixedSize() + .transition(.opacity) + } + } + Sparkline(values: kpi.series, color: kpi.tint, delay: 0.25 + Double(index) * 0.08) + .frame(height: 30) + .id("\(store.period.rawValue)-\(index)") } - .buttonStyle(.plain) + .padding(.vertical, 16) + .frame(maxWidth: .infinity, alignment: .leading) } - private var stripDivider: some View { - Divider().frame(height: 40) - } - - private var activeRow: some View { + private var factsLine: some View { let stats = store.dashboard?.overallStats - // Sending accounts are never narrowed by the campaign filter; say so when it is on. - let accounts = store.campaignFilter.isEmpty ? "sending accounts" : "sending accounts in workspace" - return Text("\(stats?.activeCampaigns ?? 0) active campaigns · \(stats?.activeAccounts ?? 0) \(accounts)") - .font(.footnote.weight(.medium)) - .monospacedDigit() - .foregroundStyle(.secondary) - .padding(.horizontal, 20) - .padding(.top, 10) + let campaigns = stats?.activeCampaigns ?? 0 + let mailboxes = stats?.activeAccounts ?? 0 + let bounce = stats?.bounceRate ?? 0 + let bold: (String) -> Text = { Text($0).foregroundStyle(WTheme.ink).fontWeight(.semibold) } + let bounceText = Text(String(format: "%.1f%%", bounce)) + .foregroundStyle(bounce >= 5 ? WTheme.negative : bounce >= 2 ? WTheme.warning : WTheme.ink) + .fontWeight(.semibold) + return Text("\(bold("\(campaigns)")) active \(campaigns == 1 ? "campaign" : "campaigns") · \(bold("\(mailboxes)")) sending \(mailboxes == 1 ? "mailbox" : "mailboxes") · \(bounceText) bounced") + .font(.system(size: 13.5)) + .foregroundStyle(WTheme.inkSecondary) + .lineSpacing(3) } - // MARK: Trend chart + // MARK: Funnel - private var chartPoints: [AnalyticsChartPoint] { - let trend = store.dashboard?.dailyTrend ?? [] - return trend.enumerated().compactMap { index, point in - guard let day = AnalyticsDay.parse(point.date) else { return nil } - return AnalyticsChartPoint(id: index, day: day, value: metric.value(point)) - } - } - - /// Catmull-Rom line + gradient area, matching the Home trend card. - private var chartSection: some View { - let points = chartPoints - return VStack(alignment: .leading, spacing: 10) { - EyebrowLabel("\(metric.title) per day") - .padding(.horizontal, 20) - if points.count > 1 { - Chart(points) { point in - AreaMark( - x: .value("Day", point.day), - y: .value(metric.title, point.value) - ) - .interpolationMethod(.catmullRom) - .foregroundStyle( - LinearGradient( - colors: [metric.color.opacity(0.32), metric.color.opacity(0.02)], - startPoint: .top, - endPoint: .bottom + private var funnelCard: some View { + let now = current + let steps: [(label: String, symbol: String, value: Int, color: Color)] = [ + ("Sent", "paperplane.fill", now.sent, WTheme.accent), + ("Opened", "envelope.open.fill", now.opens, WTheme.special), + ("Clicked", "cursorarrow.click", now.clicks, Tone.orange.color), + ("Replied", "arrowshape.turn.up.left.fill", now.replies, WTheme.positive), + ] + let base = max(1, now.sent) + return VStack(alignment: .leading, spacing: 16) { + CardTitle("From send to reply", caption: "How far each email got") + VStack(spacing: 14) { + ForEach(Array(steps.enumerated()), id: \.offset) { index, step in + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 8) { + Image(systemName: step.symbol) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(step.color) + .frame(width: 18) + Text(step.label) + .font(.system(size: 15)) + .foregroundStyle(WTheme.ink) + Spacer() + CountUpText(value: shown ? Double(step.value) : 0, format: { WFormat.compact(Int($0.rounded())) }) + .font(.system(size: 15, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(WTheme.ink) + .animation(.easeOut(duration: 0.9).delay(0.2 + Double(index) * 0.1), value: shown) + Text(index == 0 ? "100%" : WFormat.percent(step.value, of: now.sent)) + .font(.system(size: 13)) + .monospacedDigit() + .foregroundStyle(WTheme.inkTertiary) + .frame(width: 46, alignment: .trailing) + } + GrowBar( + fraction: Double(step.value) / Double(base), + color: step.color, + shown: shown, + delay: 0.2 + Double(index) * 0.12, + height: 8 ) - ) - LineMark( - x: .value("Day", point.day), - y: .value(metric.title, point.value) - ) - .interpolationMethod(.catmullRom) - .lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round)) - .foregroundStyle(metric.color) - } - .chartXAxis { - AxisMarks(values: .automatic(desiredCount: 4)) { _ in - AxisGridLine().foregroundStyle(Color(.separator).opacity(0.4)) - AxisValueLabel(format: .dateTime.month(.abbreviated).day()) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(Color.secondary) + .padding(.leading, 26) } } - .chartYAxis { - AxisMarks(position: .trailing, values: .automatic(desiredCount: 3)) { _ in - AxisGridLine().foregroundStyle(Color(.separator).opacity(0.4)) - AxisValueLabel() - .font(.system(size: 10)) - .foregroundStyle(Color.secondary) - } - } - .frame(height: 180) - .padding(.horizontal, 20) - } else { - Text("Not enough activity to chart yet.") - .font(.subheadline) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, minHeight: 120) } } - .padding(.top, 14) - .padding(.bottom, 14) + .coverCard() + } + + // MARK: Campaigns + + private var campaignsCard: some View { + let campaigns = Array((store.dashboard?.topCampaigns ?? []).prefix(5)) + return VStack(alignment: .leading, spacing: 4) { + CardTitle("Top campaigns", caption: "Ranked by sends this period") + .padding(.bottom, 8) + ForEach(Array(campaigns.enumerated()), id: \.element.id) { index, campaign in + if index > 0 { Hairline().padding(.leading, 52) } + AnalyticsCampaignRow(campaign: campaign, rank: index + 1, shown: shown) + } + } + .coverCard() + } + + // MARK: Activity + + private var activityCard: some View { + let items = Array((store.dashboard?.recentActivity ?? []).prefix(8)) + return VStack(alignment: .leading, spacing: 4) { + CardTitle("Latest from your recipients") + .padding(.bottom, 8) + ForEach(Array(items.enumerated()), id: \.offset) { index, item in + if index > 0 { Hairline().padding(.leading, 52) } + AnalyticsActivityRow(item: item) + } + } + .coverCard() } // MARK: Drawer @@ -625,8 +507,7 @@ struct AnalyticsRootView: View { topInset: topInset, revealed: sidebarOpen ) { newScope in - withAnimation(.spring(response: 0.38, dampingFraction: 0.8)) { scope = newScope } - // Let the highlight capsule slide to the tapped row before closing. + withAnimation(.spring(response: 0.45, dampingFraction: 0.86)) { scope = newScope } Task { try? await Task.sleep(for: .milliseconds(280)) closeSidebar() @@ -670,63 +551,128 @@ struct AnalyticsRootView: View { } } -// MARK: - Stat strip cell +// MARK: - Rows -/// StatCell plus a rate caption and a selected underline (acts as the chart -/// metric filter). -struct AnalyticsStatCell: View { - let label: String - let value: String - let caption: String - var tone: Tone? = nil - var selected: Bool = false +/// A ranked campaign: its status, sends, and reply rate as a small ring. +struct AnalyticsCampaignRow: View { + let campaign: AnalyticsTopCampaign + let rank: Int + var shown: Bool var body: some View { - VStack(alignment: .leading, spacing: 4) { - EyebrowLabel(label) - Text(value) - .font(.system(size: 22, weight: .bold, design: .rounded)) + let status = CampaignStatusInfo.from(campaign.status) + let reply = campaign.replyRate ?? 0 + return HStack(spacing: 12) { + Text("\(rank)") + .font(.system(size: 15, weight: .semibold, design: .rounded)) .monospacedDigit() - .foregroundStyle(tone?.color ?? Color.primary) - .contentTransition(.numericText()) - Text(caption) - .font(.caption.weight(.medium)) - .monospacedDigit() - .foregroundStyle(.secondary) - .contentTransition(.numericText()) + .foregroundStyle(rank == 1 ? WTheme.accent : WTheme.inkSecondary) + .frame(width: 34, height: 34) + .background(rank == 1 ? WTheme.accentSoft : WTheme.fill, in: Circle()) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 7) { + Text(campaign.name ?? "Untitled campaign") + .font(.system(size: 15.5, weight: .medium)) + .foregroundStyle(WTheme.ink) + .lineLimit(1) + StatusPill(text: status.label.prefix(1).uppercased() + status.label.dropFirst(), tone: status.tone) + .fixedSize() + } + Text("\(WFormat.compact(campaign.emailsSent ?? 0)) sent · \(AnalyticsFmt.rate(campaign.openRate)) opened") + .font(.system(size: 13)) + .monospacedDigit() + .foregroundStyle(WTheme.inkSecondary) + .lineLimit(1) + } + Spacer(minLength: 6) + SweepRing(fraction: reply / 100, color: WTheme.positive, shown: shown, lineWidth: 4, delay: 0.3 + Double(rank) * 0.08) { + Text("\(Int(reply.rounded()))%") + .font(.system(size: 11, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(WTheme.ink) + } + .frame(width: 40, height: 40) + .accessibilityLabel("\(Int(reply.rounded())) percent replied") } - .padding(.horizontal, 14) .padding(.vertical, 10) - .frame(minWidth: 96, alignment: .leading) - .overlay(alignment: .bottom) { - if selected { - RoundedRectangle(cornerRadius: 1) - .fill(WTheme.accent) - .frame(height: 2) - .padding(.horizontal, 14) + } +} + +/// One thing a recipient did, with what it was and when. +struct AnalyticsActivityRow: View { + let item: AnalyticsActivityItem + + private var kind: (verb: String, symbol: String, color: Color) { + switch item.type { + case "opened": ("opened", "envelope.open.fill", WTheme.special) + case "clicked": ("clicked a link", "cursorarrow.click", Tone.orange.color) + case "replied": ("replied", "arrowshape.turn.up.left.fill", WTheme.positive) + case "bounced": ("bounced", "exclamationmark.triangle.fill", WTheme.negative) + default: ("was sent an email", "paperplane.fill", WTheme.accent) + } + } + + private var name: String { + let email = item.contactEmail ?? "Someone" + let local = email.split(separator: "@").first.map(String.init) ?? email + return local.split(whereSeparator: { $0 == "." || $0 == "_" || $0 == "-" }) + .map { $0.prefix(1).uppercased() + $0.dropFirst() } + .joined(separator: " ") + } + + var body: some View { + let kind = kind + return HStack(spacing: 12) { + WAvatar(name: name, seed: item.contactID ?? item.contactEmail ?? "", size: 38) + .overlay(alignment: .bottomTrailing) { + Image(systemName: kind.symbol) + .font(.system(size: 8.5, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 18, height: 18) + .background(kind.color, in: Circle()) + .overlay(Circle().stroke(WTheme.groupedCard, lineWidth: 2)) + .offset(x: 4, y: 4) + } + VStack(alignment: .leading, spacing: 3) { + Text("\(Text(name).fontWeight(.semibold).foregroundStyle(WTheme.ink)) \(kind.verb)") + .font(.system(size: 15)) + .foregroundStyle(WTheme.inkSecondary) + .lineLimit(1) + Text(item.campaignName ?? item.contactEmail ?? "") + .font(.system(size: 13)) + .foregroundStyle(WTheme.inkTertiary) + .lineLimit(1) + } + Spacer(minLength: 6) + if let timestamp = item.timestamp { + Text(WFormat.ago(timestamp)) + .font(.system(size: 13)) + .monospacedDigit() + .foregroundStyle(WTheme.inkTertiary) } } - .contentShape(Rectangle()) + .padding(.vertical, 10) + .accessibilityElement(children: .combine) } } -/// Compact labeled number used inside the section grids. -struct AnalyticsMiniStat: View { - let label: String - let value: String - var tone: Tone? = nil - +/// Grey placeholder cards with a shimmer, while the first load runs. +struct AnalyticsLoadingCards: View { var body: some View { - VStack(alignment: .leading, spacing: 3) { - EyebrowLabel(label) - Text(value) - .font(.system(size: 18, weight: .semibold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(tone?.color ?? Color.primary) - .contentTransition(.numericText()) - .lineLimit(1) - .minimumScaleFactor(0.75) + VStack(spacing: 16) { + ForEach([240.0, 190, 170], id: \.self) { height in + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(WTheme.groupedCard) + .frame(height: height) + .overlay(alignment: .topLeading) { + VStack(alignment: .leading, spacing: 12) { + RoundedRectangle(cornerRadius: 6).fill(WTheme.fill).frame(width: 120, height: 14) + RoundedRectangle(cornerRadius: 6).fill(WTheme.fill).frame(width: 80, height: 26) + } + .padding(18) + } + } } - .frame(maxWidth: .infinity, alignment: .leading) + .skeletonShimmer() } } diff --git a/ios/Warmbly/Features/Analytics/AnalyticsSectionViews.swift b/ios/Warmbly/Features/Analytics/AnalyticsSectionViews.swift index e0a4a939b..e8c221fde 100644 --- a/ios/Warmbly/Features/Analytics/AnalyticsSectionViews.swift +++ b/ios/Warmbly/Features/Analytics/AnalyticsSectionViews.swift @@ -1,5 +1,4 @@ import SwiftUI -import Charts // MARK: - Tone maps @@ -15,6 +14,16 @@ enum AnalyticsToneMap { } } + static func bandLabel(_ raw: String?) -> String { + switch raw { + case "healthy": "Healthy" + case "warning": "Watch" + case "quarantine": "At risk" + case "blocked": "Blocked" + default: "No data yet" + } + } + /// Account health status -> chip tone. static func health(_ raw: String?) -> Tone { switch raw { @@ -38,112 +47,42 @@ enum AnalyticsToneMap { } } -// MARK: - Flat list helpers - -/// Eyebrow caption as a bare row — never a grouped `Section` header (that -/// reintroduces the sticky gray band). Whitespace above is the separator. -struct AnalyticsSectionCaption: View { - let title: String - let icon: String? - let iconTone: Tone - let top: CGFloat - let trailing: Trailing - - init( - _ title: String, - icon: String? = nil, - iconTone: Tone = .slate, - top: CGFloat = 20, - @ViewBuilder trailing: () -> Trailing - ) { - self.title = title - self.icon = icon - self.iconTone = iconTone - self.top = top - self.trailing = trailing() - } +/// Compact labeled number used inside stat grids. +struct AnalyticsMiniStat: View { + let label: String + let value: String + var tone: Tone? = nil var body: some View { - HStack(spacing: 5) { - if let icon { - Image(systemName: icon) - .font(.caption2) - .foregroundStyle(iconTone.color) - } - EyebrowLabel(title) - Spacer(minLength: 8) - trailing + VStack(alignment: .leading, spacing: 3) { + Text(label) + .font(.system(size: 13)) + .foregroundStyle(WTheme.inkSecondary) + .lineLimit(1) + Text(value) + .font(.system(size: 20, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(tone?.color ?? WTheme.ink) + .contentTransition(.numericText()) + .lineLimit(1) + .minimumScaleFactor(0.75) } - .padding(.horizontal, 20) - .padding(.top, top) - .padding(.bottom, 8) .frame(maxWidth: .infinity, alignment: .leading) - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - .listRowBackground(Color(.systemBackground)) } } -extension AnalyticsSectionCaption where Trailing == EmptyView { - init(_ title: String, icon: String? = nil, iconTone: Tone = .slate, top: CGFloat = 20) { - self.init(title, icon: icon, iconTone: iconTone, top: top) { EmptyView() } - } -} - -/// Flat row treatment for the analytics plain lists: 20pt gutters, white row -/// background, and (when given) a hairline tucked under the text column. -private struct AnalyticsPlainRowModifier: ViewModifier { - let separator: Visibility - let separatorLeading: CGFloat? - - func body(content: Content) -> some View { - let base = content - .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) - .listRowSeparator(separator) - .listRowBackground(Color(.systemBackground)) - if let separatorLeading { - base.alignmentGuide(.listRowSeparatorLeading) { _ in separatorLeading } - } else { - base - } - } -} - -extension View { - func analyticsPlainRow(separator: Visibility = .automatic, separatorLeading: CGFloat? = nil) -> some View { - modifier(AnalyticsPlainRowModifier(separator: separator, separatorLeading: separatorLeading)) - } -} - -/// Redacted stat grid shown while a section's endpoint is still loading. -struct AnalyticsSectionLoading: View { - var body: some View { - LazyVGrid( - columns: [GridItem(.flexible(), alignment: .leading), GridItem(.flexible(), alignment: .leading), GridItem(.flexible(), alignment: .leading)], - alignment: .leading, - spacing: 14 - ) { - ForEach(0 ..< 3, id: \.self) { _ in - AnalyticsMiniStat(label: "Loading", value: "0000") - } - } - .redacted(reason: .placeholder) - .padding(.vertical, 8) - } -} - -/// Inline section failure row; the rest of the screen keeps working. +/// Inline section failure; the rest of the screen keeps working. struct AnalyticsSectionError: View { let text: String var body: some View { HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle") - .font(.footnote) + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 13)) .foregroundStyle(WTheme.negative) Text(text) - .font(.footnote) - .foregroundStyle(.secondary) + .font(.system(size: 14)) + .foregroundStyle(WTheme.inkSecondary) } .padding(.vertical, 6) } @@ -151,350 +90,759 @@ struct AnalyticsSectionError: View { // MARK: - Deliverability -struct AnalyticsDeliverabilitySection: View { - let summary: DeliverabilitySummary? - let error: String? - var captionTop: CGFloat = 14 - - private let columns = [ - GridItem(.flexible(), alignment: .leading), - GridItem(.flexible(), alignment: .leading), - GridItem(.flexible(), alignment: .leading), - ] +/// The workspace's sending reputation: a score, the three rates that move +/// it against their safe ranges, how replies read, and who is suppressed. +struct AnalyticsDeliverabilityPage: View { + let store: AnalyticsStore + var shown: Bool var body: some View { - AnalyticsSectionCaption("Deliverability", top: captionTop) { - if let band = summary?.band { - StatusPill(text: band, tone: AnalyticsToneMap.band(band)) + if let summary = store.deliverability { + VStack(alignment: .leading, spacing: 16) { + scoreCard(summary).reveal(shown, 0) + rangesCard(summary).reveal(shown, 1) + if intentTotal(summary) > 0 { + intentCard(summary).reveal(shown, 2) + } + suppressionCard(summary).reveal(shown, 3) + if let mailboxes = summary.byMailbox, !mailboxes.isEmpty { + mailboxCard(mailboxes).reveal(shown, 4) + } + } + } else if let error = store.deliverabilityError { + AnalyticsSectionError(text: error).coverCard() + } else { + AnalyticsLoadingCards() + } + } + + // MARK: Score + + private func scoreCard(_ summary: DeliverabilitySummary) -> some View { + let tone = AnalyticsToneMap.band(summary.band) + let score = summary.score ?? (summary.band == "healthy" ? 100 : 0) + return HStack(spacing: 18) { + SweepRing(fraction: Double(score) / 100, color: tone.color, shown: shown, lineWidth: 11) { + VStack(spacing: -2) { + CountUpText(value: shown ? Double(score) : 0, format: { "\(Int($0.rounded()))" }) + .font(.system(size: 34, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(WTheme.ink) + .animation(.easeOut(duration: 1.1).delay(0.15), value: shown) + Text("of 100") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(WTheme.inkTertiary) + } + } + .frame(width: 116, height: 116) + VStack(alignment: .leading, spacing: 6) { + StatusPill(text: AnalyticsToneMap.bandLabel(summary.band), tone: tone) + Text(headline(summary.band)) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(WTheme.ink) + .fixedSize(horizontal: false, vertical: true) + Text((summary.emailsSent ?? 0) > 0 ? "Read from \(WFormat.compact(summary.emailsSent ?? 0)) emails sent" : "Nothing sent in this period to judge") + .font(.system(size: 13.5)) + .monospacedDigit() + .foregroundStyle(WTheme.inkSecondary) + } + Spacer(minLength: 0) + } + .coverCard() + .accessibilityElement(children: .combine) + } + + private func headline(_ band: String?) -> String { + switch band { + case "healthy": "Your mail is landing well" + case "warning": "Worth keeping an eye on" + case "quarantine": "Sending is at risk" + case "blocked": "Sending is paused" + default: "Send a few emails to see this" + } + } + + // MARK: Ranges + + private func rangesCard(_ summary: DeliverabilitySummary) -> some View { + VStack(alignment: .leading, spacing: 18) { + CardTitle("Inside the safe range", caption: "The rates mailbox providers judge you on") + RateGauge( + label: "Bounce rate", value: summary.bounceRate ?? 0, + watch: 2, risk: 5, note: "Keep under 2%", shown: shown, delay: 0.2 + ) + RateGauge( + label: "Complaint rate", value: summary.complaintRate ?? 0, + watch: 0.03, risk: 0.10, note: "Keep under 0.03%", shown: shown, delay: 0.3 + ) + if let spam = summary.spamPlacementRate { + RateGauge( + label: "Landed in spam", value: spam, + watch: 10, risk: 20, note: "Keep under 10%", shown: shown, delay: 0.4 + ) + } else { + HStack(spacing: 8) { + Image(systemName: "tray.full") + .font(.system(size: 13)) + .foregroundStyle(WTheme.inkTertiary) + Text("Spam placement shows once warmup has enough deliveries to read.") + .font(.system(size: 13.5)) + .foregroundStyle(WTheme.inkSecondary) + .fixedSize(horizontal: false, vertical: true) + } } } - if let summary { - LazyVGrid(columns: columns, alignment: .leading, spacing: 16) { - AnalyticsMiniStat(label: "Sent", value: AnalyticsFmt.count(summary.emailsSent)) - AnalyticsMiniStat( - label: "Bounce rate", - value: AnalyticsFmt.rate(summary.bounceRate), - tone: (summary.bounceRate ?? 0) >= 5 ? .rose : ((summary.bounceRate ?? 0) >= 2 ? .amber : nil) - ) + .coverCard() + } + + // MARK: Intent + + private func intentTotal(_ summary: DeliverabilitySummary) -> Int { + intents(summary).reduce(0) { $0 + $1.count } + } + + private func intents(_ summary: DeliverabilitySummary) -> [(label: String, count: Int, color: Color)] { + [ + ("Interested", summary.intentPositive ?? 0, WTheme.positive), + ("Question", summary.intentQuestion ?? 0, WTheme.accent), + ("Neutral", summary.intentNeutral ?? 0, WTheme.special), + ("Out of office", summary.intentOutOfOffice ?? 0, WTheme.paused), + ("Not interested", summary.intentNegative ?? 0, WTheme.negative), + ("Automated", summary.intentAutomated ?? 0, WTheme.hairlineStrong), + ] + } + + private func intentCard(_ summary: DeliverabilitySummary) -> some View { + let rows = intents(summary).filter { $0.count > 0 } + let total = max(1, rows.reduce(0) { $0 + $1.count }) + return VStack(alignment: .leading, spacing: 16) { + CardTitle("How people replied", caption: "\(total) \(total == 1 ? "reply" : "replies"), read for intent") + GeometryReader { proxy in + HStack(spacing: 3) { + ForEach(Array(rows.enumerated()), id: \.offset) { index, row in + let width = max(6, (proxy.size.width - CGFloat(rows.count - 1) * 3) * CGFloat(row.count) / CGFloat(total)) + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(row.color) + .frame(width: shown ? width : 0) + .animation(.spring(response: 0.8, dampingFraction: 0.82).delay(0.2 + Double(index) * 0.07), value: shown) + } + Spacer(minLength: 0) + } + } + .frame(height: 14) + LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], alignment: .leading, spacing: 10) { + ForEach(Array(rows.enumerated()), id: \.offset) { _, row in + HStack(spacing: 7) { + RoundedRectangle(cornerRadius: 2.5).fill(row.color).frame(width: 9, height: 9) + Text(row.label) + .font(.system(size: 14)) + .foregroundStyle(WTheme.inkSecondary) + .lineLimit(1) + Spacer(minLength: 4) + Text("\(row.count)") + .font(.system(size: 14, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(WTheme.ink) + } + } + } + } + .coverCard() + } + + // MARK: Suppression + + private func suppressionCard(_ summary: DeliverabilitySummary) -> some View { + VStack(alignment: .leading, spacing: 14) { + CardTitle("Kept off your lists", caption: "Addresses Warmbly will not email again") + HStack(spacing: 0) { + AnalyticsMiniStat(label: "Suppressed", value: AnalyticsFmt.count(summary.suppressedRecipients)) + AnalyticsMiniStat(label: "Unsubscribed", value: AnalyticsFmt.count(summary.unsubscribeCount)) AnalyticsMiniStat( label: "Complaints", - value: AnalyticsFmt.rate(summary.complaintRate), - tone: (summary.complaintRate ?? 0) >= 0.10 ? .rose : ((summary.complaintRate ?? 0) >= 0.03 ? .amber : nil) + value: AnalyticsFmt.count(summary.complaintCount), + tone: (summary.complaintCount ?? 0) > 0 ? .rose : nil ) - AnalyticsMiniStat( - label: "Spam placement", - value: summary.spamPlacementRate.map { AnalyticsFmt.rate($0) } ?? "no samples", - tone: (summary.spamPlacementRate ?? 0) >= 20 ? .rose : ((summary.spamPlacementRate ?? 0) >= 10 ? .amber : nil) - ) - AnalyticsMiniStat(label: "Suppressed", value: AnalyticsFmt.count(summary.suppressedRecipients)) - AnalyticsMiniStat(label: "Unsubscribes", value: AnalyticsFmt.count(summary.unsubscribeCount)) } - .padding(.vertical, 10) - .analyticsPlainRow(separator: .hidden) if let pending = summary.dlqPending, pending > 0 { - HStack(spacing: 8) { - Image(systemName: "clock.badge.exclamationmark") - .font(.footnote) - .foregroundStyle(WTheme.warning) - Text("\(pending) deliverability events pending retry") - .font(.footnote) - .monospacedDigit() - .foregroundStyle(.secondary) - } - .padding(.vertical, 6) - .analyticsPlainRow(separator: .hidden) + Label("\(pending) events waiting to retry", systemImage: "clock.arrow.circlepath") + .font(.system(size: 13.5)) + .foregroundStyle(WTheme.warning) } - } else if let error { - AnalyticsSectionError(text: error) - .analyticsPlainRow(separator: .hidden) - } else { - AnalyticsSectionLoading() - .analyticsPlainRow(separator: .hidden) } + .coverCard() + } + + // MARK: Mailboxes + + private func mailboxCard(_ rows: [AnalyticsDeliverabilityMailbox]) -> some View { + VStack(alignment: .leading, spacing: 4) { + CardTitle("By mailbox").padding(.bottom, 8) + ForEach(Array(rows.prefix(8).enumerated()), id: \.element.id) { index, row in + if index > 0 { Hairline().padding(.leading, 50) } + HStack(spacing: 12) { + store.mailboxAvatar(id: row.id, email: row.email, size: 36) + VStack(alignment: .leading, spacing: 3) { + Text(row.email ?? "Mailbox") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(WTheme.ink) + .lineLimit(1) + Text("\(WFormat.compact(row.sent ?? 0)) sent · \(AnalyticsFmt.rate(row.bounceRate)) bounced") + .font(.system(size: 13)) + .monospacedDigit() + .foregroundStyle(WTheme.inkSecondary) + } + Spacer() + StatusPill(text: AnalyticsToneMap.bandLabel(row.band), tone: AnalyticsToneMap.band(row.band)) + } + .padding(.vertical, 9) + } + } + .coverCard() + } +} + +/// A rate on a track split into fine, watch and risk, with a knob that slides to it. +struct RateGauge: View { + let label: String + let value: Double + let watch: Double + let risk: Double + let note: String + var shown: Bool + var delay: Double = 0 + + private var tone: Tone { value >= risk ? .rose : value >= watch ? .amber : .emerald } + private var scale: Double { risk * 1.6 } + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + HStack(alignment: .firstTextBaseline) { + Text(label) + .font(.system(size: 15)) + .foregroundStyle(WTheme.ink) + Spacer() + Text(AnalyticsFmt.rate(value)) + .font(.system(size: 17, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(tone == .emerald ? WTheme.ink : tone.color) + } + GeometryReader { proxy in + let width = proxy.size.width + let watchX = width * watch / scale + let riskX = width * risk / scale + let knob = width * min(1, value / scale) + ZStack(alignment: .leading) { + HStack(spacing: 2) { + Capsule().fill(WTheme.positive.opacity(0.28)).frame(width: max(0, watchX - 1)) + Rectangle().fill(WTheme.warning.opacity(0.3)).frame(width: max(0, riskX - watchX - 2)) + Capsule().fill(WTheme.negative.opacity(0.26)) + } + .frame(height: 6) + Circle() + .fill(WTheme.groupedCard) + .frame(width: 16, height: 16) + .overlay(Circle().stroke(tone.color, lineWidth: 4)) + .shadow(color: tone.color.opacity(0.35), radius: 4, y: 1) + .offset(x: (shown ? knob : 0) - 8) + .animation(.spring(response: 1, dampingFraction: 0.7).delay(delay), value: shown) + } + .frame(height: 16) + } + .frame(height: 16) + Text(note) + .font(.system(size: 12.5)) + .foregroundStyle(WTheme.inkTertiary) + } + .accessibilityElement(children: .combine) } } // MARK: - Warmup -struct AnalyticsWarmupSection: View { - let warmup: WarmupAnalytics? - let error: String? - let periodLabel: String - var captionTop: CGFloat = 14 +/// Warmup across every mailbox: what went out, how the days tracked the +/// ramp, where it landed, and each mailbox's climb. +struct AnalyticsWarmupPage: View { + let store: AnalyticsStore + var shown: Bool - private let columns = [ - GridItem(.flexible(), alignment: .leading), - GridItem(.flexible(), alignment: .leading), - GridItem(.flexible(), alignment: .leading), - ] + @State private var selectedDay: Int? - private var chartDays: [(id: Int, day: Date, sent: Int, target: Int)] { - (warmup?.dailyStats ?? []).enumerated().compactMap { index, day in - guard let date = AnalyticsDay.parse(day.date) else { return nil } - return (id: index, day: date, sent: day.emailsSent ?? 0, target: day.targetVolume ?? 0) + var body: some View { + if let summary = store.warmup?.summary { + VStack(alignment: .leading, spacing: 16) { + heroCard(summary).reveal(shown, 0) + if (summary.totalSent ?? 0) > 0 { + dailyCard.reveal(shown, 1) + } + if !placements.isEmpty { + placementCard.reveal(shown, 2) + } + if !warming.isEmpty { + rampCard.reveal(shown, 3) + } + } + } else if let error = store.warmupError { + AnalyticsSectionError(text: error).coverCard() + } else { + AnalyticsLoadingCards() } } - var body: some View { - AnalyticsSectionCaption("Warmup", icon: "flame", iconTone: .orange, top: captionTop) - if let summary = warmup?.summary { - LazyVGrid(columns: columns, alignment: .leading, spacing: 16) { - AnalyticsMiniStat(label: "Warmup sent", value: AnalyticsFmt.count(summary.totalSent), tone: .orange) + // MARK: Hero + + private func heroCard(_ summary: AnalyticsWarmupSummary) -> some View { + let progress = (summary.targetProgress ?? 0) / 100 + return VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .center, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: "flame.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Tone.orange.color) + .symbolEffect(.bounce, value: shown) + Text("Warmup emails sent") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(WTheme.inkSecondary) + } + CountUpText(value: shown ? Double(summary.totalSent ?? 0) : 0, format: { Int($0.rounded()).formatted() }) + .font(.system(size: 40, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(WTheme.ink) + .animation(.easeOut(duration: 1.2), value: shown) + Text("over \(summary.daysActive ?? 0) active \((summary.daysActive ?? 0) == 1 ? "day" : "days")") + .font(.system(size: 13.5)) + .foregroundStyle(WTheme.inkTertiary) + } + Spacer(minLength: 8) + SweepRing(fraction: progress, color: Tone.orange.color, shown: shown, lineWidth: 9) { + VStack(spacing: -1) { + Text("\(Int((progress * 100).rounded()))%") + .font(.system(size: 20, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(WTheme.ink) + Text("of target") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(WTheme.inkTertiary) + } + } + .frame(width: 96, height: 96) + } + Hairline() + HStack(spacing: 0) { AnalyticsMiniStat(label: "Replies", value: AnalyticsFmt.count(summary.totalReplied)) AnalyticsMiniStat(label: "Reply rate", value: AnalyticsFmt.rate(summary.replyRate)) - AnalyticsMiniStat(label: "Avg / active day", value: String(format: "%.1f", summary.averageDaily ?? 0)) - AnalyticsMiniStat(label: "Days active", value: "\(summary.daysActive ?? 0)") - AnalyticsMiniStat(label: "Target met", value: String(format: "%.0f%%", summary.targetProgress ?? 0)) + AnalyticsMiniStat(label: "A day", value: String(format: "%.0f", summary.averageDaily ?? 0)) } - .padding(.vertical, 10) - .analyticsPlainRow(separator: .hidden) - - if (summary.totalSent ?? 0) > 0, chartDays.count > 1 { - Chart(chartDays, id: \.id) { day in - BarMark( - x: .value("Day", day.day), - y: .value("Sent", day.sent) - ) - .foregroundStyle(Tone.orange.color.opacity(0.75)) - LineMark( - x: .value("Day", day.day), - y: .value("Target", day.target) - ) - .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) - .foregroundStyle(WTheme.paused) - } - .chartXAxis { - AxisMarks(values: .automatic(desiredCount: 4)) { _ in - AxisGridLine().foregroundStyle(Color(.separator).opacity(0.4)) - AxisValueLabel(format: .dateTime.month(.abbreviated).day()) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(Color.secondary) - } - } - .chartYAxis { - AxisMarks(position: .trailing, values: .automatic(desiredCount: 3)) { _ in - AxisGridLine().foregroundStyle(Color(.separator).opacity(0.4)) - AxisValueLabel() - .font(.system(size: 10)) - .foregroundStyle(Color.secondary) - } - } - .frame(height: 130) - .padding(.vertical, 8) - .analyticsPlainRow(separator: .hidden) - } else if (summary.totalSent ?? 0) == 0 { - Text("No warmup activity in the last \(periodLabel).") - .font(.subheadline) - .foregroundStyle(.secondary) - .padding(.vertical, 4) - .analyticsPlainRow(separator: .hidden) - } - } else if let error { - AnalyticsSectionError(text: error) - .analyticsPlainRow(separator: .hidden) - } else { - AnalyticsSectionLoading() - .analyticsPlainRow(separator: .hidden) } + .coverCard() + } + + // MARK: Daily bars + + private struct Day { + let date: Date + let sent: Int + let target: Int + let replied: Int + } + + private var days: [Day] { + (store.warmup?.dailyStats ?? []).compactMap { day in + guard let date = AnalyticsDay.parse(day.date.map { String($0.prefix(10)) }) else { return nil } + return Day(date: date, sent: day.emailsSent ?? 0, target: day.targetVolume ?? 0, replied: day.emailsReplied ?? 0) + } + } + + private var dailyCard: some View { + let days = days + let peak = max(1, days.map { max($0.sent, $0.target) }.max() ?? 1) + let picked = selectedDay.flatMap { days.indices.contains($0) ? days[$0] : nil } + let utcDay: Date.FormatStyle = { + var style = Date.FormatStyle.dateTime.weekday(.abbreviated).month(.abbreviated).day() + style.timeZone = TimeZone(identifier: "UTC")! + return style + }() + return VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 3) { + Text(picked.map { utcDay.format($0.date) } ?? "Each day against its target") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(WTheme.ink) + .contentTransition(.opacity) + Group { + if let picked { + Text("\(picked.sent) sent of \(picked.target) · \(picked.replied) replied") + } else { + Text("Drag across the bars to read a day") + } + } + .font(.system(size: 13.5)) + .monospacedDigit() + .foregroundStyle(WTheme.inkSecondary) + .contentTransition(.numericText()) + } + .animation(.snappy(duration: 0.2), value: selectedDay) + GeometryReader { proxy in + let count = max(1, days.count) + let gap: CGFloat = count > 40 ? 1.5 : 4 + let barWidth = max(2, (proxy.size.width - gap * CGFloat(count - 1)) / CGFloat(count)) + HStack(alignment: .bottom, spacing: gap) { + ForEach(Array(days.enumerated()), id: \.offset) { index, day in + let height = proxy.size.height * CGFloat(day.sent) / CGFloat(peak) + let targetY = proxy.size.height * CGFloat(day.target) / CGFloat(peak) + let dim = selectedDay != nil && selectedDay != index + ZStack(alignment: .bottom) { + RoundedRectangle(cornerRadius: min(4, barWidth / 2), style: .continuous) + .fill(WTheme.fill) + .frame(height: max(2, targetY)) + RoundedRectangle(cornerRadius: min(4, barWidth / 2), style: .continuous) + .fill(LinearGradient(colors: [Color(hex: 0xFDBA74), Tone.orange.color], startPoint: .top, endPoint: .bottom)) + .frame(height: shown ? max(2, height) : 2) + .opacity(dim ? 0.35 : 1) + .animation(.spring(response: 0.8, dampingFraction: 0.78).delay(0.15 + Double(index) * min(0.04, 1.2 / Double(count))), value: shown) + } + .frame(width: barWidth) + } + } + .frame(maxHeight: .infinity, alignment: .bottom) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let index = Int(value.location.x / (barWidth + gap)) + selectedDay = min(max(0, index), count - 1) + } + .onEnded { _ in + withAnimation(.snappy) { selectedDay = nil } + } + ) + } + .frame(height: 130) + .sensoryFeedback(.selection, trigger: selectedDay) + HStack(spacing: 14) { + legend(color: Tone.orange.color, text: "Sent") + legend(color: WTheme.hairlineStrong, text: "Target") + Spacer() + } + } + .coverCard() + } + + private func legend(color: Color, text: String) -> some View { + HStack(spacing: 6) { + RoundedRectangle(cornerRadius: 2.5).fill(color).frame(width: 9, height: 9) + Text(text) + .font(.system(size: 12.5)) + .foregroundStyle(WTheme.inkSecondary) + } + } + + // MARK: Placement + + private var placements: [AccountHealthRow] { + store.accounts.filter { ($0.warmupPlacement?.delivered ?? 0) > 0 } + } + + private var placementCard: some View { + VStack(alignment: .leading, spacing: 4) { + CardTitle("Where warmup landed", caption: "Verified deliveries at Google, Microsoft and Yahoo, last 7 days") + .padding(.bottom, 10) + ForEach(Array(placements.enumerated()), id: \.element.id) { index, account in + if index > 0 { Hairline() } + placementRow(account, index: index) + } + HStack(spacing: 14) { + legend(color: WTheme.positive, text: "Inbox") + legend(color: WTheme.accent, text: "Tabs") + legend(color: WTheme.negative, text: "Spam") + Spacer() + } + .padding(.top, 10) + } + .coverCard() + } + + private func placementRow(_ account: AccountHealthRow, index: Int) -> some View { + let placement = account.warmupPlacement + let delivered = max(1, placement?.delivered ?? 1) + let parts: [(Int, Color)] = [ + (placement?.inbox ?? 0, WTheme.positive), + (placement?.tabs ?? 0, WTheme.accent), + (placement?.spam ?? 0, WTheme.negative), + ] + let rate = placement?.inboxRate ?? 0 + return VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline) { + Text(account.email ?? "Mailbox") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(WTheme.ink) + .lineLimit(1) + Spacer(minLength: 8) + Text(String(format: "%.0f%%", rate)) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(rate >= 90 ? WTheme.positive : rate >= 80 ? WTheme.warning : WTheme.negative) + Text("inbox") + .font(.system(size: 12.5)) + .foregroundStyle(WTheme.inkTertiary) + } + GeometryReader { proxy in + HStack(spacing: 2) { + ForEach(Array(parts.enumerated()), id: \.offset) { part, item in + if item.0 > 0 { + Capsule() + .fill(item.1) + .frame(width: shown ? max(4, (proxy.size.width - 4) * CGFloat(item.0) / CGFloat(delivered)) : 0) + .animation(.spring(response: 0.9, dampingFraction: 0.8).delay(0.2 + Double(index) * 0.08 + Double(part) * 0.1), value: shown) + } + } + Spacer(minLength: 0) + } + } + .frame(height: 8) + Text("\(placement?.delivered ?? 0) delivered · \(placement?.spam ?? 0) in spam") + .font(.system(size: 12.5)) + .monospacedDigit() + .foregroundStyle(WTheme.inkTertiary) + } + .padding(.vertical, 10) + .accessibilityElement(children: .combine) + } + + // MARK: Ramp + + private var warming: [AccountHealthRow] { + store.accounts.filter { $0.warmupStatus?.enabled == true } + } + + private var rampCard: some View { + VStack(alignment: .leading, spacing: 4) { + CardTitle("Each mailbox's ramp", caption: "Today's daily target on its way to the ceiling") + .padding(.bottom, 8) + ForEach(Array(warming.enumerated()), id: \.element.id) { index, account in + if index > 0 { Hairline().padding(.leading, 48) } + rampRow(account, index: index) + } + } + .coverCard() + } + + private func rampRow(_ account: AccountHealthRow, index: Int) -> some View { + let status = account.warmupStatus + let ceiling = max(1, status?.maxVolume ?? 40) + let today = min(ceiling, status?.targetVolume ?? 0) + let paused = status?.paused == true + return HStack(alignment: .top, spacing: 12) { + MailboxAvatar(email: account.email ?? "", avatarURL: account.avatarURL, brand: account.resolvedBrand, seed: account.id, size: 36) + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline) { + Text(account.email ?? "Mailbox") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(WTheme.ink) + .lineLimit(1) + Spacer(minLength: 8) + Text(paused ? "Paused" : "Day \(status?.daysActive ?? 0)") + .font(.system(size: 13, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(paused ? WTheme.inkTertiary : Tone.orange.color) + } + WarmupBar(progress: shown ? Double(today) / Double(ceiling) : 0, paused: paused, delay: Double(index) * 0.08) + .frame(height: 8) + Text("\(Text("\(today)").foregroundStyle(WTheme.ink).fontWeight(.semibold)) of \(ceiling) a day · \(status?.currentVolume ?? 0) sent today") + .font(.system(size: 12.5)) + .monospacedDigit() + .foregroundStyle(WTheme.inkSecondary) + } + } + .padding(.vertical, 10) } } -// MARK: - Account health +// MARK: - Mailboxes -struct AnalyticsAccountsSection: View { - let accounts: [AccountHealthRow] - let counts: AnalyticsAccountHealthCounts? - let error: String? - var isLoading: Bool = false - var captionTop: CGFloat = 14 +/// Every mailbox's health: a summary of the fleet, then each mailbox with +/// today's sending and its score, opening its detail. +struct AnalyticsAccountsPage: View { + let store: AnalyticsStore + var shown: Bool + + #if DEBUG + @State private var debugOpen = false + #endif var body: some View { - AnalyticsSectionCaption("Account health", top: captionTop) { - if let counts { - HStack(spacing: 6) { - if let healthy = counts.healthyAccounts, healthy > 0 { - StatusPill(text: "\(healthy)", tone: .emerald) + if store.accounts.isEmpty, let error = store.accountsError { + AnalyticsSectionError(text: error).coverCard() + } else if store.accounts.isEmpty, store.isLoading { + AnalyticsLoadingCards() + } else if store.accounts.isEmpty { + CoverEmptyState( + symbol: "envelope.fill", + title: "No mailboxes yet", + message: "Connect a mailbox and its health shows up here." + ) + } else { + VStack(alignment: .leading, spacing: 16) { + summaryCard.reveal(shown, 0) + listCard.reveal(shown, 1) + } + #if DEBUG + .navigationDestination(isPresented: $debugOpen) { + if let first = store.accounts.last { AnalyticsAccountDetailView(account: first) } + } + .task { debugOpen = ProcessInfo.processInfo.environment["WARMBLY_DEBUG_ANALYTICS_ACCOUNT"] != nil } + #endif + } + } + + private var counts: (healthy: Int, warning: Int, error: Int) { + let accounts = store.accounts + return ( + accounts.count { $0.health?.status == "healthy" }, + accounts.count { $0.health?.status == "warning" }, + accounts.count { $0.health?.status == "error" } + ) + } + + private var averageScore: Int { + let scores = store.accounts.compactMap { $0.health?.score } + guard !scores.isEmpty else { return 0 } + return scores.reduce(0, +) / scores.count + } + + private var summaryCard: some View { + let counts = counts + let total = max(1, store.accounts.count) + let segments: [(String, Int, Color)] = [ + ("Healthy", counts.healthy, WTheme.positive), + ("Warning", counts.warning, WTheme.warning), + ("Error", counts.error, WTheme.negative), + ] + let average = averageScore + let tone: Tone = average >= 80 ? .emerald : average >= 60 ? .amber : .rose + return VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .center, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("Average health") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(WTheme.inkSecondary) + HStack(alignment: .firstTextBaseline, spacing: 4) { + CountUpText(value: shown ? Double(average) : 0, format: { "\(Int($0.rounded()))" }) + .font(.system(size: 40, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(WTheme.ink) + .animation(.easeOut(duration: 1.1), value: shown) + Text("/ 100") + .font(.system(size: 15)) + .foregroundStyle(WTheme.inkTertiary) } - if let warning = counts.warningAccounts, warning > 0 { - StatusPill(text: "\(warning)", tone: .amber) - } - if let bad = counts.errorAccounts, bad > 0 { - StatusPill(text: "\(bad)", tone: .rose) + Text("across \(store.accounts.count) \(store.accounts.count == 1 ? "mailbox" : "mailboxes")") + .font(.system(size: 13.5)) + .foregroundStyle(WTheme.inkTertiary) + } + Spacer() + SweepRing(fraction: Double(average) / 100, color: tone.color, shown: shown, lineWidth: 9) { + Image(systemName: tone == .emerald ? "checkmark" : "exclamationmark") + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(tone.color) + .symbolEffect(.bounce, value: shown) + } + .frame(width: 88, height: 88) + } + GeometryReader { proxy in + HStack(spacing: 3) { + ForEach(Array(segments.enumerated()), id: \.offset) { index, segment in + if segment.1 > 0 { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(segment.2) + .frame(width: shown ? max(6, (proxy.size.width - 6) * CGFloat(segment.1) / CGFloat(total)) : 0) + .animation(.spring(response: 0.8, dampingFraction: 0.82).delay(0.25 + Double(index) * 0.1), value: shown) + } } + Spacer(minLength: 0) } } + .frame(height: 10) + HStack(spacing: 16) { + ForEach(Array(segments.enumerated()), id: \.offset) { _, segment in + HStack(spacing: 6) { + Circle().fill(segment.2).frame(width: 7, height: 7) + Text("\(segment.1) \(segment.0.lowercased())") + .font(.system(size: 13.5)) + .monospacedDigit() + .foregroundStyle(segment.1 > 0 ? WTheme.ink : WTheme.inkTertiary) + } + } + Spacer() + } } - if let error, accounts.isEmpty { - AnalyticsSectionError(text: error) - .analyticsPlainRow(separator: .hidden) - } else if accounts.isEmpty, isLoading { - // First load in flight; don't flash the empty message. - AnalyticsSectionLoading() - .analyticsPlainRow(separator: .hidden) - } else if accounts.isEmpty { - Text("No mailboxes connected yet.") - .font(.subheadline) - .foregroundStyle(.secondary) - .padding(.vertical, 6) - .analyticsPlainRow(separator: .hidden) - } else { - ForEach(accounts) { account in + .coverCard() + } + + private var listCard: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(store.accounts.enumerated()), id: \.element.id) { index, account in + if index > 0 { Hairline().padding(.leading, 66) } NavigationLink { AnalyticsAccountDetailView(account: account) } label: { - AnalyticsAccountRowView(account: account) + AnalyticsAccountRowView(account: account, shown: shown, index: index) } - .analyticsPlainRow(separatorLeading: 66) + .buttonStyle(MoreRowPressStyle()) } } + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .background(WTheme.groupedCard) + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) } } struct AnalyticsAccountRowView: View { let account: AccountHealthRow - - private var usage: String { - guard let usage = account.dailyUsage else { return "" } - var parts: [String] = [] - if let limit = usage.campaignLimit, limit > 0 { - parts.append("\(usage.campaignSent ?? 0) of \(limit) / day") - } - if let warmupLimit = usage.warmupLimit, warmupLimit > 0 { - parts.append("warmup \(usage.warmupSent ?? 0) of \(warmupLimit)") - } - return parts.joined(separator: " · ") - } + var shown = true + var index = 0 var body: some View { - HStack(spacing: 12) { - IconTile(symbol: "envelope", tone: AnalyticsToneMap.health(account.health?.status), size: 38) - VStack(alignment: .leading, spacing: 3) { - Text(account.email ?? "mailbox") - .font(.body.weight(.medium)) + let tone = AnalyticsToneMap.health(account.health?.status) + let usage = account.dailyUsage + let limit = max(0, usage?.campaignLimit ?? 0) + let sent = usage?.campaignSent ?? 0 + let issues = account.errors?.count ?? 0 + return HStack(spacing: 12) { + MailboxAvatar(email: account.email ?? "", avatarURL: account.avatarURL, brand: account.resolvedBrand, seed: account.id, size: 40) + VStack(alignment: .leading, spacing: 6) { + Text(account.email ?? "Mailbox") + .font(.system(size: 15.5, weight: .medium)) + .foregroundStyle(WTheme.ink) .lineLimit(1) - HStack(spacing: 4) { - if let provider = account.provider, !provider.isEmpty { - Text(provider) - } - if !usage.isEmpty { - if account.provider?.isEmpty == false { Text("·") } - Text(usage).monospacedDigit() + HStack(spacing: 6) { + Text(account.providerLabel) + if issues > 0 { + Text("·") + Text("\(issues) \(issues == 1 ? "issue" : "issues")") + .foregroundStyle(WTheme.negative) + } else if limit > 0 { + Text("·") + Text("\(sent)/\(limit) today").monospacedDigit() } } - .font(.footnote) - .foregroundStyle(.secondary) + .font(.system(size: 13)) + .foregroundStyle(WTheme.inkSecondary) .lineLimit(1) - } - Spacer() - StatusPill( - text: account.health?.status ?? "unknown", - tone: AnalyticsToneMap.health(account.health?.status) - ) - if let score = account.health?.score { - HealthRing(score: score, tone: AnalyticsToneMap.health(account.health?.status), size: 34) - } - } - .padding(.vertical, 9) - } -} - -// MARK: - Top campaigns - -struct AnalyticsTopCampaignsSection: View { - let campaigns: [AnalyticsTopCampaign] - - private func statusTone(_ raw: String?) -> Tone { - switch raw { - case "active": .emerald - case "paused": .amber - case "draft": .slate - default: .slate - } - } - - var body: some View { - if !campaigns.isEmpty { - AnalyticsSectionCaption("Top campaigns") - ForEach(campaigns) { campaign in - HStack(spacing: 12) { - IconTile(symbol: "megaphone", tone: statusTone(campaign.status), size: 36) - VStack(alignment: .leading, spacing: 3) { - Text(campaign.name ?? "Untitled campaign") - .font(.body.weight(.medium)) - .lineLimit(1) - Text("\(WFormat.compact(campaign.emailsSent ?? 0)) sent · \(AnalyticsFmt.rate(campaign.openRate)) open · \(AnalyticsFmt.rate(campaign.replyRate)) reply") - .font(.footnote) - .monospacedDigit() - .foregroundStyle(.secondary) - .lineLimit(1) - } - Spacer() - StatusPill( - text: campaign.status ?? "unknown", - tone: statusTone(campaign.status), - pulsing: campaign.status == "active" - ) + if limit > 0, sent > 0 { + GrowBar(fraction: Double(sent) / Double(limit), color: WTheme.accent, shown: shown, delay: 0.2 + Double(index) * 0.05, height: 4) + .frame(maxWidth: 160) } - .padding(.vertical, 9) - .analyticsPlainRow(separatorLeading: 64) - } - } - } -} - -// MARK: - Recent activity - -struct AnalyticsActivitySection: View { - let items: [AnalyticsActivityItem] - - private func icon(_ type: String?) -> String { - switch type { - case "sent": "paperplane" - case "opened": "envelope.open" - case "clicked": "link" - case "replied": "arrowshape.turn.up.left" - case "bounced": "exclamationmark.triangle" - default: "bolt" - } - } - - private func tone(_ type: String?) -> Tone { - switch type { - case "sent": .slate - case "opened": .sky - case "clicked": .indigo - case "replied": .emerald - case "bounced": .rose - default: .slate - } - } - - var body: some View { - if !items.isEmpty { - AnalyticsSectionCaption("Recent activity") - ForEach(Array(items.prefix(12).enumerated()), id: \.offset) { _, item in - HStack(spacing: 12) { - IconTile(symbol: icon(item.type), tone: tone(item.type), size: 34) - VStack(alignment: .leading, spacing: 3) { - Text(item.contactEmail ?? "unknown recipient") - .font(.body.weight(.medium)) - .lineLimit(1) - Text("\(item.type ?? "event") · \(item.campaignName ?? "campaign")") - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } - Spacer() - if let timestamp = item.timestamp { - Text(WFormat.relative(timestamp)) - .font(.footnote) - .monospacedDigit() - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 8) - .analyticsPlainRow(separatorLeading: 62) } + Spacer(minLength: 6) + HealthRing(score: account.health?.score, tone: tone, size: 38) + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(WTheme.inkTertiary) } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .contentShape(Rectangle()) } } diff --git a/ios/Warmbly/Features/Analytics/AnalyticsSidebar.swift b/ios/Warmbly/Features/Analytics/AnalyticsSidebar.swift index 3131e15b6..c863abf8f 100644 --- a/ios/Warmbly/Features/Analytics/AnalyticsSidebar.swift +++ b/ios/Warmbly/Features/Analytics/AnalyticsSidebar.swift @@ -1,10 +1,9 @@ import SwiftUI /// Navigation drawer for the analytics browser, mirroring the mailboxes / -/// contacts / campaigns sidebars: a slim sky hero with live totals, then the -/// analytics sections as pill rows on a rounded white sheet. The selected -/// pill slides between rows in the section's tone and rows cascade in when -/// the drawer opens. +/// contacts / campaigns sidebars: an ink header on the canvas wash with live +/// totals as hairline chips, then the analytics sections as rows. The +/// selected row's fill slides between rows and rows cascade in on open. struct AnalyticsSidebar: View { let store: AnalyticsStore let selection: AnalyticsScope @@ -26,66 +25,14 @@ struct AnalyticsSidebar: View { .padding(.top, 8) .padding(.bottom, 40) } - .background { - UnevenRoundedRectangle(topLeadingRadius: 24, topTrailingRadius: 24, style: .continuous) - .fill(Color(.systemBackground)) - .shadow(color: .black.opacity(0.1), radius: 14, y: -4) - } } - .background(alignment: .top) { - AirSkyWash().frame(height: 340) - } - .background(Color(.systemBackground)) + .background(WTheme.canvas) } // MARK: Hero private var hero: some View { - let sent = store.dashboard?.overallStats?.totalEmailsSent ?? 0 - return VStack(alignment: .leading, spacing: 13) { - HStack(spacing: 8) { - WarmblyLogo() - .fill(.white) - .frame(width: 21, height: 21 * (764 / 746)) - Text("Analytics") - .font(.system(size: 17.5, weight: .heavy)) - .tracking(-0.4) - .foregroundStyle(.white) - } - Text("Sends, opens, replies and health") - .font(.footnote.weight(.medium)) - .foregroundStyle(.white.opacity(0.82)) - .lineLimit(1) - HStack(spacing: 6) { - heroBadge(symbol: "paperplane.fill", text: "\(WFormat.compact(sent)) sent") - if store.warmingCount > 0 { - heroBadge(symbol: "flame.fill", text: "\(WFormat.compact(store.warmingCount)) warming", live: true) - } else if !store.accounts.isEmpty { - heroBadge(symbol: "envelope.fill", text: "\(WFormat.compact(store.accounts.count)) accounts") - } - } - } - .padding(.horizontal, 20) - .padding(.top, topInset + 12) - .padding(.bottom, 16) - .frame(maxWidth: .infinity, alignment: .leading) - } - - private func heroBadge(symbol: String, text: String, live: Bool = false) -> some View { - HStack(spacing: 5) { - Image(systemName: symbol) - .font(.system(size: 10.5, weight: .semibold)) - .foregroundStyle(.white.opacity(0.9)) - .modifier(PingEffect(active: live, color: .white)) - Text(text) - .font(.footnote.weight(.medium)) - .monospacedDigit() - .foregroundStyle(.white) - .contentTransition(.numericText()) - } - .padding(.horizontal, 10) - .padding(.vertical, 5.5) - .background(.white.opacity(0.16), in: Capsule()) + DrawerWorkspaceHeader(topInset: topInset) } private func sectionLabel(_ text: String) -> some View { @@ -106,41 +53,40 @@ struct AnalyticsSidebar: View { private func row(index: Int, scope: AnalyticsScope) -> some View { let selected = selection == scope - // Only the accounts row carries a count; it turns rose when any - // account has health issues, mirroring the sheet's counts pills. + // Only the accounts row carries a count; rose when any account has issues. let count = scope == .accounts ? store.accounts.count : 0 let countColor: Color? = scope == .accounts && store.issueCount > 0 ? Tone.rose.color : nil return Button { onSelect(scope) } label: { - HStack(spacing: 13) { + HStack(spacing: 12) { Image(systemName: scope.icon) - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(selected ? scope.tone.color : Color.secondary) - .frame(width: 24) + .font(.system(size: 14, weight: .regular)) + .foregroundStyle(selected ? WTheme.ink : WTheme.inkTertiary) + .frame(width: 22) Text(scope.title) - .font(.subheadline.weight(selected ? .semibold : .medium)) - .foregroundStyle(selected ? scope.tone.color : Color.primary) + .font(.system(size: 15, weight: selected ? .medium : .regular)) + .foregroundStyle(selected ? WTheme.ink : WTheme.inkSecondary) .lineLimit(1) Spacer(minLength: 8) if count > 0 { Text(WFormat.compact(count)) - .font(.footnote.weight(countColor != nil || selected ? .semibold : .medium)) + .font(WFont.meta(12, weight: .medium)) .monospacedDigit() - .foregroundStyle(countColor ?? (selected ? scope.tone.color : Color.secondary)) + .foregroundStyle(countColor ?? (selected ? WTheme.ink : WTheme.inkTertiary)) .contentTransition(.numericText()) } } - .padding(.horizontal, 16) - .frame(height: 44) + .padding(.horizontal, 14) + .frame(height: 42) .background { if selected { - Capsule() - .fill(scope.tone.background) + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(WTheme.fill) .matchedGeometryEffect(id: "analyticsdrawer-active", in: activeNS) } } - .contentShape(Capsule()) + .contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } .buttonStyle(TapScaleStyle()) .accessibilityElement(children: .ignore) diff --git a/ios/Warmbly/Features/Analytics/AnalyticsStore.swift b/ios/Warmbly/Features/Analytics/AnalyticsStore.swift index 219bdcff5..8eb3bc4e1 100644 --- a/ios/Warmbly/Features/Analytics/AnalyticsStore.swift +++ b/ios/Warmbly/Features/Analytics/AnalyticsStore.swift @@ -1,4 +1,4 @@ -import Foundation +import SwiftUI /// Loads the four analytics surfaces in parallel; each section fails /// independently so one broken endpoint never blanks the whole screen. @@ -10,6 +10,8 @@ final class AnalyticsStore { var campaignFilter = AnalyticsCampaignFilter() private(set) var dashboard: DashboardAnalytics? + /// The period before this one, from the next longer range; nil for 90 days. + private(set) var previous: AnalyticsTotals? private(set) var deliverability: DeliverabilitySummary? private(set) var warmup: WarmupAnalytics? private(set) var accounts: [AccountHealthRow] = [] @@ -77,11 +79,15 @@ final class AnalyticsStore { var query: [String: String?] = ["period": period.rawValue] if !filter.campaigns.isEmpty { query["campaign_ids"] = filter.campaigns.sorted().joined(separator: ",") } if !filter.folders.isEmpty { query["folder_ids"] = filter.folders.sorted().joined(separator: ",") } + let wanted = period + async let comparison: DashboardAnalytics? = loadComparison(api, query: query) do { let result: DashboardAnalytics = try await api.get("analytics/dashboard", query: query) + let longer = await comparison // A newer filter is already on its way; this answer describes the old one. - guard filter == campaignFilter else { return } + guard filter == campaignFilter, wanted == period else { return } dashboard = result + previous = longer.map { AnalyticsTotals.before(days: wanted.days, in: $0.dailyTrend ?? []) } dashboardError = nil // Drop selections the server no longer recognises (deleted, or another workspace's). if let scope = result.scope { @@ -97,6 +103,19 @@ final class AnalyticsStore { } } + /// The next longer range, whose older days are this period's predecessor. + private func loadComparison(_ api: APIClient, query: [String: String?]) async -> DashboardAnalytics? { + let longer: AnalyticsPeriod? = switch period { + case .week: .month + case .month: .quarter + case .quarter: nil + } + guard let longer else { return nil } + var query = query + query["period"] = longer.rawValue + return try? await api.get("analytics/dashboard", query: query) + } + private func loadDeliverability(_ api: APIClient) async { // from/to are RFC3339 here (unlike the warmup endpoint). let to = Date() @@ -130,10 +149,32 @@ final class AnalyticsStore { } } + /// A mailbox's avatar by id, branded once the health rows have loaded. + func mailboxAvatar(id: String, email: String?, size: CGFloat) -> MailboxAvatar { + let row = accounts.first { $0.id == id } + return MailboxAvatar( + email: email ?? row?.email ?? "", + avatarURL: row?.avatarURL, + brand: row?.resolvedBrand ?? MailboxBrand(provider: nil, mailHost: nil), + seed: id, + size: size + ) + } + private func loadAccounts(_ api: APIClient) async { do { - let envelope: AnalyticsDataEnvelope = try await api.get("analytics/accounts") - accounts = envelope.data ?? [] + async let health: AnalyticsDataEnvelope = api.get("analytics/accounts") + async let list: ListResponse = api.get("emails", query: ["limit": "200"]) + let envelope = try await health + let mailboxes = Dictionary(((try? await list)?.data ?? []).map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + accounts = (envelope.data ?? []).map { row in + var row = row + if let mailbox = mailboxes[row.id] { + row.brand = mailbox.brand + row.avatarURL = mailbox.avatarURL + } + return row + } accountsError = nil } catch { accountsError = error.localizedDescription diff --git a/ios/Warmbly/Features/Analytics/AuditLogView.swift b/ios/Warmbly/Features/Analytics/AuditLogView.swift index b947fbe4e..98b953d26 100644 --- a/ios/Warmbly/Features/Analytics/AuditLogView.swift +++ b/ios/Warmbly/Features/Analytics/AuditLogView.swift @@ -14,6 +14,8 @@ final class AuditLogStore { private(set) var errorMessage: String? private(set) var nextCursor: String? private(set) var hasMore = false + /// Entries that just arrived live, lit briefly in the feed. + private(set) var fresh: Set = [] var actionFilter: String? var entityFilter: String? @@ -77,8 +79,14 @@ final class AuditLogStore { let known = Set(entries.map(\.id)) let new = fresh.filter { !known.contains($0.id) } if !new.isEmpty { - withAnimation(.snappy) { + withAnimation(.spring(response: 0.5, dampingFraction: 0.82)) { entries.insert(contentsOf: new, at: 0) + self.fresh.formUnion(new.map(\.id)) + } + let ids = Set(new.map(\.id)) + Task { + try? await Task.sleep(for: .seconds(2.4)) + withAnimation(.easeOut(duration: 0.8)) { self.fresh.subtract(ids) } } } } @@ -188,67 +196,137 @@ enum AuditFmt { } } - static func entityTone(_ raw: String?) -> Tone { + /// "delete" -> "deleted", for "Dev deleted a meeting". + static func verb(_ raw: String?) -> String { switch raw { - case "campaign", "step", "template", "unibox": - .sky - case "contact", "crm_pipeline", "crm_stage", "crm_deal", "crm_task", "crm_note": - .indigo - case "email_account", "warmup_routing_rule": - .orange - case "organization_member", "invitation", "team", "role", "user": - .emerald - case "api_key", "webhook", "integration", "lead_sync_source", "automation": - .amber - default: - .slate + case "create": "created" + case "update": "updated" + case "delete": "deleted" + case "send": "sent" + case "start": "started" + case "stop": "stopped" + case "pause": "paused" + case "resume": "resumed" + case "invite": "invited" + case "remove": "removed" + case "connect": "connected" + case "disconnect": "disconnected" + case "revoke": "revoked" + case "export": "exported" + case "import": "imported" + case "duplicate": "duplicated" + case "transfer": "transferred" + case "rotate", "rotate_keys": "rotated keys for" + case "test": "tested" + case "api_call": "called the API on" + case "apply": "applied" + case "install": "installed" + case "uninstall": "uninstalled" + case "assign": "assigned" + case "reboot": "rebooted" + default: humanize(raw).lowercased() } } + + /// The entity as a noun phrase with its article: "a meeting", "an API key". + static func noun(_ raw: String?) -> String { + let word: String = switch raw { + case "email_account": "mailbox" + case "organization_member": "member" + case "organization": "workspace" + case "crm_deal": "deal" + case "crm_task": "task" + case "crm_note": "note" + case "crm_pipeline": "pipeline" + case "crm_stage": "stage" + case "api_key": "API key" + case "lead_sync_source": "lead source" + case "warmup_routing_rule": "warmup rule" + case "unibox": "inbox thread" + case "step": "campaign step" + case nil, "": "something" + default: humanize(raw).lowercased() + } + if raw == "settings" || raw == "subscription" || word == "something" { + return raw == "subscription" ? "the subscription" : word + } + let article = "aeiou".contains(word.prefix(1).lowercased()) || word.hasPrefix("API") ? "an" : "a" + return "\(article) \(word)" + } + + /// The best human name the entry recorded for its target. + static func subject(_ entry: AuditLogEntry) -> String? { + let bag = entry.metadata ?? [:] + for key in ["title", "name", "subject", "email", "invitee_email", "label"] { + if let value = bag[key]?.trimmingCharacters(in: .whitespaces), !value.isEmpty { return value } + } + return nil + } } // MARK: - View -/// The org activity trail: every audited mutation, live. This feed is the -/// realtime spine made visible. +/// A quick entity filter above the feed. +enum AuditScope: String, CaseIterable, Hashable { + case all, campaigns, contacts, mailboxes, team, crm + + var title: String { + switch self { + case .all: "All" + case .campaigns: "Campaigns" + case .contacts: "Contacts" + case .mailboxes: "Mailboxes" + case .team: "Team" + case .crm: "Deals" + } + } + + var entity: String? { + switch self { + case .all: nil + case .campaigns: "campaign" + case .contacts: "contact" + case .mailboxes: "email_account" + case .team: "organization_member" + case .crm: "crm_deal" + } + } +} + +/// The org activity trail: every audited mutation as a live timeline, with +/// new entries sliding in at the top as teammates work. struct AuditLogView: View { @Environment(AppEnvironment.self) private var env @State private var store = AuditLogStore() + @State private var shown = false + #if DEBUG + @State private var debugOpen = false + #endif var body: some View { Group { if !env.session.can(.viewAnalytics) { - EmptyStateView( + CoverEmptyState( + symbol: "lock.fill", + color: WTheme.inkSecondary, title: "No access", message: "You need the view analytics permission to see the activity trail." ) - } else if !store.entries.isEmpty { - feed - } else if let error = store.errorMessage { + .frame(maxHeight: .infinity) + } else if let error = store.errorMessage, store.entries.isEmpty { ErrorStateView(title: "Couldn't load activity", message: error) { await store.load(env.api) } - } else if store.hasLoaded { - if store.hasFilters { - EmptyStateView( - title: "No matching entries", - message: "Nothing in the last 90 days matches these filters.", - ctaTitle: "Clear filters" - ) { - store.clearFilters() - Task { await store.load(env.api) } - } - } else { - EmptyStateView( - title: "No activity yet", - message: "Team actions appear here as they happen. Entries are kept for 90 days." - ) - } + } else if !store.hasLoaded { + ScrollView { AnalyticsLoadingCards().padding(16) } } else { - SkeletonRows(rows: 12) + feed } } + .background(WTheme.groupedPage) .navigationTitle("Activity") .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(WTheme.groupedPage, for: .navigationBar) .toolbar { ToolbarItem(placement: .topBarTrailing) { filterMenu } } @@ -262,52 +340,233 @@ struct AuditLogView: View { .onChange(of: store.entityFilter) { Task { await store.load(env.api) } } + .sensoryFeedback(.impact(weight: .light), trigger: store.fresh.count) { old, new in new > old } + #if DEBUG + .navigationDestination(isPresented: $debugOpen) { + if let first = store.entries.first { AuditEntryDetailView(entry: first) } + } + .onChange(of: store.hasLoaded) { + debugOpen = ProcessInfo.processInfo.environment["WARMBLY_DEBUG_AUDIT_ENTRY"] != nil && !store.entries.isEmpty + } + #endif + } + + // MARK: Hero + + /// Today's count is exact once the oldest loaded entry predates today. + private var todayCount: (value: Int, exact: Bool) { + let today = store.entries.count { $0.when.map(Calendar.current.isDateInToday) ?? false } + let exact = !store.hasMore || (store.entries.last?.when.map { !Calendar.current.isDateInToday($0) } ?? true) + return (today, exact) + } + + private var recentActors: [AuditActor] { + var seen = Set() + var result: [AuditActor] = [] + for entry in store.entries { + guard let actor = entry.actor, let key = actor.id ?? actor.email, !seen.contains(key) else { continue } + seen.insert(key) + result.append(actor) + if result.count == 4 { break } + } + return result + } + + private var hero: some View { + let today = todayCount + let actors = recentActors + return VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 7) { + LivePulse() + Text("Live") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(WTheme.positive) + Spacer() + Text("Kept for 90 days") + .font(.system(size: 12.5)) + .foregroundStyle(WTheme.inkTertiary) + } + HStack(alignment: .firstTextBaseline, spacing: 8) { + CountUpText(value: shown ? Double(today.value) : 0, format: { "\(Int($0.rounded()))" }) + .font(.system(size: 40, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(WTheme.ink) + .contentTransition(.numericText()) + .animation(.spring(response: 0.9, dampingFraction: 0.9).delay(0.1), value: shown) + Text((today.exact ? "" : "+ ") + (today.value == 1 ? "change today" : "changes today")) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(WTheme.inkSecondary) + } + if !actors.isEmpty { + HStack(spacing: 10) { + HStack(spacing: -8) { + ForEach(Array(actors.enumerated()), id: \.offset) { index, actor in + WAvatar(name: actor.displayName ?? "?", seed: actor.id ?? actor.email ?? "", size: 28) + .overlay(Circle().stroke(WTheme.groupedCard, lineWidth: 2)) + .zIndex(Double(actors.count - index)) + .scaleEffect(shown ? 1 : 0.4) + .opacity(shown ? 1 : 0) + .animation(.spring(response: 0.5, dampingFraction: 0.65).delay(0.15 + Double(index) * 0.06), value: shown) + } + } + Text(actorsLine(actors)) + .font(.system(size: 13.5)) + .foregroundStyle(WTheme.inkSecondary) + .lineLimit(1) + } + } + } + .coverCard() + .reveal(shown, 0) + } + + private func actorsLine(_ actors: [AuditActor]) -> String { + let first = actors.first?.firstName.flatMap { $0.isEmpty ? nil : $0 } ?? actors.first?.displayName ?? "Someone" + switch actors.count { + case 1: return "\(first) has been busy" + case 2: return "\(first) and 1 teammate recently" + default: return "\(first) and \(actors.count - 1) teammates recently" + } + } + + // MARK: Feed + + private var scope: Binding { + Binding( + get: { AuditScope.allCases.first { $0.entity == store.entityFilter } ?? .all }, + set: { store.entityFilter = $0.entity } + ) } private var feed: some View { List { - ForEach(store.sections) { section in - // Day captions are bare rows, not sticky grouped headers. - AnalyticsSectionCaption( - section.label, - top: section.id == store.sections.first?.id ? 14 : 20 - ) - ForEach(section.entries) { entry in - NavigationLink { - AuditEntryDetailView(entry: entry) - } label: { - AuditRowView(entry: entry) + Section { + VStack(spacing: 14) { + if !store.hasFilters || store.actionFilter == nil { + hero } - .analyticsPlainRow(separatorLeading: 64) + ChipFilterRow(options: AuditScope.allCases, selection: scope, label: \.title) + if let action = store.actionFilter { + activeFilter(action) + } + } + .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 0, trailing: 0)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + if store.entries.isEmpty { + Section { + emptyState + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) } } - if store.hasMore { - HStack { - Spacer() - ProgressView() - Spacer() + ForEach(store.sections) { section in + Section { + ForEach(section.entries) { entry in + NavigationLink { + AuditEntryDetailView(entry: entry) + } label: { + AuditRowView(entry: entry) + } + .listRowBackground( + ZStack { + WTheme.groupedCard + if store.fresh.contains(entry.id) { + WTheme.accent.opacity(0.09).transition(.opacity) + } + } + ) + .listRowInsets(EdgeInsets(top: 10, leading: 14, bottom: 10, trailing: 14)) + .alignmentGuide(.listRowSeparatorLeading) { _ in 64 } + .transition(.asymmetric(insertion: .move(edge: .top).combined(with: .opacity), removal: .opacity)) + } + } header: { + CoverSectionHeader( + title: section.label, + count: section.entries.count, + color: section.id == Calendar.current.startOfDay(for: .now) ? WTheme.positive : nil, + pulsing: section.id == Calendar.current.startOfDay(for: .now) + ) } - .frame(height: 44) + } + if !store.entries.isEmpty { + Section { + if store.hasMore { + ProgressView() + .frame(maxWidth: .infinity) + .frame(height: 44) + .onAppear { + Task { await store.loadMore(env.api) } + } + } else { + Text("That's everything from the last 90 days") + .font(.system(size: 12.5, weight: .medium)) + .foregroundStyle(WTheme.inkTertiary) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + } + } + .listRowBackground(Color.clear) .listRowSeparator(.hidden) - .listRowBackground(Color(.systemBackground)) - .onAppear { - Task { await store.loadMore(env.api) } - } - } else { - Text("End of trail · 90 day retention") - .font(.footnote.weight(.medium)) - .foregroundStyle(.tertiary) - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .listRowSeparator(.hidden) - .listRowBackground(Color(.systemBackground)) } } - .listStyle(.plain) + .listStyle(.insetGrouped) + .listSectionSpacing(18) + .contentMargins(.horizontal, 16, for: .scrollContent) + .contentMargins(.top, 4, for: .scrollContent) + .contentMargins(.bottom, 100, for: .scrollContent) .scrollContentBackground(.hidden) - .environment(\.defaultMinListRowHeight, 0) - .background(Color(.systemBackground)) + .defaultScrollAnchor(SettingsDebug.anchor) .refreshable { await store.load(env.api) } + .onAppear { shown = true } + } + + private func activeFilter(_ action: String) -> some View { + HStack(spacing: 8) { + Image(systemName: AuditFmt.actionIcon(action)) + .font(.system(size: 12, weight: .semibold)) + Text("Only \(AuditFmt.verb(action))") + .font(.system(size: 13.5, weight: .medium)) + Spacer() + Button { + withAnimation(.snappy) { store.actionFilter = nil } + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 17)) + .foregroundStyle(WTheme.inkTertiary) + } + .buttonStyle(.plain) + .accessibilityLabel("Clear action filter") + } + .foregroundStyle(AuditFmt.actionTone(action).color) + .padding(.horizontal, 14) + .frame(height: 40) + .background(AuditFmt.actionTone(action).background, in: Capsule()) + } + + @ViewBuilder + private var emptyState: some View { + if store.hasFilters { + CoverEmptyState( + symbol: "line.3.horizontal.decrease", + color: WTheme.inkSecondary, + title: "Nothing matches", + message: "Nothing in the last 90 days matches this filter.", + actionTitle: "Show everything", + action: { + store.clearFilters() + Task { await store.load(env.api) } + } + ) + } else { + CoverEmptyState( + symbol: "sparkles", + color: WTheme.positive, + title: "Quiet so far", + message: "Every change your team makes lands here the moment it happens." + ) + } } private var filterMenu: some View { @@ -316,7 +575,7 @@ struct AuditLogView: View { Picker("Action", selection: $store.actionFilter) { Text("All actions").tag(String?.none) ForEach(Self.filterActions, id: \.self) { action in - Text(AuditFmt.humanize(action)).tag(String?.some(action)) + Label(AuditFmt.humanize(action), systemImage: AuditFmt.actionIcon(action)).tag(String?.some(action)) } } Picker("Entity", selection: $store.entityFilter) { @@ -335,6 +594,7 @@ struct AuditLogView: View { ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") } + .accessibilityLabel("Filter activity") } /// Open string enums server-side; this is the subset the web filter offers. @@ -352,70 +612,92 @@ struct AuditLogView: View { ] } +/// A green dot breathing a ring, for a live feed. +private struct LivePulse: View { + @State private var pulse = false + + var body: some View { + Circle() + .fill(WTheme.positive) + .frame(width: 8, height: 8) + .background { + Circle() + .fill(WTheme.positive.opacity(0.35)) + .scaleEffect(pulse ? 2.8 : 1) + .opacity(pulse ? 0 : 1) + } + .onAppear { + withAnimation(.easeOut(duration: 1.5).repeatForever(autoreverses: false)) { pulse = true } + } + } +} + // MARK: - Row +/// One entry as a sentence: who did what to which thing, and when. struct AuditRowView: View { let entry: AuditLogEntry private var actorLabel: String { - entry.actor?.displayName ?? "former member" + entry.actor?.displayName ?? "A former member" } var body: some View { - HStack(spacing: 12) { - IconTile(symbol: AuditFmt.actionIcon(entry.action), tone: AuditFmt.actionTone(entry.action), size: 36) + HStack(alignment: .top, spacing: 12) { + AuditActorBadge(entry: entry, size: 38) VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 6) { - Text(AuditFmt.humanize(entry.action)) - .font(.body.weight(.medium)) + (Text(actorLabel).fontWeight(.semibold).foregroundStyle(WTheme.ink) + + Text(" \(AuditFmt.verb(entry.action)) \(AuditFmt.noun(entry.entityType))").foregroundStyle(WTheme.inkSecondary)) + .font(.system(size: 14.5)) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + if let subject = AuditFmt.subject(entry) { + Text(subject) + .font(.system(size: 13.5, weight: .medium)) + .foregroundStyle(WTheme.ink) .lineLimit(1) - AuditEntityChip(entityType: entry.entityType) } - HStack(spacing: 4) { - Text(actorLabel) - .lineLimit(1) - if let entityID = entry.entityID, !entityID.isEmpty { - Text("·") - Text(String(entityID.prefix(8))) - .font(.footnote.monospaced()) - } + if let when = entry.when { + Text(when.formatted(date: .omitted, time: .shortened)) + .font(.system(size: 12.5)) + .monospacedDigit() + .foregroundStyle(WTheme.inkTertiary) } - .font(.footnote) - .foregroundStyle(.secondary) - } - Spacer() - if let when = entry.when { - Text(WFormat.relative(when)) - .font(.footnote) - .monospacedDigit() - .foregroundStyle(.secondary) } + Spacer(minLength: 0) } - .padding(.vertical, 8) } } -struct AuditEntityChip: View { - let entityType: String? +/// The actor's avatar with the action riding its corner. +struct AuditActorBadge: View { + let entry: AuditLogEntry + var size: CGFloat = 38 var body: some View { - Text(AuditFmt.humanize(entityType).lowercased()) - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(AuditFmt.entityTone(entityType).color) - .padding(.horizontal, 6) - .padding(.vertical, 2.5) - .background(AuditFmt.entityTone(entityType).background, in: Capsule()) - .lineLimit(1) + let tone = AuditFmt.actionTone(entry.action) + WAvatar(name: entry.actor?.displayName ?? "?", seed: entry.actor?.id ?? entry.userID ?? "", size: size) + .overlay(alignment: .bottomTrailing) { + Image(systemName: AuditFmt.actionIcon(entry.action)) + .font(.system(size: size * 0.24, weight: .bold)) + .foregroundStyle(.white) + .frame(width: size * 0.46, height: size * 0.46) + .background(tone.color, in: Circle()) + .overlay(Circle().stroke(WTheme.groupedCard, lineWidth: 2)) + .offset(x: 4, y: 3) + } } } // MARK: - Detail -/// Full record for a single audited action: actor, target, request context, -/// and the recorded field changes / metadata. +/// Full record for a single audited action: the sentence, when and from +/// where, and the recorded field changes and metadata. struct AuditEntryDetailView: View { let entry: AuditLogEntry + @State private var copied: String? + private var changePairs: [(key: String, value: String)] { (entry.changes ?? [:]).sorted { $0.key < $1.key }.map { ($0.key, $0.value) } } @@ -425,75 +707,65 @@ struct AuditEntryDetailView: View { } var body: some View { - List { - HStack(spacing: 12) { - IconTile(symbol: AuditFmt.actionIcon(entry.action), tone: AuditFmt.actionTone(entry.action), size: 42) - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 6) { - Text(AuditFmt.humanize(entry.action)) - .font(.body.weight(.semibold)) - AuditEntityChip(entityType: entry.entityType) - } - Text(entry.actor?.displayName ?? "former member") - .font(.subheadline) - .foregroundStyle(.secondary) + SettingsPage(title: "Activity", spacing: 22) { + VStack(spacing: 12) { + AuditActorBadge(entry: entry, size: 64) + (Text(entry.actor?.displayName ?? "A former member").fontWeight(.semibold).foregroundStyle(WTheme.ink) + + Text(" \(AuditFmt.verb(entry.action)) \(AuditFmt.noun(entry.entityType))").foregroundStyle(WTheme.inkSecondary)) + .font(.system(size: 19)) + .multilineTextAlignment(.center) + if let subject = AuditFmt.subject(entry) { + Text(subject) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(WTheme.ink) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(WTheme.fill, in: Capsule()) + } + if let when = entry.when { + Text(when.formatted(.dateTime.weekday(.wide).month(.wide).day().hour().minute())) + .font(.system(size: 13.5)) + .foregroundStyle(WTheme.inkTertiary) } - Spacer() } - .padding(.top, 16) - .padding(.bottom, 6) - .analyticsPlainRow(separator: .hidden) + .frame(maxWidth: .infinity) + .padding(.vertical, 20) + .padding(.horizontal, 16) + .background(WTheme.groupedCard, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) - AnalyticsSectionCaption("Details") - if let when = entry.when { - AuditDetailRow(label: "When", value: when.formatted(date: .abbreviated, time: .standard)) - .analyticsPlainRow() - } - if let email = entry.actor?.email, !email.isEmpty { - AuditDetailRow(label: "Actor", value: email) - .analyticsPlainRow() - } - if let entityID = entry.entityID, !entityID.isEmpty { - AuditDetailRow(label: "Entity id", value: entityID, mono: true) - .analyticsPlainRow() - } - if let ip = entry.ipAddress, !ip.isEmpty { - AuditDetailRow(label: "IP address", value: ip, mono: true) - .analyticsPlainRow() - } - if let agent = entry.userAgent, !agent.isEmpty { - AuditDetailRow(label: "User agent", value: agent) - .analyticsPlainRow() + SettingsSection("Details", dividerLeading: 16) { + AuditDetailRow(label: "Type", value: AuditFmt.humanize(entry.entityType)) + if let email = entry.actor?.email, !email.isEmpty { + AuditDetailRow(label: "Actor", value: email, copied: $copied) + } + if let entityID = entry.entityID, !entityID.isEmpty { + AuditDetailRow(label: "Record id", value: entityID, mono: true, copied: $copied) + } + if let ip = entry.ipAddress, !ip.isEmpty { + AuditDetailRow(label: "IP address", value: ip, mono: true, copied: $copied) + } + if let agent = entry.userAgent, !agent.isEmpty { + AuditDetailRow(label: "Client", value: agent) + } } if !changePairs.isEmpty { - AnalyticsSectionCaption("Changes") - ForEach(changePairs, id: \.key) { pair in - AuditDetailRow(label: AuditFmt.humanize(pair.key), value: pair.value, mono: true) - .analyticsPlainRow() + SettingsSection("Changes", dividerLeading: 16) { + ForEach(changePairs, id: \.key) { pair in + AuditDetailRow(label: AuditFmt.humanize(pair.key), value: pair.value, mono: true, copied: $copied) + } } } if !metadataPairs.isEmpty { - AnalyticsSectionCaption("Metadata") - ForEach(metadataPairs, id: \.key) { pair in - AuditDetailRow(label: AuditFmt.humanize(pair.key), value: pair.value, mono: true) - .analyticsPlainRow() + SettingsSection("Recorded with it", dividerLeading: 16) { + ForEach(metadataPairs, id: \.key) { pair in + AuditDetailRow(label: AuditFmt.humanize(pair.key), value: pair.value, copied: $copied) + } } } - - Color.clear - .frame(height: 24) - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - .listRowBackground(Color(.systemBackground)) } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .environment(\.defaultMinListRowHeight, 0) - .background(Color(.systemBackground)) - .navigationTitle("Activity") - .navigationBarTitleDisplayMode(.inline) + .sensoryFeedback(.selection, trigger: copied) } } @@ -501,19 +773,39 @@ struct AuditDetailRow: View { let label: String let value: String var mono: Bool = false + var copied: Binding? var body: some View { - HStack(alignment: .top, spacing: 12) { + HStack(alignment: .firstTextBaseline, spacing: 12) { Text(label) - .font(.subheadline) - .foregroundStyle(.secondary) + .font(.system(size: 14)) + .foregroundStyle(WTheme.inkSecondary) .frame(width: 96, alignment: .leading) Text(value) - .font(mono ? .subheadline.monospaced() : .subheadline) - .foregroundStyle(.primary) + .font(mono ? .system(size: 13.5, design: .monospaced) : .system(size: 14.5)) + .foregroundStyle(WTheme.ink) .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) + if let copied { + Button { + UIPasteboard.general.string = value + withAnimation(.snappy) { copied.wrappedValue = label } + Task { + try? await Task.sleep(for: .seconds(1.4)) + withAnimation(.snappy) { if copied.wrappedValue == label { copied.wrappedValue = nil } } + } + } label: { + Image(systemName: copied.wrappedValue == label ? "checkmark" : "doc.on.doc") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(copied.wrappedValue == label ? WTheme.positive : WTheme.inkTertiary) + .contentTransition(.symbolEffect(.replace)) + .frame(width: 28, height: 28) + } + .buttonStyle(.plain) + .accessibilityLabel("Copy \(label.lowercased())") + } } - .padding(.vertical, 6) + .padding(.horizontal, 16) + .padding(.vertical, 12) } } diff --git a/ios/Warmbly/Features/Assistant/AssistantStore.swift b/ios/Warmbly/Features/Assistant/AssistantStore.swift index 3e7f71010..5d7b1d302 100644 --- a/ios/Warmbly/Features/Assistant/AssistantStore.swift +++ b/ios/Warmbly/Features/Assistant/AssistantStore.swift @@ -42,6 +42,10 @@ final class AssistantChatStore { private(set) var freeModel = false private(set) var creditsRemaining: Int? private(set) var errorMessage: String? + /// The live run's start, step and step budget, for the working status. + private(set) var runStartedAt: Date? + private(set) var iteration = 0 + private(set) var budget = 0 private var streamTask: Task? @@ -107,6 +111,7 @@ final class AssistantChatStore { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, !isStreaming else { return } errorMessage = nil + beginRun() withAnimation(.snappy) { turns.append(Turn(role: "user", blocks: [Block(kind: .text, text: trimmed, done: true)])) } @@ -146,6 +151,7 @@ final class AssistantChatStore { guard let sessionID = session?.id, !isStreaming else { return } withAnimation(.snappy) { pending = nil } errorMessage = nil + beginRun() isStreaming = true streamTask = Task { do { @@ -205,6 +211,8 @@ final class AssistantChatStore { } case "iteration": if let credits = event.creditsRemaining { creditsRemaining = credits } + if let step = event.iteration { iteration = step } + if let total = event.budget { budget = total } case "done": if let credits = event.creditsRemaining { creditsRemaining = credits } if let free = event.freeModel { freeModel = free } @@ -215,6 +223,61 @@ final class AssistantChatStore { } } + #if DEBUG + /// Plays a scripted run through the real event folding, like the web's dev demo. + func playDemo(approval: Bool) { + guard !isStreaming else { return } + errorMessage = nil + beginRun() + withAnimation(.snappy) { + turns.append(Turn(role: "user", blocks: [Block(kind: .text, text: "Which mailboxes need attention?", done: true)])) + } + isStreaming = true + let reply = "Two mailboxes need a look. **anna@sunrise.io** is landing in spam at 14% on Gmail, so I'd lower its daily volume for a week. **mark@sunrise.io** failed to sync twice today with an auth error; reconnecting it should fix that.\n\n- Lower anna's cold limit from 50 to 30\n- Reconnect mark's mailbox" + streamTask = Task { + @MainActor func step(_ ms: Int, _ event: AgentStreamEvent) async throws { + try await Task.sleep(for: .milliseconds(ms)) + apply(event) + } + do { + try await step(900, AgentStreamEvent(type: "iteration", iteration: 1, creditsRemaining: 4820, budget: 8)) + try await step(300, AgentStreamEvent(type: "tool_start", tool: "list_email_accounts", argsSummary: "status: all")) + try await step(1300, AgentStreamEvent(type: "tool_result", tool: "list_email_accounts", result: "12 mailboxes")) + try await step(200, AgentStreamEvent(type: "tool_start", tool: "get_dashboard_analytics", argsSummary: "last 7 days")) + try await step(1200, AgentStreamEvent(type: "tool_result", tool: "get_dashboard_analytics", result: "inbox rate 91%")) + try await step(200, AgentStreamEvent(type: "tool_start", tool: "get_advisor_summary")) + try await step(1000, AgentStreamEvent(type: "tool_result", tool: "get_advisor_summary", result: "2 open findings")) + try await step(400, AgentStreamEvent(type: "iteration", iteration: 2, creditsRemaining: 4811, budget: 8)) + var sent = "" + for word in reply.split(separator: " ", omittingEmptySubsequences: false) { + let chunk = (sent.isEmpty ? "" : " ") + word + sent += chunk + try await step(45, AgentStreamEvent(type: "text_delta", text: chunk)) + } + try await step(100, AgentStreamEvent(type: "text", text: reply)) + if approval { + try await step(600, AgentStreamEvent( + type: "approval_required", + tool: "update_email_account", + risk: "write", + argsSummary: "email: anna@sunrise.io\ncampaign_limit: 50 -> 30", + toolCallID: "demo" + )) + } else { + try await step(200, AgentStreamEvent(type: "done", creditsRemaining: 4807)) + } + } catch {} + markStreamEnded() + } + } + #endif + + private func beginRun() { + runStartedAt = .now + iteration = 0 + budget = 0 + } + private func ensureAssistantTurn() -> Int { if let last = turns.indices.last, turns[last].role == "assistant" { return last diff --git a/ios/Warmbly/Features/Assistant/AssistantView.swift b/ios/Warmbly/Features/Assistant/AssistantView.swift index bd174bbc8..a5052fa25 100644 --- a/ios/Warmbly/Features/Assistant/AssistantView.swift +++ b/ios/Warmbly/Features/Assistant/AssistantView.swift @@ -1,62 +1,66 @@ import SwiftUI -/// The dashboard AI agent as a full-screen conversation: streaming answers, -/// tool steps as they run, approval cards for risky tools, and a history -/// sheet with search and grouped conversations. Entry is gated on `.useAI`. +/// Remie on the phone, shaped like the agent apps people already know: glass +/// controls over the conversation, answers in full-width type, a neutral +/// bubble for what you asked, swipeable starters and a floating composer. struct AssistantView: View { @Environment(AppEnvironment.self) private var env @Environment(\.dismiss) private var dismiss /// Context hint sent with the first message (web sends the current page). var page: String? + /// Sent as the first message when the view opens (Home's starter prompts). + var initialPrompt: String? @State private var chat = AssistantChatStore() @State private var input = "" @State private var showHistory = false + @State private var suggestions: [RemieSuggestion] = [] + @State private var appeared = false + @State private var boop = 0 + @State private var sent = 0 + @State private var atBottom = true @FocusState private var inputFocused: Bool + private struct Starter: Identifiable { + let title: String + let detail: String + let prompt: String + var dot: Color? + var id: String { prompt } + } + + private static let starters = [ + Starter(title: "Review my week", detail: "How are my campaigns doing?", prompt: "How are my campaigns doing this week?"), + Starter(title: "Mailbox health", detail: "Which mailboxes need attention?", prompt: "Which mailboxes need attention?"), + Starter(title: "Draft a follow-up", detail: "For my latest reply", prompt: "Draft a follow-up for my latest reply"), + Starter(title: "Find warm leads", detail: "Who replied positively lately?", prompt: "Which contacts replied positively in the last week?"), + ] + + private var starters: [Starter] { + guard !suggestions.isEmpty else { return Self.starters } + return suggestions.prefix(4).map { s in + Starter(title: s.text, detail: Self.severityLabel(s.severity), prompt: s.prompt, dot: s.dot) + } + } + + private static func severityLabel(_ severity: String) -> String { + switch severity { + case "critical": "Critical · ask Remie to fix it" + case "high": "High impact · ask Remie to fix it" + default: "Ask Remie to fix it" + } + } + var body: some View { NavigationStack { - VStack(spacing: 0) { - conversation - if let pending = chat.pending { - approvalCard(pending) - } - if let error = chat.errorMessage { - errorBanner(error) - } - inputBar - } - .background(Color(.systemBackground)) - .navigationTitle(chat.title ?? "Assistant") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button { - dismiss() - } label: { - Image(systemName: "xmark") - } - .accessibilityLabel("Close assistant") - } - ToolbarItemGroup(placement: .topBarTrailing) { - Button { - showHistory = true - } label: { - Image(systemName: "clock.arrow.circlepath") - } - .accessibilityLabel("Conversation history") - Button { - withAnimation(.snappy) { chat.newChat() } - inputFocused = true - } label: { - Image(systemName: "square.and.pencil") - } - .disabled(chat.isEmpty) - .accessibilityLabel("New chat") - } - } + content + .background(WTheme.canvas) + .safeAreaInset(edge: .bottom, spacing: 0) { bottomBar } + .navigationBarTitleDisplayMode(.inline) + .toolbar { toolbar } } + .tint(WTheme.ink) .sheet(isPresented: $showHistory) { AssistantHistoryView { session in showHistory = false @@ -64,324 +68,503 @@ struct AssistantView: View { } } .onDisappear { chat.stop() } + .task { + guard let initialPrompt, chat.isEmpty else { return } + #if DEBUG + if initialPrompt.hasPrefix("demo") { + chat.playDemo(approval: initialPrompt == "demo-approval") + return + } + #endif + chat.send(env.api, text: initialPrompt, page: page) + } + .task { await loadSuggestions() } + .onAppear { + withAnimation(.spring(response: 0.6, dampingFraction: 0.86)) { appeared = true } + } + .sensoryFeedback(.impact(weight: .light), trigger: sent) + .sensoryFeedback(trigger: chat.isStreaming) { was, now in + was && !now && chat.errorMessage == nil ? .success : nil + } } - // MARK: Conversation + private var markState: RemieMarkState { + if chat.pending != nil { return .attention } + return chat.isStreaming ? .thinking : .idle + } + + // MARK: Toolbar + + @ToolbarContentBuilder + private var toolbar: some ToolbarContent { + ToolbarItem(placement: .topBarLeading) { + Button { + dismiss() + } label: { + Image(systemName: "chevron.down") + } + .accessibilityLabel("Close Remie") + } + ToolbarItem(placement: .principal) { + HStack(spacing: 7) { + RemieMark(size: 20, state: markState, celebrate: chat.errorMessage == nil, boop: boop) + Text(chat.title.flatMap { $0.isEmpty ? nil : $0 } ?? "Remie") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(WTheme.ink) + .lineLimit(1) + .contentTransition(.opacity) + } + .frame(maxWidth: 220) + .animation(.easeOut(duration: 0.2), value: chat.title) + } + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + showHistory = true + } label: { + Image(systemName: "clock.arrow.circlepath") + } + .accessibilityLabel("Conversation history") + Button { + withAnimation(.snappy) { chat.newChat() } + inputFocused = true + } label: { + Image(systemName: "square.and.pencil") + } + .disabled(chat.isEmpty) + .accessibilityLabel("New chat") + } + } + + // MARK: Content @ViewBuilder - private var conversation: some View { + private var content: some View { if chat.isLoadingTranscript { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if chat.turns.isEmpty { + loadingSkeleton + } else if chat.turns.isEmpty, !chat.isStreaming { emptyState } else { - ScrollViewReader { proxy in - ScrollView { - VStack(alignment: .leading, spacing: 14) { - ForEach(chat.turns) { turn in - turnView(turn) - } - if chat.isStreaming, awaitingFirstBlock { - thinkingRow - } - Color.clear.frame(height: 1).id("bottom") + conversation + } + } + + private var conversation: some View { + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 22) { + ForEach(chat.turns) { turn in + turnView(turn, streaming: chat.isStreaming && chat.pending == nil && turn.id == chat.turns.last?.id) } - .padding(.horizontal, 16) - .padding(.vertical, 14) - .frame(maxWidth: .infinity, alignment: .leading) + if let pending = chat.pending { + RemieApprovalCard(pending: pending) { decision in + chat.decide(env.api, decision: decision) + } + .transition(.asymmetric( + insertion: .scale(scale: 0.96, anchor: .bottom).combined(with: .opacity).combined(with: .offset(y: 10)), + removal: .opacity.combined(with: .scale(scale: 0.98)) + )) + } + if chat.isStreaming, chat.pending == nil { + RemieWorkingStatus( + label: phase, + since: chat.runStartedAt, + step: chat.iteration, + budget: chat.budget + ) + .transition(.opacity.combined(with: .offset(y: 4))) + } + if let error = chat.errorMessage { + errorRow(error) + } + Color.clear.frame(height: 1).id("bottom") } - .scrollDismissesKeyboard(.interactively) - .onChange(of: chat.turns.last?.blocks.last?.text) { - proxy.scrollTo("bottom", anchor: .bottom) - } - .onChange(of: chat.turns.count) { - withAnimation(.snappy) { proxy.scrollTo("bottom", anchor: .bottom) } + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 24) + .frame(maxWidth: .infinity, alignment: .leading) + .animation(.spring(response: 0.42, dampingFraction: 0.86), value: chat.pending?.toolCallID) + .animation(.easeOut(duration: 0.2), value: chat.isStreaming) + } + .defaultScrollAnchor(.bottom) + .defaultScrollAnchor(.bottom, for: .sizeChanges) + .scrollEdgeEffectStyle(.soft, for: .vertical) + .scrollDismissesKeyboard(.interactively) + .onScrollGeometryChange(for: Bool.self) { geo in + geo.contentSize.height <= geo.containerSize.height || geo.visibleRect.maxY >= geo.contentSize.height - 80 + } action: { _, isAtBottom in + withAnimation(.snappy) { atBottom = isAtBottom } + } + .onChange(of: chat.turns.count) { + withAnimation(.snappy) { proxy.scrollTo("bottom", anchor: .bottom) } + } + .overlay(alignment: .bottom) { + if !atBottom { + Button { + withAnimation(.snappy) { proxy.scrollTo("bottom", anchor: .bottom) } + } label: { + Image(systemName: "arrow.down") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(WTheme.ink) + .frame(width: 40, height: 40) + } + .buttonStyle(TapScaleStyle()) + .glassEffect(.regular.interactive(), in: Circle()) + .padding(.bottom, 10) + .transition(.scale(scale: 0.6).combined(with: .opacity)) + .accessibilityLabel("Jump to latest") } } } } - /// True until the run's first block arrives, so the user sees activity. - private var awaitingFirstBlock: Bool { - guard let last = chat.turns.last else { return true } - return last.role == "user" || last.blocks.isEmpty - } - - private var thinkingRow: some View { - HStack(spacing: 8) { - Image(systemName: "sparkles") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(Tone.indigo.color) - .symbolEffect(.pulse) - Text("Thinking…") - .font(.subheadline) - .foregroundStyle(.secondary) - .skeletonShimmer() + /// What the live run is doing, read off the tail of the transcript. + private var phase: String { + guard let last = chat.turns.last, last.role == "assistant", let block = last.blocks.last else { + return "Thinking" } - .transition(.opacity) + if block.kind == .tool, !block.done { return "Working" } + if block.kind == .text, block.live { return "Writing" } + return "Thinking" } - private var emptyState: some View { - VStack(spacing: 14) { - Spacer() - Image(systemName: "sparkles") - .font(.system(size: 34, weight: .medium)) - .foregroundStyle(Tone.indigo.color) - Text("Ask your workspace") - .font(.title3.weight(.semibold)) - Text("Campaign performance, contact lookups, drafting help — the assistant can read and act on your workspace, and asks before anything risky.") - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 36) - VStack(spacing: 8) { - suggestionChip("How are my campaigns doing this week?") - suggestionChip("Which mailboxes need attention?") - suggestionChip("Draft a follow-up for my latest reply") + private var loadingSkeleton: some View { + VStack(alignment: .leading, spacing: 22) { + HStack { + Spacer() + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(WTheme.fill) + .frame(width: 170, height: 42) + } + VStack(alignment: .leading, spacing: 10) { + ForEach([0.94, 0.82, 0.58], id: \.self) { width in + RoundedRectangle(cornerRadius: 5) + .fill(WTheme.fill) + .frame(height: 13) + .containerRelativeFrame(.horizontal) { length, _ in (length - 40) * width } + } } - .padding(.top, 6) - Spacer() Spacer() } - .frame(maxWidth: .infinity) - } - - private func suggestionChip(_ text: String) -> some View { - Button { - chat.send(env.api, text: text, page: page) - } label: { - Text(text) - .font(.footnote.weight(.medium)) - .foregroundStyle(.primary) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(Color(.secondarySystemBackground), in: Capsule()) - } - .buttonStyle(TapScaleStyle()) + .skeletonShimmer() + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } // MARK: Turns @ViewBuilder - private func turnView(_ turn: AssistantChatStore.Turn) -> some View { + private func turnView(_ turn: AssistantChatStore.Turn, streaming: Bool) -> some View { if turn.role == "user" { HStack { - Spacer(minLength: 48) - Text(turn.blocks.first?.text ?? "") - .font(.subheadline) - .foregroundStyle(.white) - .padding(.horizontal, 13) - .padding(.vertical, 9) - .background(WTheme.accent, in: RoundedRectangle(cornerRadius: 17, style: .continuous)) + Spacer(minLength: 60) + Text(turn.blocks.map(\.text).joined()) + .font(.system(size: 16)) + .lineSpacing(3) + .foregroundStyle(WTheme.ink) + .textSelection(.enabled) + .padding(.horizontal, 15) + .padding(.vertical, 10) + .background(WTheme.fill, in: RoundedRectangle(cornerRadius: 21, style: .continuous)) } + .transition(.asymmetric( + insertion: .scale(scale: 0.94, anchor: .bottomTrailing) + .combined(with: .opacity) + .combined(with: .offset(y: 10)), + removal: .opacity + )) } else { - VStack(alignment: .leading, spacing: 8) { - ForEach(turn.blocks) { block in - blockView(block) + let segments = Self.segments(turn.blocks) + let lastIndex = turn.blocks.count - 1 + let finished = !streaming && !(chat.isStreaming && turn.id == chat.turns.last?.id) + let answer = turn.blocks.filter { $0.kind == .text }.map(\.text).joined(separator: "\n\n") + VStack(alignment: .leading, spacing: 12) { + ForEach(segments) { segment in + switch segment.kind { + case let .tools(steps): + RemieActivityTrace( + steps: steps, + live: streaming && segment.id == segments.last?.id, + streaming: chat.isStreaming + ) + case let .text(block, index): + if !block.text.isEmpty { + if streaming && index == lastIndex { + RemieSmoothText(text: block.text) + } else { + RemieMarkdown(text: block.text) + } + } + } + } + if finished, !answer.isEmpty { + RemieTurnActions(text: answer) + .transition(.opacity) } } + .transition(.opacity) } } - @ViewBuilder - private func blockView(_ block: AssistantChatStore.Block) -> some View { - switch block.kind { - case .text: - Text(markdown(block.text) + (block.live ? AttributedString(" ▍") : AttributedString())) - .font(.subheadline) - .foregroundStyle(.primary) - .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) - case .tool: - toolChip(block) + private struct Segment: Identifiable { + enum Kind { + case tools([AssistantChatStore.Block]) + case text(AssistantChatStore.Block, Int) } + + let id: UUID + var kind: Kind } - private func toolChip(_ block: AssistantChatStore.Block) -> some View { - HStack(spacing: 8) { - if block.done { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(Tone.emerald.color) + /// Folds consecutive tool calls into one trace, keyed by its first step so + /// a trace keeps its open state as steps stream in. + private static func segments(_ blocks: [AssistantChatStore.Block]) -> [Segment] { + var out: [Segment] = [] + for (index, block) in blocks.enumerated() { + guard block.kind == .tool else { + out.append(Segment(id: block.id, kind: .text(block, index))) + continue + } + if let last = out.indices.last, case var .tools(steps) = out[last].kind { + steps.append(block) + out[last].kind = .tools(steps) } else { - ProgressView().controlSize(.mini) + out.append(Segment(id: block.id, kind: .tools([block]))) } - VStack(alignment: .leading, spacing: 1) { - Text(AgentToolDisplay.label(block.tool)) - .font(.footnote.weight(.semibold)) - .foregroundStyle(.primary) - if let summary = block.argsSummary, !summary.isEmpty { - Text(summary) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - } + } + return out + } + + private func errorRow(_ message: String) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 16)) + .foregroundStyle(WTheme.negative) + VStack(alignment: .leading, spacing: 2) { + Text("Remie couldn't finish") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(WTheme.ink) + Text(message.prefix(1).uppercased() + message.dropFirst()) + .font(.system(size: 14)) + .foregroundStyle(WTheme.inkSecondary) + .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 0) } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .transition(.move(edge: .bottom).combined(with: .opacity)) + .padding(14) + .background(WTheme.negative.opacity(0.07), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .transition(.opacity.combined(with: .offset(y: 6))) } - private func markdown(_ text: String) -> AttributedString { - (try? AttributedString( - markdown: text, - options: AttributedString.MarkdownParsingOptions( - interpretedSyntax: .inlineOnlyPreservingWhitespace - ) - )) ?? AttributedString(text) - } + // MARK: Empty state - // MARK: Approval - - /// A risky tool paused the run: show what it wants to do and let the - /// member approve once, always allow the tool, or deny it. - private func approvalCard(_ pending: AgentPendingTool) -> some View { - let tone: Tone = pending.risk == "high" ? .rose : pending.risk == "medium" ? .amber : .slate - return VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 8) { - Image(systemName: "hand.raised.fill") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(tone.color) - Text("Approval needed · \(AgentToolDisplay.label(pending.toolName))") - .font(.footnote.weight(.semibold)) - Spacer(minLength: 0) - if let risk = pending.risk, !risk.isEmpty { - Text(risk.uppercased()) - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(tone.color) - .padding(.horizontal, 7) - .padding(.vertical, 2.5) - .background(tone.background, in: Capsule()) + private var emptyState: some View { + ScrollView { + VStack(spacing: 0) { + ZStack { + Circle() + .fill(RadialGradient( + colors: [Remie.sky500.opacity(0.18), Remie.sky500.opacity(0)], + center: .center, + startRadius: 0, + endRadius: 110 + )) + .frame(width: 220, height: 220) + .scaleEffect(appeared ? 1 : 0.6) + RemieMark(size: 76, boop: boop, tappable: true) + .scaleEffect(appeared ? 1 : 0.7) } + .frame(height: 170) + Text("Hi, I'm Remie") + .displayFont(30, weight: .semibold) + .foregroundStyle(WTheme.ink) + Text("I look into your outreach and fix things for you. I always ask before changing anything.") + .font(.system(size: 16)) + .lineSpacing(3) + .multilineTextAlignment(.center) + .foregroundStyle(WTheme.inkSecondary) + .frame(maxWidth: 310) + .padding(.top, 10) } - if let summary = pending.argsSummary, !summary.isEmpty { - Text(summary) - .font(.footnote) - .foregroundStyle(.secondary) - } - HStack(spacing: 8) { - Button { - chat.decide(env.api, decision: "approve") - } label: { - Text("Approve") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.white) - .padding(.horizontal, 14) - .padding(.vertical, 7) - .background(WTheme.accent, in: Capsule()) - } - .buttonStyle(TapScaleStyle()) - Button { - chat.decide(env.api, decision: "always_allow") - } label: { - Text("Always allow") - .font(.footnote.weight(.semibold)) - .foregroundStyle(WTheme.accent) - .padding(.horizontal, 14) - .padding(.vertical, 7) - .background(Tone.sky.background, in: Capsule()) - } - .buttonStyle(TapScaleStyle()) - Button { - chat.decide(env.api, decision: "deny") - } label: { - Text("Deny") - .font(.footnote.weight(.semibold)) - .foregroundStyle(WTheme.negative) - .padding(.horizontal, 14) - .padding(.vertical, 7) - .background(Tone.rose.background, in: Capsule()) - } - .buttonStyle(TapScaleStyle()) - Spacer(minLength: 0) - } + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 12) + .padding(.horizontal, 24) + .frame(maxWidth: .infinity) + .containerRelativeFrame(.vertical, alignment: .center) { length, _ in length } } - .padding(12) - .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - .padding(.horizontal, 12) - .padding(.bottom, 6) - .transition(.move(edge: .bottom).combined(with: .opacity)) + .scrollBounceBehavior(.basedOnSize) + .scrollDismissesKeyboard(.interactively) } - private func errorBanner(_ error: String) -> some View { - Text(error) - .font(.footnote) - .foregroundStyle(WTheme.negative) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.vertical, 6) - } - - // MARK: Input - - private var inputBar: some View { - VStack(spacing: 0) { - Divider() + /// Swipeable starters above the composer: the Advisor's open problems when + /// there are any, else everyday asks. + private var starterCards: some View { + ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { - TextField("Ask anything…", text: $input, axis: .vertical) - .font(.subheadline) - .lineLimit(1 ... 4) - .focused($inputFocused) - .padding(.horizontal, 12) - .padding(.vertical, 9) - .background( - Color(.secondarySystemBackground), - in: RoundedRectangle(cornerRadius: 18, style: .continuous) + ForEach(Array(starters.enumerated()), id: \.element.id) { index, starter in + Button { + send(starter.prompt) + } label: { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 7) { + if let dot = starter.dot { + Circle().fill(dot).frame(width: 7, height: 7) + } + Text(starter.title) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(WTheme.ink) + .lineLimit(1) + } + Text(starter.detail) + .font(.system(size: 13.5)) + .foregroundStyle(WTheme.inkSecondary) + .lineLimit(1) + } + .frame(width: 210, alignment: .leading) + .padding(.horizontal, 15) + .padding(.vertical, 12) + .background(WTheme.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + .buttonStyle(TapScaleStyle()) + .opacity(appeared ? 1 : 0) + .offset(x: appeared ? 0 : 24) + .animation( + .spring(response: 0.5, dampingFraction: 0.86).delay(0.12 + Double(index) * 0.06), + value: appeared ) - if chat.isStreaming { - Button { - chat.stop() - } label: { - Image(systemName: "stop.circle.fill") - .font(.system(size: 28)) - .foregroundStyle(WTheme.negative) - } - .accessibilityLabel("Stop") - } else { - Button { - let text = input - input = "" - chat.send(env.api, text: text, page: page) - } label: { - Image(systemName: "arrow.up.circle.fill") - .font(.system(size: 28)) - .foregroundStyle( - input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? Color(.systemGray3) : WTheme.accent - ) - } - .disabled(input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || chat.pending != nil) - .accessibilityLabel("Send") } } - .padding(.horizontal, 12) - .padding(.vertical, 8) - if let footnote = inputFootnote { - Text(footnote) - .font(.caption2) - .foregroundStyle(.tertiary) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.bottom, 6) - } + .padding(.horizontal, 16) } - .background(.bar) + .scrollClipDisabled() + .animation(.easeOut(duration: 0.25), value: suggestions.count) } - private var inputFootnote: String? { - if chat.freeModel { return "Free model · no credits charged" } - if let credits = chat.creditsRemaining { return "\(WFormat.compact(credits)) credits left" } - return nil + private func loadSuggestions() async { + guard env.session.can(.viewAnalytics) else { return } + struct Page: Decodable { var data: [RemieAdvisorFinding]? } + guard let page: Page = try? await env.api.get("advisor/recommendations", query: ["limit": "50"]) else { return } + withAnimation(.easeOut(duration: 0.2)) { + suggestions = RemieSuggestion.from(page.data ?? []) + } + } + + // MARK: Composer + + private var canSend: Bool { + !input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && chat.pending == nil && !chat.isStreaming + } + + private func send(_ text: String) { + guard chat.pending == nil, !chat.isStreaming else { return } + sent += 1 + chat.send(env.api, text: text, page: page) + } + + private var bottomBar: some View { + VStack(spacing: 10) { + if chat.turns.isEmpty, !chat.isStreaming, !chat.isLoadingTranscript { + starterCards + .transition(.opacity.combined(with: .offset(y: 8))) + } + composer + .padding(.horizontal, 12) + footnote + .padding(.horizontal, 20) + } + .padding(.top, 8) + .padding(.bottom, 4) + .animation(.spring(response: 0.4, dampingFraction: 0.88), value: chat.turns.isEmpty) + } + + private var composer: some View { + HStack(alignment: .bottom, spacing: 10) { + TextField( + chat.pending != nil ? "Answer the approval first" : "Ask Remie anything", + text: $input, + axis: .vertical + ) + .font(.system(size: 16.5)) + .foregroundStyle(WTheme.ink) + .lineLimit(1 ... 6) + .focused($inputFocused) + .disabled(chat.pending != nil) + .padding(.vertical, 11) + .onChange(of: input) { old, new in + if new.count > old.count { boop += 1 } + } + + composerButton + .padding(.bottom, 6) + } + .padding(.leading, 18) + .padding(.trailing, 6) + .glassEffect(.regular.interactive(), in: RoundedRectangle(cornerRadius: 26, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: 26, style: .continuous)) + .onTapGesture { inputFocused = true } + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: chat.isStreaming) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: canSend) + } + + @ViewBuilder + private var composerButton: some View { + if chat.isStreaming { + Button { + chat.stop() + } label: { + Image(systemName: "square.fill") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(WTheme.inkInverse) + .frame(width: 36, height: 36) + .background(WTheme.ink, in: Circle()) + } + .buttonStyle(TapScaleStyle()) + .accessibilityLabel("Stop") + .transition(.scale(scale: 0.5).combined(with: .opacity)) + } else { + Button { + let text = input + input = "" + send(text) + } label: { + Image(systemName: "arrow.up") + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(canSend ? WTheme.inkInverse : WTheme.inkTertiary) + .frame(width: 36, height: 36) + .background(canSend ? WTheme.ink : WTheme.fill, in: Circle()) + } + .buttonStyle(TapScaleStyle()) + .disabled(!canSend) + .accessibilityLabel("Send") + .transition(.scale(scale: 0.5).combined(with: .opacity)) + } + } + + private var footnote: some View { + HStack(spacing: 6) { + if chat.freeModel { + Text("Free local model, nothing is charged") + } else { + Text("Reads run on their own. Changes ask you first.") + if let credits = chat.creditsRemaining { + Text("·") + Text("\(credits.formatted()) credits") + .monospacedDigit() + .contentTransition(.numericText()) + } + } + } + .font(.system(size: 11.5)) + .foregroundStyle(WTheme.inkTertiary) + .lineLimit(1) + .minimumScaleFactor(0.85) + .frame(maxWidth: .infinity) } } // MARK: - History -/// Conversation history sheet: grouped sections, title search, swipe delete, -/// and Clear history — the mobile mirror of the web history rail. +/// Conversation history sheet: grouped hairline rows, title search, swipe +/// delete, and Clear history. The mobile mirror of the web history rail. struct AssistantHistoryView: View { @Environment(AppEnvironment.self) private var env @Environment(\.dismiss) private var dismiss @@ -397,6 +580,7 @@ struct AssistantHistoryView: View { Group { if store.isLoading, !store.hasLoaded { ProgressView() + .tint(WTheme.inkSecondary) .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error = store.errorMessage { EmptyStateView(title: "Couldn't load history", message: error) @@ -405,7 +589,8 @@ struct AssistantHistoryView: View { title: store.query.isEmpty ? "No conversations yet" : "No matches", message: store.query.isEmpty ? "Conversations with the assistant show up here." - : "No conversation matches \"\(store.query)\"." + : "No conversation matches \"\(store.query)\".", + symbol: "sparkle" ) } else { List { @@ -413,9 +598,17 @@ struct AssistantHistoryView: View { Section { ForEach(group.sessions) { session in row(session) + .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) + .listRowBackground(WTheme.canvas) + .listRowSeparatorTint(WTheme.hairline) } } header: { EyebrowLabel(group.title) + .padding(.top, 10) + .padding(.bottom, 2) + .frame(maxWidth: .infinity, alignment: .leading) + .background(WTheme.canvas) + .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) } } if store.hasMore { @@ -424,14 +617,20 @@ struct AssistantHistoryView: View { ProgressView().controlSize(.small) Spacer() } + .listRowBackground(WTheme.canvas) + .listRowSeparator(.hidden) .onAppear { Task { await store.loadMore(env.api) } } } } .listStyle(.plain) + .scrollContentBackground(.hidden) + .environment(\.defaultMinListRowHeight, 0) } } + .background(WTheme.canvas) .navigationTitle("History") .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(WTheme.canvas, for: .navigationBar) .searchable( text: $store.query, placement: .navigationBarDrawer(displayMode: .always), @@ -464,8 +663,10 @@ struct AssistantHistoryView: View { } } } + .tint(WTheme.ink) .presentationDetents([.large]) .presentationDragIndicator(.visible) + .presentationBackground(WTheme.canvas) .task { await store.load(env.api) } .onChange(of: env.realtime.pulse(for: .ai)) { Task { await store.load(env.api) } @@ -477,34 +678,39 @@ struct AssistantHistoryView: View { onSelect(session) } label: { HStack(spacing: 10) { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 4) { Text(session.displayTitle) - .font(.subheadline.weight(.medium)) - .foregroundStyle(.primary) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(WTheme.ink) .lineLimit(1) HStack(spacing: 6) { // Owner attribution appears only with shared history on. if let owner = session.userName, !owner.isEmpty { Text(owner) - .font(.caption) - .foregroundStyle(.secondary) + .font(.system(size: 12.5)) + .foregroundStyle(WTheme.inkSecondary) + .lineLimit(1) } Text(UniboxFormat.listTime(session.updatedAt ?? session.createdAt)) - .font(.caption) - .monospacedDigit() - .foregroundStyle(.tertiary) + .font(WFont.meta(11.5)) + .foregroundStyle(WTheme.inkTertiary) } } Spacer(minLength: 8) if session.context?.pending != nil { - Image(systemName: "hand.raised.fill") - .font(.system(size: 12, weight: .semibold)) + Image(systemName: "hand.raised") + .font(.system(size: 12, weight: .medium)) .foregroundStyle(Tone.amber.color) .accessibilityLabel("Waiting for approval") } + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(WTheme.inkTertiary) } - .padding(.vertical, 2) + .padding(.vertical, 12) + .contentShape(Rectangle()) } + .buttonStyle(.plain) .swipeActions(edge: .trailing) { Button(role: .destructive) { Task { await store.delete(env.api, id: session.id) } diff --git a/ios/Warmbly/Features/Assistant/RemieMark.swift b/ios/Warmbly/Features/Assistant/RemieMark.swift new file mode 100644 index 000000000..083cb02dc --- /dev/null +++ b/ios/Warmbly/Features/Assistant/RemieMark.swift @@ -0,0 +1,362 @@ +import SwiftUI + +/// Remie's palette, matching the web panel. +enum Remie { + static let sky500 = Color(hex: 0x0EA5E9) + static let sky600 = Color(hex: 0x0284C7) + static let emerald = Color(hex: 0x10B981) + static let amber = Color.dynamic(light: 0xB45309, dark: 0xFBBF24) + static let amberTint = Color.dynamic(light: 0xFFFBEB, dark: 0x2A2010) + static let amberLine = Color.dynamic(light: 0xFDE68A, dark: 0x4A3713) +} + +enum RemieMarkState: Equatable { + case idle, thinking, attention +} + +/// What the mark shows: the run state plus the done and muted looks. +enum RemieLook: Equatable { + case idle, thinking, attention, done, muted + + var colors: (Color, Color, Color) { + switch self { + case .idle, .thinking: (Color(hex: 0x7DD3FC), Color(hex: 0x0EA5E9), Color(hex: 0x2563EB)) + case .attention: (Color(hex: 0xFCD34D), Color(hex: 0xF59E0B), Color(hex: 0xEA580C)) + case .done: (Color(hex: 0x6EE7B7), Color(hex: 0x10B981), Color(hex: 0x0D9488)) + case .muted: ( + Color.dynamic(light: 0xE2E8F0, dark: 0x62666D), + Color.dynamic(light: 0x94A3B8, dark: 0x35373C), + Color.dynamic(light: 0xCBD5E1, dark: 0x26272B) + ) + } + } + + var tint: Color { + switch self { + case .idle, .thinking: Color(hex: 0x0EA5E9) + case .attention: Color(hex: 0xF59E0B) + case .done: Color(hex: 0x10B981) + case .muted: Color.dynamic(light: 0x94A3B8, dark: 0x62666D) + } + } + + /// Seconds per blob breath and per swirl turn. + var periods: (body: Double, swirl: Double) { + switch self { + case .thinking: (1.6, 1.2) + case .muted: (9, 7) + default: (5, 7) + } + } +} + +/// Integrates phase across frames so a speed change never jumps, and fades +/// colors between looks. +private final class RemieClock { + var last: Date? + var time: Double = 0 + var bodyPhase: Double = 0 + var swirlPhase: Double = 0 + var look: RemieLook? + var previous: RemieLook? + var changedAt: Double = -10 + + func advance(to date: Date, look target: RemieLook) { + let dt = last.map { min(0.1, max(0, date.timeIntervalSince($0))) } ?? 0 + last = date + time += dt + let periods = target.periods + bodyPhase += dt / periods.body + swirlPhase += dt / periods.swirl + if look != target { + previous = look ?? target + look = target + changedAt = time + } + } + + func colors() -> (Color, Color, Color) { + guard let look else { return RemieLook.idle.colors } + let to = look.colors + guard let previous, previous != look else { return to } + let p = min(1, (time - changedAt) / 0.6) + guard p < 1 else { return to } + let from = previous.colors + let e = p * p * (3 - 2 * p) + return (from.0.mix(with: to.0, by: e), from.1.mix(with: to.1, by: e), from.2.mix(with: to.2, by: e)) + } +} + +/// The breathing outline: a circle whose radius ripples with the phase. +private struct BlobShape: Shape { + var phase: Double + + func path(in rect: CGRect) -> Path { + let a = phase * 2 * .pi + let sx = 1 + 0.035 * sin(a) + let sy = 1 - 0.03 * sin(a) + let center = CGPoint(x: rect.midX, y: rect.midY) + let base = min(rect.width, rect.height) / 2 / 1.06 + var path = Path() + let steps = 72 + for i in 0 ... steps { + let t = Double(i) / Double(steps) * 2 * .pi + let r = base * (1 + 0.045 * sin(2 * t + a) + 0.028 * sin(3 * t - a + 1)) + let p = CGPoint(x: center.x + cos(t) * r * sx, y: center.y + sin(t) * r * sy) + if i == 0 { path.move(to: p) } else { path.addLine(to: p) } + } + path.closeSubpath() + return path + } +} + +private struct Squash: Equatable { + var x: CGFloat = 1 + var y: CGFloat = 1 + var lift: CGFloat = 0 +} + +/// Remie's icon: a soft blob with light swirling inside. It breathes at rest, +/// swirls faster while a run works, hops in amber while a run waits on +/// approval, bounces in green when a run finishes, and boops on `boop`. +struct RemieMark: View { + var size: CGFloat = 20 + var state: RemieMarkState = .idle + var muted = false + /// Whether a run ending now succeeded; only then does the mark celebrate. + var celebrate = true + /// Bumped on composer keystrokes. + var boop = 0 + /// Jiggles when poked. Off inside a button, where the tap belongs to the button. + var tappable = false + + @State private var clock = RemieClock() + @State private var justFinished = false + @State private var jelly = 0 + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var look: RemieLook { + if justFinished && state == .idle { return .done } + if muted && state == .idle { return .muted } + switch state { + case .idle: return .idle + case .thinking: return .thinking + case .attention: return .attention + } + } + + var body: some View { + TimelineView(.animation(paused: reduceMotion && clock.look == look)) { context in + let _ = clock.advance(to: context.date, look: look) + let colors = clock.colors() + ZStack { + ZStack { + AngularGradient( + colors: [colors.0, colors.1, colors.2, colors.0], + center: .center, + angle: .degrees(reduceMotion ? 0 : clock.swirlPhase * 360) + ) + .frame(width: size * 1.6, height: size * 1.6) + .blur(radius: size * 0.07) + } + .frame(width: size * 0.88, height: size * 0.88) + .clipShape(BlobShape(phase: reduceMotion ? 0 : clock.bodyPhase)) + .shadow(color: colors.1.opacity(0.55), radius: size * 0.12, y: size * 0.08) + + Ellipse() + .fill(RadialGradient( + colors: [.white.opacity(0.9), .white.opacity(0)], + center: .center, + startRadius: 0, + endRadius: size * 0.16 + )) + .frame(width: size * 0.32, height: size * 0.22) + .rotationEffect(.degrees(-28)) + .opacity(look == .muted ? 0.6 : 1) + .position(x: size * 0.38, y: size * 0.27) + } + .frame(width: size, height: size) + .offset(y: look == .attention && !reduceMotion ? -hop(clock.time) * size : 0) + } + .frame(width: size, height: size) + .keyframeAnimator(initialValue: Squash(), trigger: jelly) { content, s in + content.scaleEffect(x: s.x, y: s.y) + } keyframes: { _ in + KeyframeTrack(\.x) { + SpringKeyframe(1.22, duration: 0.18) + SpringKeyframe(0.9, duration: 0.15) + SpringKeyframe(1.06, duration: 0.12) + SpringKeyframe(1, duration: 0.15) + } + KeyframeTrack(\.y) { + SpringKeyframe(0.84, duration: 0.18) + SpringKeyframe(1.12, duration: 0.15) + SpringKeyframe(0.96, duration: 0.12) + SpringKeyframe(1, duration: 0.15) + } + } + .keyframeAnimator(initialValue: Squash(), trigger: boop) { content, s in + content.scaleEffect(x: s.x, y: s.y) + } keyframes: { _ in + KeyframeTrack(\.x) { + CubicKeyframe(1.16, duration: 0.11) + CubicKeyframe(1, duration: 0.17) + } + KeyframeTrack(\.y) { + CubicKeyframe(0.88, duration: 0.11) + CubicKeyframe(1, duration: 0.17) + } + } + .keyframeAnimator(initialValue: Squash(), trigger: justFinished) { content, s in + content + .scaleEffect(x: s.x, y: s.y, anchor: .bottom) + .offset(y: -s.lift * size) + } keyframes: { _ in + KeyframeTrack(\.x) { + SpringKeyframe(1.18, duration: 0.21) + SpringKeyframe(0.94, duration: 0.18) + SpringKeyframe(1, duration: 0.31) + } + KeyframeTrack(\.y) { + SpringKeyframe(0.88, duration: 0.21) + SpringKeyframe(1.1, duration: 0.18) + SpringKeyframe(1, duration: 0.31) + } + KeyframeTrack(\.lift) { + SpringKeyframe(0, duration: 0.21) + SpringKeyframe(0.14, duration: 0.18) + SpringKeyframe(0, duration: 0.31) + } + } + .contentShape(Circle()) + .simultaneousGesture(TapGesture().onEnded { if !reduceMotion { jelly += 1 } }, isEnabled: tappable) + .onChange(of: state) { old, new in + guard old == .thinking, new == .idle, celebrate else { return } + justFinished = true + Task { + try? await Task.sleep(for: .milliseconds(1400)) + justFinished = false + } + } + .accessibilityHidden(true) + } + + /// The amber hop, as a fraction of the mark's size. + private func hop(_ t: Double) -> CGFloat { + let f = t.truncatingRemainder(dividingBy: 1.4) / 1.4 + func ease(_ x: Double) -> Double { x * x * (3 - 2 * x) } + switch f { + case ..<0.18: return 0.16 * ease(f / 0.18) + case ..<0.32: return 0.16 * (1 - ease((f - 0.18) / 0.14)) + case ..<0.42: return 0.06 * ease((f - 0.32) / 0.10) + case ..<0.55: return 0.06 * (1 - ease((f - 0.42) / 0.13)) + default: return 0 + } + } +} + +/// The bare pixel spark, for inline loaders: a light chases around the ring +/// while thinking and the spark pulses while waiting on approval. +struct RemieSpark: View { + var size: CGFloat = 16 + var state: RemieMarkState = .idle + var muted = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private static let base: [Double] = [0, 0.62, 0, 0.62, 1, 0.62, 0, 0.62, 0] + /// The outer ring, clockwise from the top left. + private static let ring = [0, 1, 2, 5, 8, 7, 6, 3] + + private var look: RemieLook { + if muted && state == .idle { return .muted } + switch state { + case .idle: return .idle + case .thinking: return .thinking + case .attention: return .attention + } + } + + var body: some View { + let animated = state != .idle && !reduceMotion + TimelineView(.animation(paused: !animated)) { context in + grid(time: context.date.timeIntervalSinceReferenceDate) + } + .frame(width: size, height: size) + .animation(.easeOut(duration: 0.22), value: state) + .accessibilityHidden(true) + } + + private func grid(time: Double) -> some View { + let cell = size * 0.26 + let gap = max(0.5, size * 0.1) + return VStack(spacing: gap) { + ForEach(0 ..< 3, id: \.self) { row in + HStack(spacing: gap) { + ForEach(0 ..< 3, id: \.self) { col in + let i = row * 3 + col + RoundedRectangle(cornerRadius: cell * 0.24, style: .continuous) + .fill(look.tint) + .frame(width: cell, height: cell) + .opacity(opacity(i, time: time)) + } + } + } + } + } + + private func opacity(_ i: Int, time: Double) -> Double { + let o = Self.base[i] + guard !reduceMotion else { return o } + switch state { + case .thinking: + guard let r = Self.ring.firstIndex(of: i) else { return 1 } + var f = (time - Double(r) * 0.11).truncatingRemainder(dividingBy: 0.88) / 0.88 + if f < 0 { f += 1 } + if f < 0.12 { return 0.2 + 0.8 * (f / 0.12) } + if f < 0.45 { return 1 - 0.8 * ((f - 0.12) / 0.33) } + return 0.2 + case .attention: + guard o > 0 else { return 0 } + let wave = 0.5 - 0.5 * cos(2 * .pi * time / 1.2) + return o * (1 - 0.65 * wave) + case .idle: + return o + } + } +} + +/// Live status text: a sky light sweeping across slate, like the web's +/// `ai-shimmer-text`. +struct RemieShimmer: ViewModifier { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func body(content: Content) -> some View { + if reduceMotion { + content.foregroundStyle(WTheme.inkSecondary) + } else { + content + .foregroundStyle(WTheme.inkSecondary) + .overlay { + TimelineView(.animation) { context in + let t = context.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 1.8) / 1.8 + GeometryReader { proxy in + let w = proxy.size.width + LinearGradient( + colors: [Remie.sky500.opacity(0), Remie.sky500, Remie.sky500.opacity(0)], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: max(24, w * 0.45)) + .offset(x: -max(24, w * 0.45) + (w + max(24, w * 0.45)) * t) + } + } + .mask(content) + .allowsHitTesting(false) + } + } + } +} + +extension View { + func remieShimmer() -> some View { modifier(RemieShimmer()) } +} diff --git a/ios/Warmbly/Features/Assistant/RemieTranscript.swift b/ios/Warmbly/Features/Assistant/RemieTranscript.swift new file mode 100644 index 000000000..f43a971e9 --- /dev/null +++ b/ios/Warmbly/Features/Assistant/RemieTranscript.swift @@ -0,0 +1,683 @@ +import SwiftUI + +// The pieces of a Remie conversation, ported from the web agent panel +// (AgentPanel, AgentActivity, Markdown) so both read the same. + +// MARK: - Tool labels + +/// Human labels for tool calls in two tenses, derived from the tool name like +/// the web's toolLabels.ts. +enum RemieTool { + private static let verbs: [String: (String, String)] = [ + "add": ("Adding", "Added"), "bulk": ("Editing", "Edited"), + "cancel": ("Canceling", "Canceled"), "complete": ("Completing", "Completed"), + "compose": ("Composing", "Composed"), "create": ("Creating", "Created"), + "delete": ("Deleting", "Deleted"), "disconnect": ("Disconnecting", "Disconnected"), + "draft": ("Drafting", "Drafted"), "fetch": ("Fetching", "Fetched"), + "get": ("Reading", "Read"), "invite": ("Inviting", "Invited"), + "list": ("Listing", "Listed"), "load": ("Loading", "Loaded"), + "mark": ("Marking", "Marked"), "mint": ("Creating", "Created"), + "move": ("Moving", "Moved"), "preview": ("Previewing", "Previewed"), + "remove": ("Removing", "Removed"), "revoke": ("Revoking", "Revoked"), + "rotate": ("Rotating", "Rotated"), "run": ("Running", "Ran"), + "search": ("Searching", "Searched"), "send": ("Sending", "Sent"), + "set": ("Updating", "Updated"), "snooze": ("Snoozing", "Snoozed"), + "submit": ("Submitting", "Submitted"), "unsnooze": ("Unsnoozing", "Unsnoozed"), + "update": ("Updating", "Updated"), "verify": ("Verifying", "Verified"), + ] + + private static let named: [String: (String, String)] = [ + "search_web": ("Searching the web", "Searched the web"), + "fetch_url": ("Reading a web page", "Read a web page"), + "load_skill": ("Loading a skill", "Loaded a skill"), + "get_thread": ("Reading the thread", "Read the thread"), + "list_threads": ("Scanning the inbox", "Scanned the inbox"), + "get_dashboard_analytics": ("Reading analytics", "Read analytics"), + "get_advisor_summary": ("Checking the advisor", "Checked the advisor"), + "bulk_edit_contacts": ("Editing contacts", "Edited contacts"), + "add_tag": ("Adding a label", "Added a label"), + "remove_tag": ("Removing a label", "Removed a label"), + "set_thread_labels": ("Labeling the thread", "Labeled the thread"), + ] + + static func label(_ tool: String?, done: Bool = true) -> String { + guard let tool, !tool.isEmpty else { return done ? "Ran a step" : "Working" } + if let n = named[tool] { return done ? n.1 : n.0 } + let parts = tool.split(separator: "_").map(String.init) + let object = parts.dropFirst().joined(separator: " ") + guard let first = parts.first, let v = verbs[first] else { return action(tool) } + let verb = done ? v.1 : v.0 + return object.isEmpty ? verb : "\(verb) \(object)" + } + + /// The imperative form an approval asks about ("Update contact fields"). + static func action(_ tool: String?) -> String { + let plain = (tool ?? "").replacingOccurrences(of: "_", with: " ") + guard let first = plain.first else { return "Run a tool" } + return first.uppercased() + plain.dropFirst() + } +} + +// MARK: - Markdown + +/// The web's small Markdown: paragraphs, lists, headings and code fences, +/// with inline emphasis, code and links. +struct RemieMarkdown: View { + let text: String + /// Puts the insertion caret after the last paragraph. + var caret = false + /// Text is still being revealed: the caret holds and the newest characters fade in. + var typing = false + + private enum Block { + case para(String) + case heading(String) + case list(ordered: Bool, items: [String]) + case code(String) + } + + var body: some View { + let blocks = Self.parse(text) + VStack(alignment: .leading, spacing: 9) { + ForEach(Array(blocks.enumerated()), id: \.offset) { index, block in + view(block, isLast: index == blocks.count - 1) + } + } + .font(.system(size: 16)) + .lineSpacing(5) + .foregroundStyle(WTheme.ink) + .tint(Remie.sky500) + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private func view(_ block: Block, isLast: Bool) -> some View { + switch block { + case let .para(s): + paragraph(s, bold: false, isLast: isLast) + case let .heading(s): + paragraph(s, bold: true, isLast: isLast) + case let .list(ordered, items): + VStack(alignment: .leading, spacing: 5) { + ForEach(Array(items.enumerated()), id: \.offset) { i, item in + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(ordered ? "\(i + 1)." : "•") + .foregroundStyle(WTheme.inkTertiary) + .monospacedDigit() + Text(Self.inline(item)) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(.leading, 2) + case let .code(s): + ScrollView(.horizontal, showsIndicators: false) { + Text(s) + .font(WFont.code(12.5)) + .lineSpacing(3) + .foregroundStyle(WTheme.ink.opacity(0.85)) + .padding(.horizontal, 11) + .padding(.vertical, 9) + } + .background(WTheme.surface, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous).strokeBorder(WTheme.hairlineStrong, lineWidth: 1)) + } + } + + @ViewBuilder + private func paragraph(_ s: String, bold: Bool, isLast: Bool) -> some View { + var attr = Self.inline(s) + let _ = Self.fadeTail(&attr, when: typing && isLast) + if caret && isLast { + if typing { + Text("\(Text(attr))\(Self.caret(visible: true))") + .fontWeight(bold ? .semibold : nil) + } else { + TimelineView(.periodic(from: .now, by: 0.5)) { context in + let on = Int(context.date.timeIntervalSinceReferenceDate * 2) % 2 == 0 + Text("\(Text(attr))\(Self.caret(visible: on))") + .fontWeight(bold ? .semibold : nil) + } + } + } else { + Text(attr) + .fontWeight(bold ? .semibold : nil) + .textSelection(.enabled) + } + } + + private static func caret(visible: Bool) -> Text { + Text("\u{258F}").foregroundStyle(visible ? Remie.sky500 : .clear) + } + + /// The last few characters of still-typing text resolve in rather than pop. + private static func fadeTail(_ attr: inout AttributedString, when active: Bool) { + guard active else { return } + let count = attr.characters.count + guard count > 0 else { return } + let n = min(6, count) + for k in 0 ..< n { + let start = attr.characters.index(attr.endIndex, offsetBy: -(n - k)) + let end = attr.characters.index(after: start) + attr[start ..< end].foregroundColor = WTheme.ink.opacity(0.25 + 0.6 * Double(k) / Double(n)) + } + } + + private static func inline(_ s: String) -> AttributedString { + var attr = (try? AttributedString( + markdown: s, + options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) + )) ?? AttributedString(s) + // Addresses the parser auto-links read as text, not as mail links. + for run in attr.runs where run.link?.scheme == "mailto" { + attr[run.range].link = nil + } + return attr + } + + private static func parse(_ text: String) -> [Block] { + var out: [Block] = [] + let parts = text.components(separatedBy: "```") + for (pi, part) in parts.enumerated() { + if pi % 2 == 1 { + // Drop the fence's language line. + var body = part + if let nl = body.firstIndex(of: "\n") { body = String(body[body.index(after: nl)...]) } + let trimmed = body.trimmingCharacters(in: .newlines) + if !trimmed.isEmpty { out.append(.code(trimmed)) } + continue + } + var para: [String] = [] + var list: (ordered: Bool, items: [String])? + func flushPara() { + if !para.isEmpty { out.append(.para(para.joined(separator: "\n"))) } + para = [] + } + func flushList() { + if let l = list { out.append(.list(ordered: l.ordered, items: l.items)) } + list = nil + } + for line in part.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if let item = bullet(trimmed) { + flushPara() + if list?.ordered != item.ordered { flushList(); list = (item.ordered, []) } + list?.items.append(item.text) + } else if trimmed.hasPrefix("#") { + let heading = trimmed.drop { $0 == "#" }.trimmingCharacters(in: .whitespaces) + flushPara(); flushList() + if !heading.isEmpty { out.append(.heading(heading)) } + } else if trimmed.isEmpty { + flushPara(); flushList() + } else { + flushList() + para.append(line) + } + } + flushPara(); flushList() + } + return out + } + + private static func bullet(_ line: String) -> (ordered: Bool, text: String)? { + if line.hasPrefix("- ") || line.hasPrefix("* ") { + return (false, String(line.dropFirst(2))) + } + let digits = line.prefix { $0.isNumber } + guard !digits.isEmpty, digits.count <= 3 else { return nil } + let rest = line.dropFirst(digits.count) + guard let mark = rest.first, mark == "." || mark == ")", rest.dropFirst().first == " " else { return nil } + return (true, String(rest.dropFirst(2))) + } +} + +/// Streamed text as a fast typewriter: each frame reveals a twelfth of what +/// is still hidden, so bursts type out at a steady pace. +struct RemieSmoothText: View { + let text: String + @State private var shown = 0 + + var body: some View { + let total = text.count + RemieMarkdown(text: String(text.prefix(shown)), caret: true, typing: shown < total) + .task(id: text) { + while !Task.isCancelled { + let total = text.count + guard shown < total else { break } + shown = min(total, shown + max(1, Int((Double(total - shown) / 12).rounded(.up)))) + try? await Task.sleep(for: .milliseconds(16)) + } + } + } +} + +// MARK: - Activity + +/// A run's tool calls folded into one trace: the step running now while it +/// works, "Ran N steps" after, and every step under a rail when open. +struct RemieActivityTrace: View { + let steps: [AssistantChatStore.Block] + let live: Bool + let streaming: Bool + + @State private var userOpen: Bool? + + private var running: Bool { streaming && steps.contains { !$0.done } } + private var multi: Bool { steps.count > 1 } + private var open: Bool { !multi || (userOpen ?? (running || live)) } + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + if multi { header } + if open { + VStack(alignment: .leading, spacing: 0) { + ForEach(steps) { step in + RemieStepLine(step: step, streaming: streaming) + } + } + .padding(.leading, multi ? 15 : 0) + .overlay(alignment: .leading) { + if multi { + Rectangle() + .fill(WTheme.hairlineStrong) + .frame(width: 1) + .padding(.top, 4) + .padding(.bottom, 8) + .padding(.leading, 6.5) + } + } + .transition(.asymmetric( + insertion: .opacity.combined(with: .offset(y: -4)), + removal: .opacity + )) + } + } + .animation(.spring(response: 0.36, dampingFraction: 0.9), value: open) + } + + private var header: some View { + let current = steps.first { !$0.done } + let doneCount = steps.filter(\.done).count + let summary = steps.reduce(into: [String]()) { names, step in + let name = RemieTool.label(step.tool) + if !names.contains(name) { names.append(name) } + }.joined(separator: ", ") + return Button { + userOpen = !open + } label: { + HStack(spacing: 8) { + RemieSpark(size: 14, state: running ? .thinking : .idle, muted: !running) + if running, let current { + Text(RemieTool.label(current.tool, done: false)) + .font(.system(size: 13.5, weight: .medium)) + .remieShimmer() + .lineLimit(1) + .layoutPriority(1) + Text("\(doneCount)/\(steps.count)") + .font(WFont.meta(11.5)) + .foregroundStyle(WTheme.inkTertiary) + .contentTransition(.numericText()) + } else { + Text("Ran \(steps.count) steps") + .font(.system(size: 13.5, weight: .medium)) + .foregroundStyle(WTheme.inkSecondary) + .layoutPriority(1) + if !open { + Text(summary) + .font(.system(size: 12.5)) + .foregroundStyle(WTheme.inkTertiary) + .lineLimit(1) + } + } + Image(systemName: "chevron.down") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(WTheme.inkTertiary) + .rotationEffect(.degrees(open ? 180 : 0)) + } + .frame(minHeight: 30) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityValue(open ? "Expanded" : "Collapsed") + } +} + +private struct RemieStepLine: View { + let step: AssistantChatStore.Block + let streaming: Bool + + var body: some View { + let detail = [step.result, step.argsSummary].compactMap { $0 }.first { !$0.isEmpty } + HStack(spacing: 8) { + status + .frame(width: 14, height: 14) + Text(RemieTool.label(step.tool, done: step.done || !streaming)) + .font(.system(size: 13.5, weight: .medium)) + .lineLimit(1) + .layoutPriority(1) + .modifier(LabelStyle(active: !step.done && streaming)) + if let detail { + Text(detail) + .font(.system(size: 12.5)) + .foregroundStyle(WTheme.inkTertiary) + .lineLimit(1) + } + } + .frame(minHeight: 30) + .transition(.opacity.combined(with: .offset(y: 6))) + } + + @ViewBuilder + private var status: some View { + if step.done { + Image(systemName: "checkmark") + .font(.system(size: 7.5, weight: .heavy)) + .foregroundStyle(Remie.emerald) + .frame(width: 14, height: 14) + .background(Remie.emerald.opacity(0.14), in: Circle()) + .transition(.scale(scale: 0.5).combined(with: .opacity)) + } else if streaming { + RemieSpinner() + } else { + Image(systemName: "xmark") + .font(.system(size: 7.5, weight: .heavy)) + .foregroundStyle(WTheme.inkTertiary) + .frame(width: 14, height: 14) + .background(WTheme.fill, in: Circle()) + } + } + + private struct LabelStyle: ViewModifier { + let active: Bool + func body(content: Content) -> some View { + if active { + content.remieShimmer() + } else { + content.foregroundStyle(WTheme.ink.opacity(0.8)) + } + } + } +} + +/// A slate ring with a sky arc turning on it. +struct RemieSpinner: View { + var size: CGFloat = 14 + + var body: some View { + TimelineView(.animation) { context in + let angle = context.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 0.8) / 0.8 * 360 + ZStack { + Circle().stroke(WTheme.hairlineStrong, lineWidth: 1.5) + Circle() + .trim(from: 0, to: 0.25) + .stroke(Remie.sky500, style: StrokeStyle(lineWidth: 1.5, lineCap: .round)) + .rotationEffect(.degrees(angle)) + } + } + .frame(width: size - 1.5, height: size - 1.5) + } +} + +/// The live run's status line: the mark at work, a shimmering phase, the +/// elapsed time and the step count. +struct RemieWorkingStatus: View { + let label: String + var since: Date? + var step = 0 + var budget = 0 + + var body: some View { + HStack(spacing: 10) { + RemieMark(size: 20, state: .thinking) + Text(label) + .font(.system(size: 15, weight: .medium)) + .remieShimmer() + .id(label) + .transition(.asymmetric( + insertion: .opacity.combined(with: .offset(y: 4)), + removal: .opacity.combined(with: .offset(y: -4)) + )) + if let since { + TimelineView(.periodic(from: .now, by: 0.1)) { context in + Text(Self.elapsed(context.date.timeIntervalSince(since))) + .font(WFont.meta(12)) + .foregroundStyle(WTheme.inkTertiary) + .monospacedDigit() + } + } + if step > 0, budget > 0 { + Text("step \(step) of \(budget)") + .font(WFont.meta(11.5)) + .foregroundStyle(WTheme.inkTertiary.opacity(0.75)) + .contentTransition(.numericText()) + } + } + .animation(.easeOut(duration: 0.18), value: label) + .accessibilityElement(children: .combine) + } + + static func elapsed(_ seconds: Double) -> String { + let s = max(0, seconds) + if s < 60 { return String(format: "%.1fs", s) } + return "\(Int(s) / 60)m \(Int(s) % 60)s" + } +} + +/// Under a finished answer: copy it. +struct RemieTurnActions: View { + let text: String + @State private var copied = false + + var body: some View { + HStack(spacing: 2) { + Button { + UIPasteboard.general.string = text + withAnimation(.snappy) { copied = true } + Task { + try? await Task.sleep(for: .seconds(1.6)) + withAnimation(.snappy) { copied = false } + } + } label: { + Image(systemName: copied ? "checkmark" : "square.on.square") + .font(.system(size: 13.5, weight: .medium)) + .foregroundStyle(copied ? Remie.emerald : WTheme.inkTertiary) + .contentTransition(.symbolEffect(.replace)) + .frame(width: 34, height: 30) + .contentShape(Rectangle()) + } + .buttonStyle(TapScaleStyle()) + .accessibilityLabel(copied ? "Copied" : "Copy answer") + .sensoryFeedback(.success, trigger: copied) { _, now in now } + } + .padding(.leading, -9) + } +} + +// MARK: - Approval + +/// A risky tool paused the run. Built for a thumb: what Remie wants to do, +/// the exact arguments, and full-width answers. +struct RemieApprovalCard: View { + let pending: AgentPendingTool + let onDecide: (String) -> Void + + private var isSend: Bool { pending.risk == "send" } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + RemieSpark(size: 14, state: .attention) + Text(isSend ? "Ready to send" : "Needs your approval") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Remie.amber) + Spacer(minLength: 6) + Text(isSend ? "Sends email" : "Changes data") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Remie.amber) + .padding(.horizontal, 9) + .frame(height: 24) + .background(Remie.amber.opacity(0.12), in: Capsule()) + } + + Text(RemieTool.action(pending.toolName)) + .font(.system(size: 19, weight: .semibold)) + .foregroundStyle(WTheme.ink) + .padding(.top, 12) + + if let summary = pending.argsSummary, !summary.isEmpty { + Text(summary) + .font(WFont.code(13)) + .lineSpacing(3) + .foregroundStyle(WTheme.inkSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(WTheme.canvas.opacity(0.7), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .textSelection(.enabled) + .padding(.top, 12) + } + + Button { + onDecide("approve") + } label: { + HStack(spacing: 7) { + Image(systemName: isSend ? "paperplane.fill" : "checkmark") + .font(.system(size: 13, weight: .bold)) + Text(isSend ? "Send" : "Approve") + .font(.system(size: 16, weight: .semibold)) + } + .foregroundStyle(WTheme.inkInverse) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background(WTheme.ink, in: Capsule()) + } + .buttonStyle(TapScaleStyle()) + .padding(.top, 16) + + HStack(spacing: 10) { + Button { + onDecide("deny") + } label: { + Text("Skip") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(WTheme.ink) + .frame(maxWidth: .infinity) + .frame(height: 46) + .background(WTheme.fill, in: Capsule()) + } + .buttonStyle(TapScaleStyle()) + if !isSend { + Button { + onDecide("always_allow") + } label: { + Text("Always allow") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(WTheme.ink) + .frame(maxWidth: .infinity) + .frame(height: 46) + .background(WTheme.fill, in: Capsule()) + } + .buttonStyle(TapScaleStyle()) + .accessibilityHint("Run this kind of action without asking, for everyone in the workspace") + } + } + .padding(.top, 8) + } + .padding(16) + .background(WTheme.surface, in: RoundedRectangle(cornerRadius: 26, style: .continuous)) + .sensoryFeedback(.warning, trigger: pending.toolCallID) + } +} + +// MARK: - Suggestions + +/// `GET /advisor/recommendations`, only the fields Remie suggests from. +struct RemieAdvisorFinding: Decodable, Sendable { + var id: String + var detectorKey: String? + var severity: String? + var status: String? + var impact: Double? + var title: String? + var groupTitle: String? + var remedy: String? + var action: Action? + + struct Action: Decodable, Sendable { + var label: String? + } + + enum CodingKeys: String, CodingKey { + case id, severity, status, impact, title, remedy, action + case detectorKey = "detector_key" + case groupTitle = "group_title" + } + + var rank: Int { + switch severity { + case "critical": 4 + case "high": 3 + case "medium": 2 + default: 1 + } + } +} + +/// One thing Remie suggests: the Advisor's grouping of open findings. +struct RemieSuggestion: Identifiable { + let id: String + let text: String + let severity: String + let findings: [RemieAdvisorFinding] + + var dot: Color { + switch severity { + case "critical": Color(hex: 0xF43F5E) + case "high": Color(hex: 0xF97316) + case "medium": Color(hex: 0xF59E0B) + default: Remie.sky500 + } + } + + /// The same ask the web sends when a suggestion is picked. + var prompt: String { + let lines = findings.enumerated().map { i, f in + "\(i + 1). \(f.title ?? ""). Recommended: \(f.action?.label ?? f.remedy ?? "")" + } + return ([findings.count > 1 ? "Please fix these:" : "Please fix this:"] + lines + [ + "Check the current state first, make the smallest change that resolves each one, and ask me before changing anything.", + ]).joined(separator: "\n") + } + + static func from(_ findings: [RemieAdvisorFinding]) -> [RemieSuggestion] { + let open = findings.filter { $0.status == "open" && $0.rank >= 2 } + var order: [String] = [] + var byKey: [String: [RemieAdvisorFinding]] = [:] + for f in open { + let key = f.detectorKey ?? f.id + if byKey[key] == nil { order.append(key) } + byKey[key, default: []].append(f) + } + var out: [RemieSuggestion] = [] + for key in order { + guard let members = byKey[key], let lead = members.first else { continue } + if members.count >= 2, let group = lead.groupTitle, !group.isEmpty { + out.append(RemieSuggestion( + id: key, + text: group.replacingOccurrences(of: "{count}", with: "\(members.count)"), + severity: lead.severity ?? "low", + findings: members + )) + } else { + for f in members { + out.append(RemieSuggestion(id: f.id, text: f.title ?? "", severity: f.severity ?? "low", findings: [f])) + } + } + } + return out.sorted { a, b in + let ra = a.findings.first?.rank ?? 0, rb = b.findings.first?.rank ?? 0 + if ra != rb { return ra > rb } + if a.findings.count != b.findings.count { return a.findings.count > b.findings.count } + return (a.findings.first?.impact ?? 0) > (b.findings.first?.impact ?? 0) + } + } +} diff --git a/ios/Warmbly/Features/Auth/AuthFlowView.swift b/ios/Warmbly/Features/Auth/AuthFlowView.swift index 89ec90a30..72b6e05b3 100644 --- a/ios/Warmbly/Features/Auth/AuthFlowView.swift +++ b/ios/Warmbly/Features/Auth/AuthFlowView.swift @@ -58,6 +58,22 @@ struct AuthFlowView: View { .sensoryFeedback(.error, trigger: errorPulse) .sheet(isPresented: $showServerConfig) { ServerConfigSheet() } .task { providers = await env.session.fetchAuthProviders() } + #if DEBUG + // Scripted runs can open a later step (WARMBLY_DEBUG_AUTH) to see it. + .onAppear { + guard let target = ProcessInfo.processInfo.environment["WARMBLY_DEBUG_AUTH"] else { return } + email = "dev@warmbly.com" + switch target { + case "signup": mode = .signUp + case "password": step = .password + case "verify": step = .verify(session: "debug") + case "twofa": step = .twoFA(pendingToken: "debug") + case "reset": step = .resetSent + case "server": showServerConfig = true + default: break + } + } + #endif .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in if resendRemaining > 0 { withAnimation(.easeOut(duration: 0.3)) { resendRemaining -= 1 } @@ -84,20 +100,12 @@ struct AuthFlowView: View { private var stepScreen: some View { VStack(spacing: 0) { - HStack(spacing: 12) { - if step != .email { - skyIconButton("chevron.left", label: "Back") { goBackOneStep() } - .transition(.opacity.combined(with: .scale(scale: 0.7))) - } - compactLockup - Spacer() - skyIconButton("gearshape", label: "Server settings") { showServerConfig = true } + FlowTopBar( + onBack: step == .email ? nil : { goBackOneStep() }, + progress: stepProgress.map { ($0.index, 3) } + ) { + FlowSkyButton(symbol: "gearshape", label: "Server settings") { showServerConfig = true } } - .padding(.horizontal, 16) - .padding(.top, 6) - // The top bar reflow animates here; the sheet is left alone so it - // tracks the keyboard's own curve with no gap. - .animation(.spring(response: 0.45, dampingFraction: 0.86), value: step == .email) heroArea .animation(.spring(response: 0.5, dampingFraction: 0.85), value: heroExpanded) @@ -106,22 +114,6 @@ struct AuthFlowView: View { } } - /// The brand lockup, permanently docked in the top-left: the sky's center - /// stays free for the feature showcase. - private var compactLockup: some View { - HStack(spacing: 8) { - WarmblyLogo() - .fill(.white) - .frame(width: 27, height: 28) - Text("Warmbly") - .font(.system(size: 20, weight: .heavy)) - .tracking(-0.4) - .foregroundStyle(.white) - .fixedSize() - } - .shadow(color: Color(hex: 0x0C4A6E).opacity(0.25), radius: 8, y: 3) - } - /// The sky between the top bar and the sheet. On the email step it shows /// the richest thing the available height can hold: the showcase at full /// size, the showcase gently scaled down (to ~72%) when the space is only @@ -135,7 +127,10 @@ struct AuthFlowView: View { .contentShape(Rectangle()) .onTapGesture { focusedField = nil } - HeroFlightScene() + if step == .email { + HeroFlightScene() + .transition(.opacity) + } if step == .email { GeometryReader { proxy in @@ -154,8 +149,8 @@ struct AuthFlowView: View { .opacity(heroAppeared ? 1 : 0) .transition(.opacity.combined(with: .scale(scale: 0.94))) } else if let progress = stepProgress { - stepIndicator(progress) - .transition(.opacity.combined(with: .scale(scale: 0.9))) + FlowSkyStage(icon: progress.icon, title: progress.label) { focusedField = nil } + .transition(.opacity) } } .frame(maxWidth: .infinity, minHeight: 40, maxHeight: .infinity) @@ -166,8 +161,8 @@ struct AuthFlowView: View { } } - /// Where the user is in the flow, shown in the freed-up sky on the - /// deeper steps: the step's icon, name, position, and segment bar. + /// Where the user is on the deeper steps: the step's position for the top + /// bar, and its icon and name for the sky. private var stepProgress: (index: Int, icon: String, label: String)? { switch step { case .email, .resetSent: @@ -181,75 +176,6 @@ struct AuthFlowView: View { } } - /// Picks the tallest step badge that fits the sky left over above the - /// keyboard: icon bubble + name + segments, or a single compact row. - private func stepIndicator(_ progress: (index: Int, icon: String, label: String)) -> some View { - ViewThatFits(in: .vertical) { - VStack(spacing: 12) { - ZStack { - Circle() - .fill(.white.opacity(0.16)) - Circle() - .strokeBorder(.white.opacity(0.3), lineWidth: 1) - Image(systemName: progress.icon) - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(.white) - .contentTransition(.symbolEffect(.replace)) - } - .frame(width: 54, height: 54) - - VStack(spacing: 4) { - Text(progress.label) - .font(.system(size: 15.5, weight: .bold)) - .foregroundStyle(.white) - Text("Step \(progress.index + 1) of 3") - .font(.system(size: 12)) - .foregroundStyle(.white.opacity(0.7)) - } - - stepSegments(progress.index) - } - .padding(.vertical, 16) - - HStack(spacing: 10) { - ZStack { - Circle().fill(.white.opacity(0.16)) - Circle().strokeBorder(.white.opacity(0.3), lineWidth: 1) - Image(systemName: progress.icon) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.white) - .contentTransition(.symbolEffect(.replace)) - } - .frame(width: 34, height: 34) - - VStack(alignment: .leading, spacing: 4) { - Text(progress.label) - .font(.system(size: 13.5, weight: .semibold)) - .foregroundStyle(.white) - HStack(spacing: 7) { - stepSegments(progress.index) - Text("Step \(progress.index + 1) of 3") - .font(.system(size: 11)) - .foregroundStyle(.white.opacity(0.7)) - } - } - } - .padding(.vertical, 8) - } - .shadow(color: Color(hex: 0x0C4A6E).opacity(0.25), radius: 6, y: 2) - .animation(.spring(response: 0.4, dampingFraction: 0.85), value: progress.index) - } - - private func stepSegments(_ active: Int) -> some View { - HStack(spacing: 6) { - ForEach(0 ..< 3, id: \.self) { index in - Capsule() - .fill(.white.opacity(index <= active ? 0.95 : 0.3)) - .frame(width: index == active ? 26 : 14, height: 4) - } - } - } - /// The white area: full width, flush to the bottom edge (the background /// shape extends under the home indicator and the keyboard), rounded only /// at the top. Content resizes with a spring as steps swap. @@ -299,27 +225,14 @@ struct AuthFlowView: View { // The extra tail below keeps the sheet sealed to the screen edge // even mid-animation, when the sheet overshoots above the rising // keyboard for a few frames. - UnevenRoundedRectangle(cornerRadii: .init(topLeading: 36, topTrailing: 36)) - .fill(Color(.systemBackground) - .shadow(.drop(color: Color(hex: 0x0F172A).opacity(0.28), radius: 34, y: -6))) + UnevenRoundedRectangle(cornerRadii: .init(topLeading: 32, topTrailing: 32), style: .continuous) + .fill(WTheme.canvas) .padding(.bottom, -600) .ignoresSafeArea() } .geometryGroup() } - private func skyIconButton(_ systemName: String, label: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - Image(systemName: systemName) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 40, height: 40) - .background(.white.opacity(0.16), in: Circle()) - } - .buttonStyle(PressableButtonStyle()) - .accessibilityLabel(label) - } - private func goBackOneStep() { switch step { case .password, .resetSent: @@ -340,12 +253,11 @@ struct AuthFlowView: View { private func sheetTitle(_ title: String, _ subtitle: String) -> some View { VStack(alignment: .leading, spacing: 7) { Text(title) - .font(.system(size: 30, weight: .bold)) - .tracking(-0.6) - .foregroundStyle(.primary) + .displayFont(32) + .foregroundStyle(WTheme.ink) Text(subtitle) .font(.system(size: 15.5)) - .foregroundStyle(.secondary) + .foregroundStyle(WTheme.inkSecondary) .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity, alignment: .leading) @@ -356,28 +268,20 @@ struct AuthFlowView: View { Button { Task { await action() } } label: { - Group { + HStack(spacing: 8) { if busy { - ProgressView().tint(.white) + ProgressView().tint(WTheme.inkInverse) } else { Text(title) + Image(systemName: "arrow.right") + .font(.system(size: 14, weight: .medium)) } } - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(.white) + .font(.system(size: 16.5, weight: .medium)) + .foregroundStyle(WTheme.inkInverse) .frame(maxWidth: .infinity) - .frame(height: 56) - .background( - LinearGradient( - colors: disabled && !busy - ? [WTheme.accent.opacity(0.35), WTheme.accent.opacity(0.35)] - : [Color(hex: 0x0EA5E9), Color(hex: 0x0284C7)], - startPoint: .top, - endPoint: .bottom - ), - in: RoundedRectangle(cornerRadius: 17) - ) - .shadow(color: disabled ? .clear : Color(hex: 0x0284C7).opacity(0.32), radius: 12, y: 6) + .frame(height: 54) + .background(WTheme.ink.opacity(disabled && !busy ? 0.18 : 1), in: Capsule()) } .buttonStyle(PressableButtonStyle()) .disabled(busy || disabled) @@ -387,12 +291,11 @@ struct AuthFlowView: View { private func authField(_ content: some View, focused: Bool) -> some View { content .font(.system(size: 16.5)) - .padding(.horizontal, 16) - .frame(height: 56) - .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 17)) + .padding(.horizontal, 18) + .frame(height: 54) + .background(focused ? WTheme.raised : WTheme.surface, in: Capsule()) .overlay( - RoundedRectangle(cornerRadius: 17) - .strokeBorder(focused ? WTheme.accent : .clear, lineWidth: 1.8) + Capsule().strokeBorder(focused ? WTheme.ink.opacity(0.85) : WTheme.hairlineStrong, lineWidth: focused ? 1.4 : 1) ) .animation(.easeOut(duration: 0.18), value: focused) } @@ -414,10 +317,11 @@ struct AuthFlowView: View { .foregroundStyle(.tertiary) } .font(.system(size: 14)) - .foregroundStyle(.secondary) + .foregroundStyle(WTheme.inkSecondary) .padding(.horizontal, 14) .padding(.vertical, 9) - .background(Color(.secondarySystemBackground), in: Capsule()) + .background(WTheme.surface, in: Capsule()) + .overlay(Capsule().strokeBorder(WTheme.hairlineStrong, lineWidth: 1)) } .buttonStyle(PressableButtonStyle()) } @@ -479,12 +383,13 @@ struct AuthFlowView: View { ) HStack(spacing: 12) { - Rectangle().fill(Color(.separator).opacity(0.45)).frame(height: 1) - Text("or") - .font(.system(size: 13.5)) - .foregroundStyle(.tertiary) + Hairline() + Text("OR") + .font(WFont.meta(11, weight: .medium)) + .tracking(0.6) + .foregroundStyle(WTheme.inkTertiary) .fixedSize() - Rectangle().fill(Color(.separator).opacity(0.45)).frame(height: 1) + Hairline() } .padding(.vertical, 4) } @@ -519,8 +424,9 @@ struct AuthFlowView: View { mode = mode == .signIn ? .signUp : .signIn } } - .foregroundStyle(WTheme.accent) - .fontWeight(.semibold) + .foregroundStyle(WTheme.ink) + .fontWeight(.medium) + .underline(true, color: WTheme.ink.opacity(0.25)) } .font(.system(size: 14.5)) .frame(maxWidth: .infinity) @@ -892,11 +798,11 @@ struct HeroFlightScene: View { // The faint full route, plus a brighter segment forever // travelling along it like a gust carrying the plane. - ctx.stroke(trail, with: .color(.white.opacity(0.14)), style: StrokeStyle(lineWidth: 2, lineCap: .round, dash: [2, 9])) + ctx.stroke(trail, with: .color(WTheme.onSky.opacity(0.14)), style: StrokeStyle(lineWidth: 2, lineCap: .round, dash: [2, 9])) let phase = (t / 6).truncatingRemainder(dividingBy: 1) let window: CGFloat = 0.26 let segment = trail.trimmedPath(from: CGFloat(phase), to: min(CGFloat(phase) + window, 1)) - ctx.stroke(segment, with: .color(.white.opacity(0.5)), style: StrokeStyle(lineWidth: 2.4, lineCap: .round, dash: [2, 9])) + ctx.stroke(segment, with: .color(WTheme.onSky.opacity(0.5)), style: StrokeStyle(lineWidth: 2.4, lineCap: .round, dash: [2, 9])) // Envelopes rising with a gentle sway, fading at both ends. for spec in Self.envelopes { @@ -905,7 +811,7 @@ struct HeroFlightScene: View { let x = size.width * spec.x + sin(t / 2.6 + spec.phase * 9) * 13 let fade = sin(.pi * progress) var image = ctx.resolve(Image(systemName: "envelope.fill")) - image.shading = .color(.white.opacity(spec.baseOpacity * fade)) + image.shading = .color(WTheme.onSky.opacity(spec.baseOpacity * fade)) let box = CGRect(x: x - spec.size / 2, y: y - spec.size / 2, width: spec.size, height: spec.size * 0.72) ctx.draw(image, in: box) } @@ -978,7 +884,7 @@ struct FeatureShowcase: View { } } label: { Capsule() - .fill(.white.opacity(index == slide.id ? 0.95 : 0.35)) + .fill(WTheme.onSky.opacity(index == slide.id ? 0.95 : 0.35)) .frame(width: index == slide.id ? 22 : 7, height: 7) } .buttonStyle(.plain) @@ -1009,33 +915,32 @@ struct FeatureShowcase: View { .padding(.bottom, 14) Text(slide.title) - .font(.system(size: 18, weight: .bold)) - .tracking(-0.3) - .foregroundStyle(.white) + .displayFont(20) + .foregroundStyle(WTheme.onSky) Text(slide.subtitle) .font(.system(size: 13.5)) - .foregroundStyle(.white.opacity(0.78)) + .foregroundStyle(WTheme.onSky.opacity(0.6)) .fixedSize(horizontal: false, vertical: true) .padding(.top, 5) } .padding(22) .frame(maxWidth: .infinity, alignment: .leading) .background { - RoundedRectangle(cornerRadius: 26) - .fill(.white.opacity(0.13)) + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(WTheme.skyTile(0.045)) + .background(.ultraThinMaterial.opacity(0.25), in: RoundedRectangle(cornerRadius: 24, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: 26) + RoundedRectangle(cornerRadius: 24, style: .continuous) .strokeBorder( LinearGradient( - colors: [.white.opacity(0.45), .white.opacity(0.08)], - startPoint: .topLeading, - endPoint: .bottomTrailing + colors: [.white.opacity(0.22), .white.opacity(0.05)], + startPoint: .top, + endPoint: .bottom ), lineWidth: 1 ) ) - .shadow(color: Color(hex: 0x0C4A6E).opacity(0.2), radius: 16, y: 9) } } @@ -1071,11 +976,11 @@ struct FeatureTicker: View { .font(.system(size: 13.5, weight: .semibold)) .fixedSize() } - .foregroundStyle(.white) + .foregroundStyle(WTheme.onSky) .padding(.horizontal, 15) .frame(height: 36) - .background(.white.opacity(0.14), in: Capsule()) - .overlay(Capsule().strokeBorder(.white.opacity(0.25), lineWidth: 1)) + .background(WTheme.skyTile(0.14), in: Capsule()) + .overlay(Capsule().strokeBorder(WTheme.skyEdge(0.25), lineWidth: 1)) .id(index) .transition(.blurReplace) } @@ -1099,16 +1004,16 @@ struct WarmupRampVisual: View { ZStack(alignment: .topLeading) { VStack(spacing: 0) { ForEach(0 ..< 3, id: \.self) { _ in - Rectangle().fill(.white.opacity(0.1)).frame(height: 1) + Rectangle().fill(WTheme.onSky.opacity(0.1)).frame(height: 1) Spacer() } - Rectangle().fill(.white.opacity(0.22)).frame(height: 1) + Rectangle().fill(WTheme.onSky.opacity(0.22)).frame(height: 1) } Self.area(in: size) .fill( LinearGradient( - colors: [.white.opacity(0.22), .white.opacity(0.02)], + colors: [WTheme.onSky.opacity(0.22), WTheme.onSky.opacity(0.02)], startPoint: .top, endPoint: .bottom ) @@ -1117,14 +1022,14 @@ struct WarmupRampVisual: View { Self.ramp(in: size) .trim(from: 0, to: progress) - .stroke(.white, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) + .stroke(WTheme.onSky, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) Circle() - .fill(.white) + .fill(WTheme.onSky) .frame(width: 7, height: 7) .background( Circle() - .fill(.white.opacity(0.3)) + .fill(WTheme.onSky.opacity(0.3)) .frame(width: pulse ? 24 : 10, height: pulse ? 24 : 10) ) .position(x: size.width - 2, y: size.height * 0.08) @@ -1173,14 +1078,14 @@ struct InboxPlacementVisual: View { HStack(spacing: 26) { ZStack { Circle() - .stroke(.white.opacity(0.18), lineWidth: 9) + .stroke(WTheme.onSky.opacity(0.18), lineWidth: 9) Circle() .trim(from: 0, to: progress * 0.98) - .stroke(.white, style: StrokeStyle(lineWidth: 9, lineCap: .round)) + .stroke(WTheme.onSky, style: StrokeStyle(lineWidth: 9, lineCap: .round)) .rotationEffect(.degrees(-90)) Text("\(shownValue)%") .font(.system(size: 22, weight: .bold)) - .foregroundStyle(.white) + .foregroundStyle(WTheme.onSky) .monospacedDigit() .contentTransition(.numericText()) } @@ -1208,13 +1113,13 @@ struct InboxPlacementVisual: View { Text(label) .font(.system(size: 12.5, weight: .semibold)) } - .foregroundStyle(.white.opacity(0.85)) + .foregroundStyle(WTheme.onSky.opacity(0.85)) GeometryReader { proxy in ZStack(alignment: .leading) { - Capsule().fill(.white.opacity(0.18)) + Capsule().fill(WTheme.onSky.opacity(0.18)) Capsule() - .fill(.white.opacity(0.9)) + .fill(WTheme.onSky.opacity(0.9)) .frame(width: max(5, proxy.size.width * fraction * progress)) } } @@ -1247,38 +1152,38 @@ struct UnifiedInboxVisual: View { let row = pair.element HStack(spacing: 10) { Circle() - .fill(.white.opacity(0.28)) + .fill(WTheme.onSky.opacity(0.28)) .frame(width: 26, height: 26) .overlay( Text(row.initial) .font(.system(size: 11.5, weight: .bold)) - .foregroundStyle(.white) + .foregroundStyle(WTheme.onSky) ) VStack(alignment: .leading, spacing: 4) { GeometryReader { proxy in Capsule() - .fill(.white.opacity(0.75)) + .fill(WTheme.onSky.opacity(0.75)) .frame(width: proxy.size.width * row.lineWidth, height: 5) } .frame(height: 5) Capsule() - .fill(.white.opacity(0.3)) + .fill(WTheme.onSky.opacity(0.3)) .frame(width: 52, height: 5) } if row.replied { Image(systemName: "arrowshape.turn.up.left.fill") .font(.system(size: 11)) - .foregroundStyle(.white.opacity(0.85)) + .foregroundStyle(WTheme.onSky.opacity(0.85)) } if row.unread { - Circle().fill(.white).frame(width: 7, height: 7) + Circle().fill(WTheme.onSky).frame(width: 7, height: 7) } } .padding(.horizontal, 12) .padding(.vertical, 8) - .background(.white.opacity(0.1), in: RoundedRectangle(cornerRadius: 13)) + .background(WTheme.skyTile(0.1), in: RoundedRectangle(cornerRadius: 13)) .offset(x: shown ? 0 : 46) .opacity(shown ? 1 : 0) .animation( @@ -1308,21 +1213,21 @@ struct CampaignStatsVisual: View { VStack(spacing: 7) { Text("\(Int(bar.fraction * 100))%") .font(.system(size: 12, weight: .bold)) - .foregroundStyle(.white.opacity(0.9)) + .foregroundStyle(WTheme.onSky.opacity(0.9)) .monospacedDigit() .opacity(grown ? 1 : 0) VStack { Spacer(minLength: 0) RoundedRectangle(cornerRadius: 5) - .fill(.white.opacity(0.9 - Double(pair.offset) * 0.18)) + .fill(WTheme.onSky.opacity(0.9 - Double(pair.offset) * 0.18)) .frame(height: grown ? 66 * bar.fraction + 8 : 6) } .frame(height: 74) Text(bar.label) .font(.system(size: 11.5, weight: .semibold)) - .foregroundStyle(.white.opacity(0.7)) + .foregroundStyle(WTheme.onSky.opacity(0.7)) } .frame(width: 62) .animation( @@ -1395,339 +1300,91 @@ struct PasswordStrengthBar: View { } } -// MARK: - The website's airy sky +// MARK: - Brand backdrop -/// The marketing site's auth backdrop, recreated from `web/src/global.css`: -/// a radial sky, a slow "breathing" light layer, a warm sun glow top-right, -/// and the site's painterly cloud renders gliding gently back and forth. +/// The brand moment behind sign-in and onboarding. Light: the site's sheer +/// sky photo, drifting slowly under a soft veil. Dark: a near-black canvas +/// with the dot grid and a sky aurora. Content on it uses `WTheme.onSky`. struct SkyBackdrop: View { var largeCloud = false - @State private var breathe = false - @State private var glide = false - - private static let skyStops: [Gradient.Stop] = [ - .init(color: Color(hex: 0x7DD3FC), location: 0), - .init(color: Color(hex: 0x38BDF8), location: 0.20), - .init(color: Color(hex: 0x0EA5E9), location: 0.40), - .init(color: Color(hex: 0x0284C7), location: 0.62), - .init(color: Color(hex: 0x075985), location: 0.84), - .init(color: Color(hex: 0x0C4A6E), location: 1), - ] + @Environment(\.colorScheme) private var scheme + @State private var drift = false var body: some View { - GeometryReader { proxy in - let size = proxy.size - ZStack { - // sky-base: radial from the upper area, light to deep. - RadialGradient( - stops: Self.skyStops, - center: UnitPoint(x: 0.6, y: 0.18), - startRadius: 0, - endRadius: max(size.width, size.height) * 1.15 - ) - - // sky-breathe: a soft light bloom that swells and fades. - RadialGradient( - stops: [ - .init(color: Color(hex: 0xBAE6FD).opacity(0.8), location: 0), - .init(color: Color(hex: 0x38BDF8).opacity(0.4), location: 0.20), - .init(color: Color(hex: 0x0EA5E9).opacity(0.16), location: 0.40), - .init(color: .clear, location: 0.62), - ], - center: UnitPoint(x: 0.6, y: 0.16), - startRadius: 0, - endRadius: max(size.width, size.height) * 1.05 - ) - .opacity(breathe ? 0.45 : 0.7) - .scaleEffect(breathe ? 1.05 : 1) - - // sun-glow: warm haze off the top-right corner. - RadialGradient( - stops: [ - .init(color: Color(hex: 0xFDE68A).opacity(0.34), location: 0), - .init(color: Color(hex: 0xFDBA74).opacity(0.12), location: 0.32), - .init(color: Color(hex: 0xBAE6FD).opacity(0.05), location: 0.55), - .init(color: .clear, location: 1), - ], - center: .center, - startRadius: 0, - endRadius: 210 - ) - .frame(width: 420, height: 420) - .blur(radius: 36) - .position(x: size.width * 0.96, y: size.height * 0.02) - - // Painterly clouds (the site's pre-rendered webp art), - // mirroring the web auth layout's placement and glide. - Image("Cloud3") - .resizable() - .scaledToFit() - .frame(width: 280) - .opacity(0.6) - .position(x: size.width * -0.16 + 140, y: size.height * 0.04 + 73) - .offset(x: glide ? 40 : 0, y: glide ? -10 : 0) - - Image("Cloud4") - .resizable() - .scaledToFit() - .frame(width: 240) - .opacity(0.5) - .position(x: size.width * 1.14 - 120, y: size.height * 0.88 - 63) - .offset(x: glide ? -38 : 0, y: glide ? 8 : 0) - - if largeCloud { - Image("Cloud1") - .resizable() - .scaledToFit() - .frame(width: 360) - .opacity(0.35) - .position(x: size.width * 0.85, y: size.height * 0.42) - .offset(x: glide ? -24 : 0, y: glide ? 6 : 0) - } - } + Group { + if scheme == .dark { night } else { photo } } .ignoresSafeArea() .onAppear { - withAnimation(.easeInOut(duration: 15).repeatForever(autoreverses: true)) { - breathe = true - } - withAnimation(.easeInOut(duration: 19).repeatForever(autoreverses: true)) { - glide = true + withAnimation(.easeInOut(duration: 14).repeatForever(autoreverses: true)) { + drift = true } } } -} -/// Where the app connects, for local dev or a self-hosted deployment. -/// Presets cover the common cases in one tap; the captcha token and realtime -/// origin live behind Advanced so the everyday view stays simple. -struct ServerConfigSheet: View { - @Environment(\.dismiss) private var dismiss - @State private var origin = AppConfig.serverOrigin - @State private var turnstile = AppConfig.turnstileToken - @State private var realtime = AppConfig.realtimeOriginOverride - @State private var showAdvanced = false - - @FocusState private var focused: Field? - private enum Field { case origin, turnstile, realtime } - - private var isDefault: Bool { - origin == AppConfig.defaultOrigin - && turnstile == AppConfig.defaultTurnstileToken - && realtime.isEmpty - } - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .center, spacing: 14) { - Image(systemName: "globe") - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 46, height: 46) - .background( - LinearGradient(colors: [Color(hex: 0x38BDF8), Color(hex: 0x0284C7)], startPoint: .topLeading, endPoint: .bottomTrailing), - in: RoundedRectangle(cornerRadius: 14) - ) - - VStack(alignment: .leading, spacing: 3) { - Text("Connection") - .font(.system(size: 24, weight: .bold)) - .tracking(-0.5) - Text("One app, any Warmbly server.") - .font(.system(size: 14)) - .foregroundStyle(.secondary) - } - - Spacer(minLength: 12) - - Button { - dismiss() - } label: { - Image(systemName: "xmark") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.secondary) - .frame(width: 32, height: 32) - .background(Color(.secondarySystemBackground), in: Circle()) - } - .buttonStyle(PressableButtonStyle()) - .accessibilityLabel("Close") - } - .padding(.bottom, 24) - - // One-tap environments. - HStack(spacing: 10) { - presetChip("Warmbly Cloud", icon: "cloud.fill", active: origin == "https://api.warmbly.com") { - origin = "https://api.warmbly.com" - turnstile = "" - realtime = "" - } - presetChip("Local dev", icon: "laptopcomputer", active: origin == "http://localhost:8080") { - origin = "http://localhost:8080" - turnstile = "warmbly-local-turnstile-bypass" - realtime = "" - } - } - .padding(.bottom, 24) - - fieldLabel("Server", icon: "server.rack") - configField( - TextField(AppConfig.defaultOrigin, text: $origin) - .keyboardType(.URL) - .textContentType(.URL) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .focused($focused, equals: .origin), - focused: focused == .origin - ) - caption("Bare origin, no path. The app calls /v1. On a physical device, localhost won't reach your Mac; use your Mac's LAN IP or a tunnel URL instead.") - .padding(.bottom, 18) - - // Advanced: rarely needed, one quiet toggle, fields styled - // exactly like the rest of the form. - Button { - withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { - showAdvanced.toggle() - } - } label: { - HStack(spacing: 5) { - Text(showAdvanced ? "Hide advanced options" : "Advanced options") - .font(.system(size: 14, weight: .medium)) - Image(systemName: "chevron.down") - .font(.system(size: 10, weight: .semibold)) - .rotationEffect(.degrees(showAdvanced ? 180 : 0)) - } - .foregroundStyle(WTheme.accent) - } - .buttonStyle(PressableButtonStyle()) - .padding(.vertical, 2) - - if showAdvanced { - VStack(alignment: .leading, spacing: 0) { - fieldLabel("Captcha token", icon: "checkmark.shield") - .padding(.top, 20) - configField( - TextField("Turnstile token", text: $turnstile) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .focused($focused, equals: .turnstile), - focused: focused == .turnstile - ) - caption("Only for self-hosted setups. Dev backends accept the bypass token; the hosted service needs nothing here.") - .padding(.bottom, 18) - - fieldLabel("Realtime server", icon: "bolt.horizontal") - configField( - TextField("Same as server (automatic)", text: $realtime) - .keyboardType(.URL) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .focused($focused, equals: .realtime), - focused: focused == .realtime - ) - caption("Optional. Where live updates connect when your realtime service runs on a different host. Leave empty to let the server decide.") - } - .transition(.opacity.combined(with: .move(edge: .top))) - } - - if !isDefault { - Button("Reset to default") { - withAnimation(.easeOut(duration: 0.2)) { - origin = AppConfig.defaultOrigin - turnstile = AppConfig.defaultTurnstileToken - realtime = "" - } - } - .font(.system(size: 14.5, weight: .medium)) - .foregroundStyle(WTheme.accent) - .frame(maxWidth: .infinity) - .padding(.top, 22) - } - } - .padding(24) - } - .scrollBounceBehavior(.basedOnSize) - .safeAreaInset(edge: .bottom) { - Button { - AppConfig.serverOrigin = AppConfig.normalizeOrigin(origin) - AppConfig.turnstileToken = turnstile.trimmingCharacters(in: .whitespaces) - AppConfig.realtimeOriginOverride = AppConfig.normalizeOrigin(realtime) - dismiss() - } label: { - Text("Save") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(.white) - .frame(maxWidth: .infinity) - .frame(height: 56) - .background( - LinearGradient(colors: [Color(hex: 0x0EA5E9), Color(hex: 0x0284C7)], startPoint: .top, endPoint: .bottom), - in: RoundedRectangle(cornerRadius: 17) + private var photo: some View { + GeometryReader { proxy in + Image("SheerSky") + .resizable() + .scaledToFill() + .frame(width: proxy.size.width * 1.12, height: proxy.size.height) + .offset(x: proxy.size.width * (drift ? -0.1 : -0.02)) + .frame(width: proxy.size.width, height: proxy.size.height, alignment: .leading) + .clipped() + .overlay { + // A veil so ink reads on the darker folds, fading into the sheet. + LinearGradient( + colors: [.white.opacity(0.42), .white.opacity(0.08), .white.opacity(0.3), .white], + startPoint: .top, + endPoint: .bottom ) - .shadow(color: Color(hex: 0x0284C7).opacity(0.32), radius: 12, y: 6) + } + } + } + + private var night: some View { + GeometryReader { proxy in + let size = proxy.size + ZStack { + WTheme.night + + DotGrid(spacing: 22, opacity: 0.11, color: .white) + .mask( + RadialGradient( + colors: [.black, .black.opacity(0.4), .clear], + center: UnitPoint(x: 0.5, y: 0.1), + startRadius: 0, + endRadius: max(size.width, size.height) * 0.75 + ) + ) + + // Sky aurora: two soft blooms drifting against each other. + Ellipse() + .fill(Color(hex: 0x0EA5E9).opacity(0.42)) + .frame(width: size.width * 1.2, height: size.width * 0.75) + .blur(radius: 80) + .position(x: size.width * (drift ? 0.32 : 0.42), y: -size.width * 0.06) + Ellipse() + .fill(Color(hex: 0x6366F1).opacity(0.26)) + .frame(width: size.width * 0.9, height: size.width * 0.6) + .blur(radius: 90) + .position(x: size.width * (drift ? 0.92 : 0.78), y: size.width * (largeCloud ? 0.2 : 0.06)) + Ellipse() + .fill(Color(hex: 0x7DD3FC).opacity(0.18)) + .frame(width: size.width * 0.7, height: size.width * 0.32) + .blur(radius: 60) + .position(x: size.width * 0.5, y: size.width * (drift ? 0.16 : 0.1)) + + // Faint grain of light across the top edge, like a horizon. + LinearGradient( + colors: [.clear, .white.opacity(0.14), .clear], + startPoint: .leading, + endPoint: .trailing + ) + .frame(height: 1) + .position(x: size.width / 2, y: size.height * 0.001 + 0.5) } - .buttonStyle(PressableButtonStyle()) - .padding(.horizontal, 24) - .padding(.top, 10) - .padding(.bottom, 6) - .background(Color(.systemBackground)) } - .presentationBackground(Color(.systemBackground)) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } - - private func presetChip(_ title: String, icon: String, active: Bool, action: @escaping () -> Void) -> some View { - Button { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { action() } - } label: { - HStack(spacing: 7) { - Image(systemName: icon) - .font(.system(size: 12.5)) - Text(title) - .font(.system(size: 14, weight: .semibold)) - } - .foregroundStyle(active ? .white : .primary) - .padding(.horizontal, 15) - .frame(height: 38) - .background( - active ? AnyShapeStyle(WTheme.accent) : AnyShapeStyle(Color(.secondarySystemBackground)), - in: Capsule() - ) - } - .buttonStyle(PressableButtonStyle()) - } - - private func fieldLabel(_ text: String, icon: String) -> some View { - HStack(spacing: 6) { - Image(systemName: icon) - .font(.system(size: 11)) - Text(text.uppercased()) - .font(.system(size: 11, weight: .semibold)) - .tracking(1.3) - } - .foregroundStyle(.secondary) - .padding(.bottom, 8) - } - - private func configField(_ content: some View, focused: Bool) -> some View { - content - .font(.system(size: 15.5)) - .padding(.horizontal, 15) - .frame(height: 52) - .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 15)) - .overlay( - RoundedRectangle(cornerRadius: 15) - .strokeBorder(focused ? WTheme.accent : .clear, lineWidth: 1.8) - ) - .animation(.easeOut(duration: 0.18), value: focused) - .padding(.bottom, 8) - } - - private func caption(_ text: String) -> some View { - Text(text) - .font(.system(size: 12.5)) - .foregroundStyle(.tertiary) - .fixedSize(horizontal: false, vertical: true) } } diff --git a/ios/Warmbly/Features/Auth/FlowChrome.swift b/ios/Warmbly/Features/Auth/FlowChrome.swift new file mode 100644 index 000000000..4f7625281 --- /dev/null +++ b/ios/Warmbly/Features/Auth/FlowChrome.swift @@ -0,0 +1,165 @@ +import SwiftUI + +// Chrome shared by sign-in, the first-run questions and workspace creation: +// one top bar with the step progress, and a sky that never crams. + +/// The top bar over the sky: back (or the wordmark on a first step), the +/// step progress centered, and one trailing action. +struct FlowTopBar: View { + var onBack: (() -> Void)? + var progress: (index: Int, total: Int)? + @ViewBuilder var trailing: () -> Trailing + + var body: some View { + ZStack { + HStack(spacing: 12) { + if let onBack { + FlowSkyButton(symbol: "chevron.left", label: "Back", action: onBack) + .transition(.opacity.combined(with: .scale(scale: 0.7))) + } else { + FlowWordmark() + .transition(.opacity) + } + Spacer(minLength: 12) + trailing() + } + if let progress { + FlowProgress(index: progress.index, total: progress.total) + .transition(.opacity) + } + } + .frame(height: 44) + .padding(.horizontal, 16) + .padding(.top, 6) + .animation(.spring(response: 0.45, dampingFraction: 0.86), value: onBack == nil) + } +} + +/// The logo and name, for the screen a flow opens on. +struct FlowWordmark: View { + var body: some View { + HStack(spacing: 8) { + WarmblyLogo() + .fill(WTheme.onSky) + .frame(width: 25, height: 26) + Text("Warmbly") + .font(.system(size: 19, weight: .semibold)) + .tracking(-0.5) + .foregroundStyle(WTheme.onSky) + .fixedSize() + } + .accessibilityElement(children: .combine) + } +} + +/// A round glass button on the sky. +struct FlowSkyButton: View { + let symbol: String + let label: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: symbol) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(WTheme.onSky) + .frame(width: 40, height: 40) + .background(WTheme.skyTile(0.12), in: Circle()) + .overlay(Circle().strokeBorder(WTheme.skyEdge(0.16), lineWidth: 1)) + } + .buttonStyle(PressableButtonStyle()) + .accessibilityLabel(label) + } +} + +/// A text pill on the sky, like Sign out. +struct FlowSkyPill: View { + let title: String + let action: () -> Void + + var body: some View { + Button(title, action: action) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(WTheme.onSky) + .padding(.horizontal, 15) + .frame(height: 36) + .background(WTheme.skyTile(0.12), in: Capsule()) + .overlay(Capsule().strokeBorder(WTheme.skyEdge(0.16), lineWidth: 1)) + .buttonStyle(PressableButtonStyle()) + } +} + +/// Where a flow is: one segment per step, the current one long. +struct FlowProgress: View { + let index: Int + let total: Int + + var body: some View { + HStack(spacing: 5) { + ForEach(0 ..< total, id: \.self) { step in + Capsule() + .fill(WTheme.onSky.opacity(step <= index ? 0.88 : 0.18)) + .frame(width: step == index ? 24 : 12, height: 4) + } + } + .animation(.spring(response: 0.42, dampingFraction: 0.8), value: index) + .accessibilityElement() + .accessibilityLabel("Step \(index + 1) of \(total)") + } +} + +/// The sky between the top bar and the sheet. With room it holds the step's +/// icon and name over the flight ambience; when the keyboard takes the room it +/// is just sky, since the top bar already says where you are. +struct FlowSkyStage: View { + let icon: String + let title: String + var onTap: () -> Void = {} + + /// Below this the badge would crowd the top bar, so it steps aside. + static let roomyHeight: CGFloat = 150 + + @State private var appeared = false + + var body: some View { + GeometryReader { proxy in + let roomy = proxy.size.height >= Self.roomyHeight + ZStack { + Color.clear + .contentShape(Rectangle()) + .onTapGesture(perform: onTap) + if roomy { + HeroFlightScene() + .transition(.opacity) + badge + .scaleEffect(appeared ? 1 : 0.9) + .opacity(appeared ? 1 : 0) + .transition(.opacity.combined(with: .scale(scale: 0.92))) + } + } + .frame(width: proxy.size.width, height: proxy.size.height) + .animation(.easeOut(duration: 0.22), value: roomy) + } + .onAppear { + withAnimation(.spring(response: 0.7, dampingFraction: 0.75).delay(0.05)) { appeared = true } + } + } + + private var badge: some View { + VStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 21, weight: .semibold)) + .foregroundStyle(WTheme.onSky) + .contentTransition(.symbolEffect(.replace)) + .frame(width: 56, height: 56) + .background(WTheme.skyTile(0.16), in: Circle()) + .overlay(Circle().strokeBorder(WTheme.skyEdge(0.3), lineWidth: 1)) + Text(title) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(WTheme.onSky) + .contentTransition(.opacity) + } + .shadow(color: Color(hex: 0x0C4A6E).opacity(0.18), radius: 8, y: 3) + .animation(.spring(response: 0.45, dampingFraction: 0.85), value: icon) + } +} diff --git a/ios/Warmbly/Features/Auth/OnboardingFlowView.swift b/ios/Warmbly/Features/Auth/OnboardingFlowView.swift index 5f452264f..57a311c41 100644 --- a/ios/Warmbly/Features/Auth/OnboardingFlowView.swift +++ b/ios/Warmbly/Features/Auth/OnboardingFlowView.swift @@ -40,7 +40,6 @@ struct OnboardingFlowView: View { @State private var busy = false @State private var errorMessage: String? @State private var errorPulse = 0 - @State private var badgeAppeared = false @FocusState private var focusedName: NameField? private enum NameField { case first, last } @@ -81,7 +80,18 @@ struct OnboardingFlowView: View { } .sensoryFeedback(.impact(weight: .light), trigger: page) .sensoryFeedback(.error, trigger: errorPulse) - .onAppear { prefillName() } + .onAppear { + prefillName() + #if DEBUG + // Scripted runs can open a later page (WARMBLY_DEBUG_ONBOARDING) to see it. + switch ProcessInfo.processInfo.environment["WARMBLY_DEBUG_ONBOARDING"] { + case "referral": page = .referral + case "role": page = .role + case "team": page = .teamSize + default: break + } + #endif + } } private var pageTransition: AnyTransition { @@ -94,145 +104,23 @@ struct OnboardingFlowView: View { // MARK: - Sky chrome private var topBar: some View { - HStack(spacing: 12) { - if page != .name { - Button { - goBack() - } label: { - Image(systemName: "chevron.left") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 40, height: 40) - .background(.white.opacity(0.16), in: Circle()) - } - .buttonStyle(PressableButtonStyle()) - .accessibilityLabel("Back") - .transition(.opacity.combined(with: .scale(scale: 0.7))) - } - - HStack(spacing: 8) { - WarmblyLogo() - .fill(.white) - .frame(width: 27, height: 28) - Text("Warmbly") - .font(.system(size: 20, weight: .heavy)) - .tracking(-0.4) - .foregroundStyle(.white) - .fixedSize() - } - .shadow(color: Color(hex: 0x0C4A6E).opacity(0.25), radius: 8, y: 3) - - Spacer() - - Button("Sign out") { + FlowTopBar( + onBack: page == .name ? nil : { goBack() }, + progress: (page.rawValue, Page.allCases.count) + ) { + FlowSkyPill(title: "Sign out") { Task { await env.session.logout() } } - .font(.system(size: 13.5, weight: .semibold)) - .foregroundStyle(.white.opacity(0.9)) - .padding(.horizontal, 14) - .frame(height: 34) - .background(.white.opacity(0.16), in: Capsule()) - .buttonStyle(PressableButtonStyle()) } - .padding(.horizontal, 16) - .padding(.top, 6) - .animation(.spring(response: 0.45, dampingFraction: 0.86), value: page) } - /// The animated sky window: flight ambience plus the current page's badge - /// (icon, question name, position). Picks the tallest badge that fits so - /// the keyboard on the name page only compacts it, never kills it. + /// The sky between the top bar and the sheet: the page's icon and name + /// when there is room, plain sky while the keyboard is up. private var skyArea: some View { - ZStack { - Color.clear - .contentShape(Rectangle()) - .onTapGesture { focusedName = nil } - - HeroFlightScene() - - pageBadge - .scaleEffect(badgeAppeared ? 1 : 0.9) - .opacity(badgeAppeared ? 1 : 0) - } - .frame(maxWidth: .infinity, minHeight: 40, maxHeight: .infinity) - .animation(.spring(response: 0.5, dampingFraction: 0.85), value: page) - .onAppear { - withAnimation(.spring(response: 0.7, dampingFraction: 0.75).delay(0.05)) { - badgeAppeared = true - } - } + FlowSkyStage(icon: page.icon, title: page.skyLabel) { focusedName = nil } + .frame(maxWidth: .infinity, minHeight: 88, maxHeight: .infinity) } - private var pageBadge: some View { - ViewThatFits(in: .vertical) { - VStack(spacing: 12) { - ZStack { - Circle().fill(.white.opacity(0.16)) - Circle().strokeBorder(.white.opacity(0.3), lineWidth: 1) - Image(systemName: page.icon) - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(.white) - .contentTransition(.symbolEffect(.replace)) - } - .frame(width: 54, height: 54) - - VStack(spacing: 4) { - Text(page.skyLabel) - .font(.system(size: 15.5, weight: .bold)) - .foregroundStyle(.white) - .contentTransition(.opacity) - Text("Step \(page.rawValue + 1) of \(Page.allCases.count)") - .font(.system(size: 12)) - .foregroundStyle(.white.opacity(0.7)) - .contentTransition(.numericText()) - } - - pageSegments - } - .padding(.vertical, 16) - - HStack(spacing: 10) { - ZStack { - Circle().fill(.white.opacity(0.16)) - Circle().strokeBorder(.white.opacity(0.3), lineWidth: 1) - Image(systemName: page.icon) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.white) - .contentTransition(.symbolEffect(.replace)) - } - .frame(width: 34, height: 34) - - VStack(alignment: .leading, spacing: 4) { - Text(page.skyLabel) - .font(.system(size: 13.5, weight: .semibold)) - .foregroundStyle(.white) - .contentTransition(.opacity) - HStack(spacing: 7) { - pageSegments - Text("Step \(page.rawValue + 1) of \(Page.allCases.count)") - .font(.system(size: 11)) - .foregroundStyle(.white.opacity(0.7)) - .contentTransition(.numericText()) - } - } - } - .padding(.vertical, 8) - } - .shadow(color: Color(hex: 0x0C4A6E).opacity(0.25), radius: 6, y: 2) - } - - private var pageSegments: some View { - HStack(spacing: 6) { - ForEach(Page.allCases, id: \.rawValue) { segment in - Capsule() - .fill(.white.opacity(segment.rawValue <= page.rawValue ? 0.95 : 0.3)) - .frame(width: segment == page ? 26 : 14, height: 4) - } - } - } - - // MARK: - The sheet - private var sheet: some View { VStack(alignment: .leading, spacing: 0) { // Always a ScrollView: ViewThatFits swaps identities when the @@ -293,7 +181,7 @@ struct OnboardingFlowView: View { } label: { Group { if busy { - ProgressView().tint(.white) + ProgressView().tint(WTheme.inkInverse) } else { HStack(spacing: 8) { Text(page == .teamSize ? "Enter Warmbly" : "Continue") @@ -304,21 +192,11 @@ struct OnboardingFlowView: View { } } } - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(.white) + .font(.system(size: 16.5, weight: .medium)) + .foregroundStyle(WTheme.inkInverse) .frame(maxWidth: .infinity) - .frame(height: 56) - .background( - LinearGradient( - colors: !canAdvance && !busy - ? [WTheme.accent.opacity(0.35), WTheme.accent.opacity(0.35)] - : [Color(hex: 0x0EA5E9), Color(hex: 0x0284C7)], - startPoint: .top, - endPoint: .bottom - ), - in: RoundedRectangle(cornerRadius: 17) - ) - .shadow(color: !canAdvance ? .clear : Color(hex: 0x0284C7).opacity(0.32), radius: 12, y: 6) + .frame(height: 54) + .background(WTheme.ink.opacity(!canAdvance && !busy ? 0.18 : 1), in: Capsule()) } .buttonStyle(PressableButtonStyle()) .disabled(busy || !canAdvance) @@ -346,12 +224,11 @@ struct OnboardingFlowView: View { private func pageTitle(_ title: String, _ subtitle: String) -> some View { VStack(alignment: .leading, spacing: 7) { Text(title) - .font(.system(size: 30, weight: .bold)) - .tracking(-0.6) - .foregroundStyle(.primary) + .displayFont(32) + .foregroundStyle(WTheme.ink) Text(subtitle) .font(.system(size: 15.5)) - .foregroundStyle(.secondary) + .foregroundStyle(WTheme.inkSecondary) .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/ios/Warmbly/Features/Auth/OrgPickerView.swift b/ios/Warmbly/Features/Auth/OrgPickerView.swift index 1212eeca4..3b30481ac 100644 --- a/ios/Warmbly/Features/Auth/OrgPickerView.swift +++ b/ios/Warmbly/Features/Auth/OrgPickerView.swift @@ -42,12 +42,12 @@ struct OrgPickerView: View { HStack(spacing: 12) { HStack(spacing: 8) { WarmblyLogo() - .fill(.white) + .fill(WTheme.onSky) .frame(width: 27, height: 28) Text("Warmbly") .font(.system(size: 20, weight: .heavy)) .tracking(-0.4) - .foregroundStyle(.white) + .foregroundStyle(WTheme.onSky) .fixedSize() } .shadow(color: Color(hex: 0x0C4A6E).opacity(0.25), radius: 8, y: 3) @@ -58,10 +58,10 @@ struct OrgPickerView: View { Task { await env.session.logout() } } .font(.system(size: 13.5, weight: .semibold)) - .foregroundStyle(.white.opacity(0.9)) + .foregroundStyle(WTheme.onSky.opacity(0.9)) .padding(.horizontal, 14) .frame(height: 34) - .background(.white.opacity(0.16), in: Capsule()) + .background(WTheme.skyTile(0.16), in: Capsule()) .buttonStyle(PressableButtonStyle()) } .padding(.horizontal, 16) @@ -74,17 +74,17 @@ struct OrgPickerView: View { VStack(spacing: 12) { ZStack { - Circle().fill(.white.opacity(0.16)) - Circle().strokeBorder(.white.opacity(0.3), lineWidth: 1) + Circle().fill(WTheme.skyTile(0.16)) + Circle().strokeBorder(WTheme.skyEdge(0.3), lineWidth: 1) Image(systemName: "person.2.fill") .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(.white) + .foregroundStyle(WTheme.onSky) } .frame(width: 54, height: 54) Text("Choose a workspace") .font(.system(size: 15.5, weight: .bold)) - .foregroundStyle(.white) + .foregroundStyle(WTheme.onSky) } .shadow(color: Color(hex: 0x0C4A6E).opacity(0.25), radius: 6, y: 2) .scaleEffect(badgeAppeared ? 1 : 0.9) diff --git a/ios/Warmbly/Features/Auth/ServerConfigSheet.swift b/ios/Warmbly/Features/Auth/ServerConfigSheet.swift new file mode 100644 index 000000000..da17c198c --- /dev/null +++ b/ios/Warmbly/Features/Auth/ServerConfigSheet.swift @@ -0,0 +1,491 @@ +import SwiftUI + +/// Where the app signs in: Warmbly Cloud unless someone picks their own +/// server. The app proves itself with App Attest rather than a captcha; only a +/// dev backend takes a bypass token, for the simulator, which cannot attest. +struct ServerConfigSheet: View { + @Environment(\.dismiss) private var dismiss + + enum Choice: Hashable { case cloud, selfHosted, local } + + @State private var choice: Choice + @State private var customOrigin: String + @State private var turnstile = AppConfig.turnstileToken + @State private var realtime = AppConfig.realtimeOriginOverride + @State private var showAdvanced = false + @State private var probe: Probe = .idle + @State private var saved = 0 + + @FocusState private var focused: Field? + private enum Field { case origin, turnstile, realtime } + + enum Probe: Equatable { case idle, checking, reachable(ServerInfo?), unreachable } + + /// The slice of `GET /v1/auth/config` the sheet shows. + struct ServerInfo: Decodable, Equatable { + let captcha: Bool + let appAttest: Bool? + let selfHosted: Bool? + let brand: Brand? + struct Brand: Decodable, Equatable { let name: String? } + } + + /// False only once the chosen server said it verifies no captcha. + private var needsCaptcha: Bool { + if case .reachable(let info?) = probe { return info.captcha } + return true + } + + /// A server that wants a captcha but takes no App Attest proof: email sign-in is web-only there. + private var emailSignInOff: Bool { + if case .reachable(let info?) = probe { return info.captcha && info.appAttest != true } + return false + } + + /// A dev backend's bypass token, for the simulator (which cannot attest) or a server without App Attest. + private var asksBypassToken: Bool { (choice == .local && needsCaptcha) || (choice == .selfHosted && emailSignInOff) } + + init() { + let origin = AppConfig.serverOrigin + switch origin { + case AppConfig.cloudOrigin: + _choice = State(initialValue: .cloud) + _customOrigin = State(initialValue: "") + case AppConfig.localOrigin: + _choice = State(initialValue: .local) + _customOrigin = State(initialValue: "") + default: + _choice = State(initialValue: .selfHosted) + _customOrigin = State(initialValue: origin) + } + } + + /// The origin the current choice resolves to, or nil while it is unusable. + private var resolvedOrigin: String? { + switch choice { + case .cloud: return AppConfig.cloudOrigin + case .local: return AppConfig.localOrigin + case .selfHosted: + let value = AppConfig.normalizeOrigin(customOrigin) + guard let url = URL(string: value), url.host() != nil else { return nil } + return value + } + } + + private var showsLocal: Bool { + #if DEBUG + return true + #else + return choice == .local + #endif + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + header + .padding(.bottom, 22) + + options + + status + .padding(.top, 10) + .padding(.horizontal, 6) + + if choice != .cloud { + advanced + .padding(.top, 26) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding(.horizontal, 20) + .padding(.top, 22) + .padding(.bottom, 16) + } + .scrollBounceBehavior(.basedOnSize) + .scrollDismissesKeyboard(.interactively) + .safeAreaInset(edge: .bottom) { saveBar } + .background(WTheme.groupedPage) + .presentationBackground(WTheme.groupedPage) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .presentationCornerRadius(30) + .animation(.spring(response: 0.42, dampingFraction: 0.86), value: choice) + .animation(.spring(response: 0.4, dampingFraction: 0.86), value: showAdvanced) + .animation(.spring(response: 0.4, dampingFraction: 0.86), value: needsCaptcha) + .sensoryFeedback(.selection, trigger: choice) + .sensoryFeedback(.success, trigger: saved) + .task(id: resolvedOrigin) { await check(resolvedOrigin) } + } + + // MARK: Header + + private var header: some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Server") + .displayFont(28) + .foregroundStyle(WTheme.ink) + Text("Where this app signs in.") + .font(.system(size: 15)) + .foregroundStyle(WTheme.inkSecondary) + } + Spacer(minLength: 12) + Button { dismiss() } label: { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(WTheme.inkSecondary) + .frame(width: 32, height: 32) + .background(WTheme.fill, in: Circle()) + } + .buttonStyle(PressableButtonStyle()) + .accessibilityLabel("Close") + } + } + + // MARK: Options + + private var options: some View { + VStack(spacing: 0) { + optionRow( + .cloud, + title: "Warmbly Cloud", + subtitle: "Hosted by Warmbly", + badge: "Default" + ) { + WarmblyLogo() + .fill(WTheme.accent) + .frame(width: 17, height: 18) + .frame(width: 34, height: 34) + .background(WTheme.accent.opacity(0.13), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + + divider + + VStack(spacing: 0) { + optionRow(.selfHosted, title: "Self-hosted", subtitle: "Your own Warmbly instance") { + SettingsIcon(symbol: "server.rack", color: WTheme.special, size: 34) + } + if choice == .selfHosted { + originField + .padding(.leading, 62) + .padding(.trailing, 14) + .padding(.bottom, 14) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + + if showsLocal { + divider + optionRow(.local, title: "Local development", subtitle: "localhost:8080 on this Mac") { + SettingsIcon(symbol: "laptopcomputer", color: WTheme.warning, size: 34) + } + } + } + .background(WTheme.groupedCard, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + } + + private var divider: some View { + Rectangle() + .fill(WTheme.hairlineStrong) + .frame(height: 0.5) + .padding(.leading, 62) + } + + private func optionRow( + _ value: Choice, + title: String, + subtitle: String, + badge: String? = nil, + @ViewBuilder icon: () -> some View + ) -> some View { + let selected = choice == value + return Button { + select(value) + } label: { + HStack(spacing: 14) { + icon() + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 7) { + Text(title) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(WTheme.ink) + if let badge { + Text(badge) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(WTheme.accent) + .padding(.horizontal, 7) + .padding(.vertical, 2.5) + .background(WTheme.accentSoft, in: Capsule()) + } + } + Text(subtitle) + .font(.system(size: 13.5)) + .foregroundStyle(WTheme.inkSecondary) + } + Spacer(minLength: 8) + RadioMark(on: selected) + } + .padding(.horizontal, 14) + .padding(.vertical, 13) + .contentShape(Rectangle()) + } + .buttonStyle(MoreRowPressStyle()) + .accessibilityAddTraits(selected ? .isSelected : []) + } + + private var originField: some View { + HStack(spacing: 8) { + Image(systemName: "link") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(WTheme.inkTertiary) + TextField("warmbly.yourcompany.com", text: $customOrigin) + .font(.system(size: 15)) + .foregroundStyle(WTheme.ink) + .keyboardType(.URL) + .textContentType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.done) + .focused($focused, equals: .origin) + } + .padding(.horizontal, 12) + .frame(height: 44) + .background(WTheme.fill, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(focused == .origin ? WTheme.accent : .clear, lineWidth: 1.5) + ) + .animation(.easeOut(duration: 0.18), value: focused == .origin) + } + + // MARK: Status + + private var status: some View { + HStack(spacing: 7) { + Group { + switch probe { + case .checking, .idle: + ProgressView().controlSize(.mini) + case .reachable: + Circle().fill(WTheme.positive).frame(width: 7, height: 7) + case .unreachable: + Circle().fill(WTheme.negative).frame(width: 7, height: 7) + } + } + .frame(width: 12, height: 12) + Text(statusText) + .font(.system(size: 12.5)) + .foregroundStyle(WTheme.inkSecondary) + .lineLimit(1) + .truncationMode(.middle) + .contentTransition(.opacity) + if choice == .selfHosted, emailSignInOff { + Text("Email sign-in off") + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(WTheme.warning) + .padding(.horizontal, 7) + .padding(.vertical, 2.5) + .background(WTheme.warning.opacity(0.12), in: Capsule()) + .fixedSize() + .transition(.opacity.combined(with: .scale(scale: 0.85))) + } + } + .opacity(resolvedOrigin == nil ? 0 : 1) + .animation(.easeOut(duration: 0.2), value: probe) + } + + private var statusText: String { + let host = resolvedOrigin.flatMap { URL(string: $0)?.host(percentEncoded: false) } ?? "" + switch probe { + case .idle, .checking: return "Checking \(host)" + case .reachable(let info): + let name = info?.brand?.name.flatMap { $0.isEmpty ? nil : $0 } + return "Connected to \(choice == .selfHosted ? name ?? host : host)" + case .unreachable: return "Can't reach \(host)" + } + } + + private func check(_ origin: String?) async { + guard let origin, let url = URL(string: origin + "/v1/auth/config") else { + probe = .idle + return + } + probe = .checking + try? await Task.sleep(for: .milliseconds(450)) + guard !Task.isCancelled else { return } + var request = URLRequest(url: url, timeoutInterval: 5) + request.httpMethod = "GET" + let result: Probe + do { + let (data, response) = try await URLSession.shared.data(for: request) + let status = (response as? HTTPURLResponse)?.statusCode ?? 500 + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + // An older server without the endpoint still answered. + result = status < 500 ? .reachable(try? decoder.decode(ServerInfo.self, from: data)) : .unreachable + } catch { + result = .unreachable + } + guard !Task.isCancelled else { return } + probe = result + } + + // MARK: Advanced + + private var advanced: some View { + VStack(alignment: .leading, spacing: 10) { + Button { + showAdvanced.toggle() + } label: { + HStack(spacing: 6) { + Text("Advanced") + .font(.system(size: 14, weight: .semibold)) + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .bold)) + .rotationEffect(.degrees(showAdvanced ? 180 : 0)) + } + .foregroundStyle(WTheme.inkSecondary) + .padding(.horizontal, 6) + .contentShape(Rectangle()) + } + .buttonStyle(PressableButtonStyle()) + + if showAdvanced { + VStack(spacing: 0) { + if asksBypassToken { + advancedField( + "Captcha bypass", + placeholder: "Dev bypass token", + text: $turnstile, + field: .turnstile + ) + Rectangle() + .fill(WTheme.hairlineStrong) + .frame(height: 0.5) + .padding(.leading, 14) + } + advancedField( + "Realtime server", + placeholder: "Same as server", + text: $realtime, + field: .realtime + ) + .keyboardType(.URL) + } + .background(WTheme.groupedCard, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .transition(.opacity.combined(with: .move(edge: .top))) + + Text(advancedHint) + .font(.system(size: 12.5)) + .foregroundStyle(WTheme.inkTertiary) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 6) + .transition(.opacity) + } + } + } + + private func advancedField(_ title: String, placeholder: String, text: Binding, field: Field) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(focused == field ? WTheme.accent : WTheme.inkSecondary) + TextField(placeholder, text: text) + .font(.system(size: 15)) + .foregroundStyle(WTheme.ink) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($focused, equals: field) + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + .contentShape(Rectangle()) + .onTapGesture { focused = field } + .animation(.easeOut(duration: 0.18), value: focused == field) + } + + // MARK: Save + + private var saveBar: some View { + Button(action: save) { + Text(choice == .cloud ? "Use Warmbly Cloud" : "Save") + .font(.system(size: 16.5, weight: .semibold)) + .foregroundStyle(WTheme.inkInverse) + .contentTransition(.opacity) + .frame(maxWidth: .infinity) + .frame(height: 54) + .background(WTheme.ink.opacity(resolvedOrigin == nil ? 0.18 : 1), in: Capsule()) + } + .buttonStyle(PressableButtonStyle()) + .disabled(resolvedOrigin == nil) + .padding(.horizontal, 20) + .padding(.top, 10) + .padding(.bottom, 8) + .background(WTheme.groupedPage) + } + + private var advancedHint: String { + if emailSignInOff, choice == .selfHosted { + return "This server asks for a captcha but takes no App Attest proof. On a dev backend, enter its TURNSTILE_BYPASS_TOKEN; otherwise sign in with Apple, Google or a passkey, or set APP_ATTEST_APP_IDS on the server." + } + return asksBypassToken + ? "The bypass token is the dev stack's TURNSTILE_BYPASS_TOKEN. The realtime field is only needed when live updates run on a separate host." + : "Only needed when your server runs live updates on a separate host." + } + + private func select(_ value: Choice) { + guard choice != value else { return } + choice = value + switch value { + case .cloud: + focused = nil + showAdvanced = false + case .selfHosted: + if turnstile == AppConfig.localTurnstileToken { turnstile = "" } + if customOrigin.isEmpty { focused = .origin } + case .local: + focused = nil + if turnstile.isEmpty { turnstile = AppConfig.localTurnstileToken } + } + } + + private func save() { + guard let origin = resolvedOrigin else { return } + AppConfig.serverOrigin = origin + if choice == .cloud { + AppConfig.turnstileToken = "" + AppConfig.realtimeOriginOverride = "" + } else { + AppConfig.turnstileToken = asksBypassToken ? turnstile.trimmingCharacters(in: .whitespaces) : "" + AppConfig.realtimeOriginOverride = AppConfig.normalizeOrigin(realtime) + } + saved += 1 + dismiss() + } +} + +/// A radio dot that fills with a tick when chosen. +private struct RadioMark: View { + let on: Bool + + var body: some View { + ZStack { + Circle() + .strokeBorder(WTheme.hairlineStrong, lineWidth: 1.5) + .opacity(on ? 0 : 1) + Circle() + .fill(WTheme.accent) + .scaleEffect(on ? 1 : 0.3) + .opacity(on ? 1 : 0) + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + .scaleEffect(on ? 1 : 0.4) + .opacity(on ? 1 : 0) + } + .frame(width: 22, height: 22) + .animation(.spring(response: 0.34, dampingFraction: 0.62), value: on) + } +} diff --git a/ios/Warmbly/Features/Auth/SocialSignIn.swift b/ios/Warmbly/Features/Auth/SocialSignIn.swift index ae110d92f..675e5ba53 100644 --- a/ios/Warmbly/Features/Auth/SocialSignIn.swift +++ b/ios/Warmbly/Features/Auth/SocialSignIn.swift @@ -31,8 +31,10 @@ struct SocialSignInRow: View { handleApple(result) } .signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black) - .frame(height: 56) - .clipShape(RoundedRectangle(cornerRadius: 17)) + // The UIKit button keeps the style it was built with; rebuild it when the theme lands. + .id(colorScheme) + .frame(height: 54) + .clipShape(Capsule()) .disabled(busy) } @@ -48,12 +50,12 @@ struct SocialSignInRow: View { GoogleGlyph() .frame(width: 19, height: 19) Text("Continue with Google") - .font(.system(size: 17, weight: .medium)) + .font(.system(size: 16.5, weight: .medium)) } .frame(maxWidth: .infinity) - .frame(height: 56) - .background(Color(.systemBackground), in: RoundedRectangle(cornerRadius: 17)) - .overlay(RoundedRectangle(cornerRadius: 17).strokeBorder(Color(.separator).opacity(0.7), lineWidth: 1.2)) + .frame(height: 54) + .background(WTheme.raised, in: Capsule()) + .overlay(Capsule().strokeBorder(WTheme.hairlineStrong, lineWidth: 1)) } .buttonStyle(PressableButtonStyle()) .disabled(busy) diff --git a/ios/Warmbly/Features/Auth/WorkspaceCreateFlow.swift b/ios/Warmbly/Features/Auth/WorkspaceCreateFlow.swift index f5c80826e..f72fade03 100644 --- a/ios/Warmbly/Features/Auth/WorkspaceCreateFlow.swift +++ b/ios/Warmbly/Features/Auth/WorkspaceCreateFlow.swift @@ -47,7 +47,6 @@ struct WorkspaceCreateFlow: View { @State private var busy = false @State private var errorMessage: String? @State private var errorPulse = 0 - @State private var badgeAppeared = false @FocusState private var nameFocused: Bool @FocusState private var focusedInvite: UUID? @@ -80,164 +79,35 @@ struct WorkspaceCreateFlow: View { // MARK: - Sky chrome private var topBar: some View { - HStack(spacing: 12) { - if page != .name { - Button { - goBack() - } label: { - Image(systemName: "chevron.left") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 40, height: 40) - .background(.white.opacity(0.16), in: Circle()) - } - .buttonStyle(PressableButtonStyle()) - .accessibilityLabel("Back") - .transition(.opacity.combined(with: .scale(scale: 0.7))) - } - - HStack(spacing: 8) { - WarmblyLogo() - .fill(.white) - .frame(width: 27, height: 28) - Text("Warmbly") - .font(.system(size: 20, weight: .heavy)) - .tracking(-0.4) - .foregroundStyle(.white) - .fixedSize() - } - .shadow(color: Color(hex: 0x0C4A6E).opacity(0.25), radius: 8, y: 3) - - Spacer() - + FlowTopBar( + onBack: page == .name ? nil : { goBack() }, + progress: (page.rawValue, Page.allCases.count) + ) { if context == .gate { // Nothing to go back to without a workspace; the only exit // is signing out, like the web's select-org gate. - Button("Sign out") { + FlowSkyPill(title: "Sign out") { Task { await env.session.logout() } } - .font(.system(size: 13.5, weight: .semibold)) - .foregroundStyle(.white.opacity(0.9)) - .padding(.horizontal, 14) - .frame(height: 34) - .background(.white.opacity(0.16), in: Capsule()) - .buttonStyle(PressableButtonStyle()) .disabled(busy) } else { // Presented as a cover: the app's standard circular close. - Button { - onClose() - } label: { - Image(systemName: "xmark") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 40, height: 40) - .background(.white.opacity(0.16), in: Circle()) - } - .buttonStyle(PressableButtonStyle()) - .accessibilityLabel("Cancel") - .disabled(busy) + FlowSkyButton(symbol: "xmark", label: "Cancel") { onClose() } + .disabled(busy) } } - .padding(.horizontal, 16) - .padding(.top, 6) - .animation(.spring(response: 0.45, dampingFraction: 0.86), value: page) } + /// The sky between the top bar and the sheet: the page's icon and name + /// when there is room, plain sky while the keyboard is up. private var skyArea: some View { - ZStack { - Color.clear - .contentShape(Rectangle()) - .onTapGesture { - nameFocused = false - focusedInvite = nil - } - - HeroFlightScene() - - pageBadge - .scaleEffect(badgeAppeared ? 1 : 0.9) - .opacity(badgeAppeared ? 1 : 0) - } - .frame(maxWidth: .infinity, minHeight: 40, maxHeight: .infinity) - .animation(.spring(response: 0.5, dampingFraction: 0.85), value: page) - .onAppear { - withAnimation(.spring(response: 0.7, dampingFraction: 0.75).delay(0.05)) { - badgeAppeared = true - } + FlowSkyStage(icon: page.icon, title: page.skyLabel) { + nameFocused = false + focusedInvite = nil } + .frame(maxWidth: .infinity, minHeight: 88, maxHeight: .infinity) } - private var pageBadge: some View { - ViewThatFits(in: .vertical) { - VStack(spacing: 12) { - ZStack { - Circle().fill(.white.opacity(0.16)) - Circle().strokeBorder(.white.opacity(0.3), lineWidth: 1) - Image(systemName: page.icon) - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(.white) - .contentTransition(.symbolEffect(.replace)) - } - .frame(width: 54, height: 54) - - VStack(spacing: 4) { - Text(page.skyLabel) - .font(.system(size: 15.5, weight: .bold)) - .foregroundStyle(.white) - .contentTransition(.opacity) - Text("Step \(page.rawValue + 1) of \(Page.allCases.count)") - .font(.system(size: 12)) - .foregroundStyle(.white.opacity(0.7)) - .contentTransition(.numericText()) - } - - pageSegments - } - .padding(.vertical, 16) - - HStack(spacing: 10) { - ZStack { - Circle().fill(.white.opacity(0.16)) - Circle().strokeBorder(.white.opacity(0.3), lineWidth: 1) - Image(systemName: page.icon) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.white) - .contentTransition(.symbolEffect(.replace)) - } - .frame(width: 34, height: 34) - - VStack(alignment: .leading, spacing: 4) { - Text(page.skyLabel) - .font(.system(size: 13.5, weight: .semibold)) - .foregroundStyle(.white) - .contentTransition(.opacity) - HStack(spacing: 7) { - pageSegments - Text("Step \(page.rawValue + 1) of \(Page.allCases.count)") - .font(.system(size: 11)) - .foregroundStyle(.white.opacity(0.7)) - .contentTransition(.numericText()) - } - } - } - .padding(.vertical, 8) - } - .shadow(color: Color(hex: 0x0C4A6E).opacity(0.25), radius: 6, y: 2) - } - - private var pageSegments: some View { - HStack(spacing: 6) { - ForEach(Page.allCases, id: \.rawValue) { segment in - Capsule() - .fill(.white.opacity(segment.rawValue <= page.rawValue ? 0.95 : 0.3)) - .frame(width: segment == page ? 26 : 14, height: 4) - } - } - } - - // MARK: - The sheet - private var sheet: some View { VStack(alignment: .leading, spacing: 0) { // Always a ScrollView: ViewThatFits swaps identities when the @@ -291,7 +161,7 @@ struct WorkspaceCreateFlow: View { } label: { Group { if busy { - ProgressView().tint(.white) + ProgressView().tint(WTheme.inkInverse) } else { HStack(spacing: 8) { Text(page == .invite ? "Create workspace" : "Continue") @@ -302,21 +172,11 @@ struct WorkspaceCreateFlow: View { } } } - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(.white) + .font(.system(size: 16.5, weight: .medium)) + .foregroundStyle(WTheme.inkInverse) .frame(maxWidth: .infinity) - .frame(height: 56) - .background( - LinearGradient( - colors: !canAdvance && !busy - ? [WTheme.accent.opacity(0.35), WTheme.accent.opacity(0.35)] - : [Color(hex: 0x0EA5E9), Color(hex: 0x0284C7)], - startPoint: .top, - endPoint: .bottom - ), - in: RoundedRectangle(cornerRadius: 17) - ) - .shadow(color: !canAdvance ? .clear : Color(hex: 0x0284C7).opacity(0.32), radius: 12, y: 6) + .frame(height: 54) + .background(WTheme.ink.opacity(!canAdvance && !busy ? 0.18 : 1), in: Capsule()) } .buttonStyle(PressableButtonStyle()) .disabled(busy || !canAdvance) @@ -344,12 +204,11 @@ struct WorkspaceCreateFlow: View { private func pageTitle(_ title: String, _ subtitle: String) -> some View { VStack(alignment: .leading, spacing: 7) { Text(title) - .font(.system(size: 30, weight: .bold)) - .tracking(-0.6) - .foregroundStyle(.primary) + .displayFont(32) + .foregroundStyle(WTheme.ink) Text(subtitle) .font(.system(size: 15.5)) - .foregroundStyle(.secondary) + .foregroundStyle(WTheme.inkSecondary) .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/ios/Warmbly/Features/CRM/CRMComponents.swift b/ios/Warmbly/Features/CRM/CRMComponents.swift index 7d3899cd8..a0210ad91 100644 --- a/ios/Warmbly/Features/CRM/CRMComponents.swift +++ b/ios/Warmbly/Features/CRM/CRMComponents.swift @@ -110,117 +110,6 @@ extension CRMMeeting { } } -// MARK: - Browser chrome - -/// Contacts-style search pill for the CRM browsers: a rounded 44pt field with -/// a leading symbol (a drawer hamburger when `onMenu` is provided), clear -/// button and the presence stack riding the trailing edge, plus optional -/// circular accessories (create/close buttons) beside it. -struct CRMSearchBar: View { - @Binding var query: String - let prompt: String - private let onMenu: (() -> Void)? - private let menuLabel: String - private let accessory: Accessory - @FocusState private var focused: Bool - - init( - query: Binding, - prompt: String, - onMenu: (() -> Void)? = nil, - menuLabel: String = "Open menu", - @ViewBuilder accessory: () -> Accessory - ) { - _query = query - self.prompt = prompt - self.onMenu = onMenu - self.menuLabel = menuLabel - self.accessory = accessory() - } - - var body: some View { - HStack(spacing: 10) { - HStack(spacing: 6) { - if let onMenu { - Button { - focused = false - onMenu() - } label: { - Image(systemName: "line.3.horizontal") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(.primary) - .frame(width: 38, height: 38) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel(menuLabel) - } else { - Image(systemName: "magnifyingglass") - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(.secondary) - .frame(width: 38, height: 38) - } - - TextField(prompt, text: $query) - .font(.subheadline) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .submitLabel(.search) - .focused($focused) - - if !query.isEmpty { - Button { - query = "" - } label: { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 16)) - .foregroundStyle(.tertiary) - } - .buttonStyle(.plain) - .accessibilityLabel("Clear search") - } - - PresenceAvatars() - .padding(.trailing, 8) - } - .frame(height: 44) - .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 22, style: .continuous)) - - accessory - } - .padding(.horizontal, 12) - .padding(.top, 4) - .padding(.bottom, 6) - } -} - -extension CRMSearchBar where Accessory == EmptyView { - init(query: Binding, prompt: String, onMenu: (() -> Void)? = nil, menuLabel: String = "Open menu") { - self.init(query: query, prompt: prompt, onMenu: onMenu, menuLabel: menuLabel) { EmptyView() } - } -} - -/// 44pt circular action button riding beside the search pill (create/close). -struct CRMCircleButton: View { - let symbol: String - let label: String - var weight: Font.Weight = .medium - var size: CGFloat = 17 - let action: () -> Void - - var body: some View { - Button(action: action) { - Image(systemName: symbol) - .font(.system(size: size, weight: weight)) - .foregroundStyle(.primary) - .frame(width: 44, height: 44) - .background(Color(.secondarySystemBackground), in: Circle()) - } - .buttonStyle(TapScaleStyle()) - .accessibilityLabel(label) - } -} - // MARK: - Browser shell /// Shared constants for the CRM full-screen browsers. @@ -237,7 +126,7 @@ enum CRMBrowser { struct CRMBrowserShell: View { @Binding var sidebarOpen: Bool let main: () -> Main - /// Built with the top safe-area inset so the sky hero can run under it. + /// Built with the top safe-area inset so the header can run under it. let drawer: (CGFloat) -> Drawer @State private var sidebarDrag: CGFloat = 0 @@ -266,7 +155,7 @@ struct CRMBrowserShell: View { } ) if sidebarOpen { - Color.black.opacity(0.32) + Color.black.opacity(0.22) .ignoresSafeArea() .transition(.opacity) .onTapGesture { close() } @@ -274,9 +163,13 @@ struct CRMBrowserShell: View { drawer(geo.safeAreaInsets.top) .frame(width: CRMBrowser.sidebarWidth) .frame(maxHeight: .infinity) - .background(Color(.systemBackground)) - .clipShape(UnevenRoundedRectangle(bottomTrailingRadius: 26, topTrailingRadius: 26, style: .continuous)) - .shadow(color: .black.opacity(sidebarOpen ? 0.22 : 0), radius: 30, x: 6, y: 0) + .background(WTheme.canvas) + .clipShape(UnevenRoundedRectangle(bottomTrailingRadius: 22, topTrailingRadius: 22, style: .continuous)) + .overlay { + UnevenRoundedRectangle(bottomTrailingRadius: 22, topTrailingRadius: 22, style: .continuous) + .strokeBorder(WTheme.hairlineStrong, lineWidth: 1) + .opacity(sidebarOpen ? 1 : 0) + } .ignoresSafeArea() .offset(x: (sidebarOpen ? 0 : -CRMBrowser.sidebarWidth - 40) + sidebarDrag) .gesture( @@ -293,6 +186,12 @@ struct CRMBrowserShell: View { } } .sensoryFeedback(.impact(weight: .light), trigger: sidebarOpen) + #if DEBUG + // Scripted debug runs can open the drawer (WARMBLY_DEBUG_DRAWER=1). + .onAppear { + if ProcessInfo.processInfo.environment["WARMBLY_DEBUG_DRAWER"] != nil { sidebarOpen = true } + } + #endif } private func close() { @@ -305,9 +204,8 @@ struct CRMBrowserShell: View { // MARK: - Drawer chrome -/// Drawer layout shared by the CRM browsers, mirroring the contacts/leads -/// sidebars: a slim sky hero (logo + heavy title + optional subtitle + badge -/// strip) over a rounded white sheet that scrolls the scope rows. +/// Drawer layout shared by the CRM browsers, mirroring the contacts sidebar: +/// a title, optional subtitle and soft count chips over the scope rows. struct CRMDrawer: View { let title: String var subtitle: String? = nil @@ -323,71 +221,65 @@ struct CRMDrawer: View { rows() } .padding(.horizontal, 12) - .padding(.top, 8) + .padding(.top, 4) .padding(.bottom, 40) } - .background { - UnevenRoundedRectangle(topLeadingRadius: 24, topTrailingRadius: 24, style: .continuous) - .fill(Color(.systemBackground)) - .shadow(color: .black.opacity(0.1), radius: 14, y: -4) - } } - .background(alignment: .top) { - AirSkyWash().frame(height: 360) - } - .background(Color(.systemBackground)) + .background(WTheme.canvas) } private var hero: some View { - VStack(alignment: .leading, spacing: 13) { - HStack(spacing: 8) { - WarmblyLogo() - .fill(.white) - .frame(width: 21, height: 21 * (764 / 746)) + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { Text(title) - .font(.system(size: 17.5, weight: .heavy)) - .tracking(-0.4) - .foregroundStyle(.white) - } - if let subtitle, !subtitle.isEmpty { - Text(subtitle) - .font(.footnote.weight(.medium)) - .foregroundStyle(.white.opacity(0.82)) - .lineLimit(1) + .displayFont(24, weight: .semibold) + .foregroundStyle(WTheme.ink) + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.system(size: 14)) + .foregroundStyle(WTheme.inkSecondary) + .lineLimit(1) + } } HStack(spacing: 6) { badges() } } .padding(.horizontal, 20) - .padding(.top, topInset + 12) - .padding(.bottom, 16) + .padding(.top, topInset + 18) + .padding(.bottom, 6) .frame(maxWidth: .infinity, alignment: .leading) } } -/// Translucent count badge on the drawer hero. +/// Hairline count chip on the drawer header; `live` swaps the symbol for a +/// pulsing state dot. struct CRMDrawerBadge: View { let symbol: String let text: String var live = false - var pingColor: Color = .white + var pingColor: Color = WTheme.accent var body: some View { HStack(spacing: 5) { - Image(systemName: symbol) - .font(.system(size: 10.5, weight: .semibold)) - .foregroundStyle(.white.opacity(0.9)) - .modifier(PingEffect(active: live, color: pingColor)) + if live { + LiveDot(color: pingColor, size: 5.5) + .frame(width: 10) + } else { + Image(systemName: symbol) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(WTheme.inkSecondary) + } Text(text) - .font(.footnote.weight(.medium)) + .font(.system(size: 13, weight: .medium)) .monospacedDigit() - .foregroundStyle(.white) + .foregroundStyle(WTheme.inkSecondary) .contentTransition(.numericText()) + .lineLimit(1) } .padding(.horizontal, 10) - .padding(.vertical, 5.5) - .background(.white.opacity(0.16), in: Capsule()) + .frame(height: 28) + .background(WTheme.fill, in: Capsule()) } } @@ -405,8 +297,8 @@ struct CRMDrawerSectionLabel: View { } } -/// Scope pill row in a CRM drawer: leading icon / dot / pulsing dot, title, -/// trailing live count, and the sliding sky capsule via matchedGeometryEffect. +/// Scope row in a CRM drawer: leading icon / dot / pulsing dot, title, +/// trailing mono count, and the sliding fill via matchedGeometryEffect. /// Rows cascade in with a capped stagger when the drawer reveals. struct CRMDrawerRow: View { var icon: String? = nil @@ -427,44 +319,41 @@ struct CRMDrawerRow: View { HStack(spacing: 13) { Group { if let pingDot { - Circle() - .fill(pingDot) - .frame(width: 11, height: 11) - .modifier(PingEffect(active: true, color: pingDot)) + LiveDot(color: pingDot, size: 8) } else if let dot { Circle() .fill(dot) - .frame(width: 11, height: 11) + .frame(width: 9, height: 9) } else if let icon { Image(systemName: icon) - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(selected ? WTheme.accent : Color.secondary) + .font(.system(size: 14.5, weight: .regular)) + .foregroundStyle(selected ? WTheme.ink : WTheme.inkSecondary) } } .frame(width: 24) Text(title) - .font(.subheadline.weight(selected ? .semibold : .medium)) - .foregroundStyle(selected ? WTheme.accent : Color.primary) + .font(.system(size: 15, weight: selected ? .medium : .regular)) + .foregroundStyle(WTheme.ink) .lineLimit(1) Spacer(minLength: 8) if let count, count > 0 { Text(WFormat.compact(count)) - .font(.footnote.weight(selected ? .semibold : .medium)) + .font(WFont.meta(11.5, weight: .medium)) .monospacedDigit() - .foregroundStyle(selected ? WTheme.accent : Color.secondary) + .foregroundStyle(selected ? WTheme.ink : WTheme.inkTertiary) .contentTransition(.numericText()) } } - .padding(.horizontal, 16) - .frame(height: 44) + .padding(.horizontal, 14) + .frame(height: 42) .background { if selected { - Capsule() - .fill(Tone.sky.background) + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(WTheme.fill) .matchedGeometryEffect(id: matchedID, in: namespace) } } - .contentShape(Capsule()) + .contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } .buttonStyle(TapScaleStyle()) .opacity(revealed ? 1 : 0) @@ -478,48 +367,6 @@ struct CRMDrawerRow: View { } } -/// Tracked-uppercase caption as a bare list row (never a sticky `Section` -/// header): an optional stage-colored dot or pulsing tone accent plus a live -/// count. Whitespace above is the group separator. -struct CRMListCaption: View { - let title: String - var count: Int? = nil - var tone: Tone? = nil - var dotColor: Color? = nil - var ping = false - - var body: some View { - HStack(spacing: 6) { - if ping, let tone { - CRMPingDot(color: tone.color) - .padding(.trailing, 2) - } else if let dotColor { - Circle() - .fill(dotColor) - .frame(width: 7, height: 7) - } - Text(title.uppercased()) - .font(.caption.weight(.semibold)) - .tracking(0.9) - .foregroundStyle(tone?.color ?? Color.secondary) - if let count { - Text(WFormat.compact(count)) - .font(.caption.weight(.semibold)) - .monospacedDigit() - .foregroundStyle(.tertiary) - .contentTransition(.numericText()) - } - Spacer(minLength: 0) - } - .padding(.horizontal, 20) - .padding(.top, 18) - .padding(.bottom, 6) - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - .listRowBackground(Color(.systemBackground)) - } -} - /// End-of-list marker: the exact total for the current scope/search. struct CRMEndMarker: View { let count: Int @@ -536,44 +383,52 @@ struct CRMEndMarker: View { HStack { Spacer() Text("\(count) \(count == 1 ? singular : plural)") - .font(.footnote) + .font(WFont.meta(10.5, weight: .medium)) .monospacedDigit() - .foregroundStyle(.tertiary) + .foregroundStyle(WTheme.inkTertiary) Spacer() } - .padding(.vertical, 10) + .padding(.vertical, 14) .listRowSeparator(.hidden) - .listRowBackground(Color(.systemBackground)) + .listRowBackground(Color.clear) } } // MARK: - Chips and accents -/// Colored capsule chip driven by a server hex color (stages, task types). +/// Hairline chip keyed by a server hex color (stages, task types): the color +/// rides a leading dot, the label stays ink. struct CRMColorChip: View { let text: String var hex: String? var body: some View { let color = CRMHex.color(hex) ?? WTheme.paused - Text(text) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(color) - .padding(.horizontal, 8) - .padding(.vertical, 3.5) - .background(color.opacity(0.12), in: Capsule()) - .lineLimit(1) + HStack(spacing: 5) { + Circle() + .fill(color) + .frame(width: 6, height: 6) + Text(text) + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(WTheme.inkSecondary) + .lineLimit(1) + } + .padding(.horizontal, 8) + .padding(.vertical, 3.5) + .background(WTheme.raised, in: Capsule()) + .overlay(Capsule().strokeBorder(WTheme.hairlineStrong, lineWidth: 1)) } } -/// Small always-pulsing dot (overdue/red, today/sky accents). -struct CRMPingDot: View { - var color: Color +// MARK: - Field chrome - var body: some View { - Circle() - .fill(color) - .frame(width: 6, height: 6) - .modifier(PingEffect(active: true, color: color)) +extension View { + /// The search/input field surface: subtle band, hairline edge, 22pt radius. + func crmFieldChrome(radius: CGFloat = 22) -> some View { + background(WTheme.surface, in: RoundedRectangle(cornerRadius: radius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: radius, style: .continuous) + .strokeBorder(WTheme.hairlineStrong, lineWidth: 1) + ) } } diff --git a/ios/Warmbly/Features/CRM/CRMDealsView.swift b/ios/Warmbly/Features/CRM/CRMDealsView.swift index f4127d722..d679104dc 100644 --- a/ios/Warmbly/Features/CRM/CRMDealsView.swift +++ b/ios/Warmbly/Features/CRM/CRMDealsView.swift @@ -30,6 +30,8 @@ private enum CRMDealScope: String, CaseIterable, Identifiable { } } + var shortTitle: String { self == .all ? "All" : title } + var icon: String { switch self { case .all: "tray.full.fill" @@ -46,6 +48,9 @@ struct CRMDealsView: View { @State private var store = CRMDealsStore() @State private var editing: CRMDeal? @State private var sidebarOpen = false + @State private var shown = false + @State private var celebrate = 0 + @FocusState private var searchFocused: Bool private var canWrite: Bool { env.session.can(.manageContacts) } private var isSearching: Bool { !store.query.trimmingCharacters(in: .whitespaces).isEmpty } @@ -95,9 +100,9 @@ struct CRMDealsView: View { withAnimation(CRMBrowser.spring) { sidebarOpen = true } } - private func selectStatus(_ status: String?) { + private func selectStatus(_ status: String?, close: Bool = true) { withAnimation(.spring(response: 0.38, dampingFraction: 0.8)) { store.statusFilter = status } - closeSidebarSoon() + if close { closeSidebarSoon() } } private func selectPipeline(_ id: String) { @@ -117,54 +122,115 @@ struct CRMDealsView: View { private var mainPane: some View { @Bindable var store = store return VStack(spacing: 0) { - CRMSearchBar( + GlassHeaderBar( + title: "Deals", + subtitle: Text(store.selectedPipeline?.name ?? "Pipeline"), query: $store.query, - prompt: "Search deals", - onMenu: { openSidebar() }, - menuLabel: "Open deals menu" + searchPrompt: "Search deals", + searchFocused: $searchFocused, + menuLabel: "Open deals menu", + onMenu: openSidebar ) { - CRMCircleButton(symbol: "xmark", label: "Close deals", weight: .semibold, size: 15) { - dismiss() - } + GlassCircleButton(symbol: "xmark", label: "Close deals") { dismiss() } } - scopeCaption list } - .background(Color(.systemBackground)) + .background(WTheme.groupedPage) + .overlay { ConfettiBurst(trigger: celebrate) } + .sensoryFeedback(.success, trigger: celebrate) } - // MARK: Caption + // MARK: Pipeline card - private var scopeCaption: some View { - HStack(spacing: 6) { - Text(captionTitle) - .font(.caption.weight(.semibold)) - .tracking(0.9) - .foregroundStyle(.secondary) - if !isSearching, let count = store.statusCount(store.statusFilter), count > 0 { - Text(WFormat.compact(count)) - .font(.caption.weight(.semibold)) - .monospacedDigit() - .foregroundStyle(.tertiary) - .contentTransition(.numericText()) + private var currency: String? { store.displayCurrency } + + private var pipelineCard: some View { + let summary = store.summary + let openValue = summary?.openValue ?? 0 + let openCount = summary?.openCount ?? 0 + let won = summary?.wonCount ?? 0 + let lost = summary?.lostCount ?? 0 + let stages = store.orderedStages + // Open deals only; terminal stages hold closed deals and read as $0 here. + let values: [(CRMStage, Double)] = stages.compactMap { stage in + let open = store.deals(inStage: stage.id) + guard !open.isEmpty else { return nil } + return (stage, open.compactMap(\.value).reduce(0, +)) + } + let total = max(1, values.reduce(0) { $0 + $1.1 }) + return VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .center, spacing: 14) { + VStack(alignment: .leading, spacing: 4) { + Text("Open pipeline") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(WTheme.inkSecondary) + CountUpText(value: shown ? openValue : 0, format: { CRMFormat.compactCurrency($0.rounded(), code: currency) }) + .font(.system(size: 38, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(WTheme.ink) + .animation(.easeOut(duration: 1.1), value: shown) + .animation(.snappy, value: openValue) + Text("\(openCount) open \(openCount == 1 ? "deal" : "deals") · \(CRMFormat.compactCurrency(summary?.wonValue ?? 0, code: currency)) won") + .font(.system(size: 13.5)) + .monospacedDigit() + .foregroundStyle(WTheme.inkTertiary) + } + Spacer(minLength: 8) + if won + lost > 0 { + let rate = Double(won) / Double(won + lost) + SweepRing(fraction: rate, color: WTheme.positive, shown: shown, lineWidth: 8) { + VStack(spacing: -1) { + Text("\(Int((rate * 100).rounded()))%") + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(WTheme.ink) + Text("win rate") + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(WTheme.inkTertiary) + } + } + .frame(width: 84, height: 84) + } } - Spacer() - if isSearching, store.hasLoaded, let count = store.totalCount { - Text("\(count) found") - .font(.caption.weight(.semibold)) - .monospacedDigit() - .foregroundStyle(.secondary) - .contentTransition(.numericText()) + if !values.isEmpty { + GeometryReader { proxy in + HStack(spacing: 3) { + ForEach(Array(values.enumerated()), id: \.offset) { index, item in + if item.1 > 0 { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(CRMHex.color(item.0.color) ?? WTheme.paused) + .frame(width: shown ? max(8, (proxy.size.width - CGFloat(values.count) * 3) * CGFloat(item.1 / total)) : 0) + .animation(.spring(response: 0.85, dampingFraction: 0.8).delay(0.2 + Double(index) * 0.08), value: shown) + } + } + Spacer(minLength: 0) + } + } + .frame(height: 10) + VStack(spacing: 9) { + ForEach(Array(values.enumerated()), id: \.offset) { _, item in + let count = store.deals(inStage: item.0.id).count + HStack(spacing: 8) { + Circle().fill(CRMHex.color(item.0.color) ?? WTheme.paused).frame(width: 8, height: 8) + Text(item.0.name) + .font(.system(size: 14)) + .foregroundStyle(WTheme.inkSecondary) + .lineLimit(1) + Text("\(count)") + .font(.system(size: 13)) + .monospacedDigit() + .foregroundStyle(WTheme.inkTertiary) + Spacer(minLength: 4) + Text(CRMFormat.compactCurrency(item.1, code: currency)) + .font(.system(size: 14, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(WTheme.ink) + } + } + } } } - .padding(.horizontal, 20) - .padding(.top, 8) - .padding(.bottom, 2) - } - - private var captionTitle: String { - if isSearching { return "SEARCH RESULTS" } - return scope.title.uppercased() + .coverCard() } // MARK: List @@ -172,28 +238,59 @@ struct CRMDealsView: View { @ViewBuilder private var list: some View { if store.isLoading, !store.hasLoaded { - ScrollView { SkeletonRows(rows: 10) } + ScrollView { AnalyticsLoadingCards().padding(16) } } else if let error = store.errorMessage, store.deals.isEmpty { ErrorStateView(title: "Couldn't load deals", message: error) { await store.load(env.api) } - } else if store.deals.isEmpty { - emptyState } else { List { - if store.statusFilter == nil, !store.orderedStages.isEmpty { + Section { + VStack(spacing: 14) { + if !isSearching { + pipelineCard + } + PillSwitcher( + options: CRMDealScope.allCases, + selection: Binding(get: { scope }, set: { selectStatus($0.status, close: false) }), + label: \.shortTitle + ) + } + .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 0, trailing: 0)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + if store.deals.isEmpty { + Section { + emptyState + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } else if store.statusFilter == nil, !isSearching, !store.orderedStages.isEmpty { groupedByStage } else { - // The scope caption above already names the filter. - ForEach(store.deals) { deal in - row(deal) + Section { + ForEach(store.deals) { deal in + row(deal) + } + } header: { + CoverSectionHeader(title: isSearching ? "Results" : scope.title, count: store.deals.count) } } - CRMEndMarker(store.totalCount ?? store.deals.count, "deal") + Section { + CRMEndMarker(store.totalCount ?? store.deals.count, "deal") + .listRowBackground(Color.clear) + } } - .listStyle(.plain) + .listStyle(.insetGrouped) + .listSectionSpacing(18) + .contentMargins(.horizontal, 16, for: .scrollContent) + .contentMargins(.top, 0, for: .scrollContent) + .scrollContentBackground(.hidden) .scrollDismissesKeyboard(.immediately) + .defaultScrollAnchor(SettingsDebug.anchor) .refreshable { await store.load(env.api) } + .onAppear { shown = true } } } @@ -202,22 +299,28 @@ struct CRMDealsView: View { ForEach(store.orderedStages) { stage in let stageDeals = store.deals(inStage: stage.id) if !stageDeals.isEmpty { - CRMListCaption( - title: stage.name, - count: stageDeals.count, - dotColor: CRMHex.color(stage.color) ?? WTheme.paused - ) - ForEach(stageDeals) { deal in - // The eyebrow already names the stage; skip the row chip. - row(deal, showsStage: false) + Section { + ForEach(stageDeals) { deal in + row(deal, showsStage: false) + } + } header: { + CoverSectionHeader( + title: stage.name, + count: stageDeals.count, + color: CRMHex.color(stage.color) ?? WTheme.paused, + trailing: CRMFormat.compactCurrency(stageDeals.compactMap(\.value).reduce(0, +), code: currency) + ) } } } let closed = store.closedDeals if !closed.isEmpty { - CRMListCaption(title: "Closed", count: closed.count, tone: .slate) - ForEach(closed) { deal in - row(deal) + Section { + ForEach(closed) { deal in + row(deal) + } + } header: { + CoverSectionHeader(title: "Closed", count: closed.count, color: WTheme.paused) } } } @@ -225,20 +328,17 @@ struct CRMDealsView: View { @ViewBuilder private var emptyState: some View { if isSearching { - EmptyStateView(title: "No matching deals", message: "Try a different search.") + CoverEmptyState(symbol: "magnifyingglass", color: WTheme.inkSecondary, title: "No matching deals", message: "Try a different search.") } else { switch scope { case .all: - EmptyStateView( - title: "No deals yet", - message: "Deals created from campaigns or the web dashboard show up here." - ) + CoverEmptyState(symbol: "briefcase.fill", color: Tone.orange.color, title: "No deals yet", message: "Deals created from replies, campaigns or the web dashboard show up here.") case .open: - EmptyStateView(title: "No open deals", message: "Deals still in play show up here.") + CoverEmptyState(symbol: "circle.lefthalf.filled", color: WTheme.accent, title: "No open deals", message: "Deals still in play show up here.") case .won: - EmptyStateView(title: "No won deals yet", message: "Deals marked won land here.") + CoverEmptyState(symbol: "trophy.fill", color: WTheme.positive, title: "No wins yet", message: "Deals you mark won land here. The first one is worth celebrating.") case .lost: - EmptyStateView(title: "No lost deals", message: "Deals marked lost show up here.") + CoverEmptyState(symbol: "xmark.seal.fill", color: WTheme.inkSecondary, title: "Nothing lost", message: "Deals marked lost show up here.") } } } @@ -247,43 +347,74 @@ struct CRMDealsView: View { private func row(_ deal: CRMDeal, showsStage: Bool = true) -> some View { let stage = deal.stage ?? store.stage(deal.stageID) + let status = deal.status ?? "open" + let person = deal.contact?.displayName + var details: [String] = [] + if let person { details.append(person) } + if status == "open", let close = deal.expectedCloseDate { + details.append("closes \(close.formatted(.dateTime.month(.abbreviated).day()))") + } else if status == "won", let wonAt = deal.wonAt { + details.append("won \(WFormat.ago(wonAt)) ago") + } return HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 4) { + WAvatar(name: person ?? deal.name, seed: deal.contactID ?? deal.id, size: 40) + .overlay(alignment: .bottomTrailing) { + if status != "open" { + Image(systemName: status == "won" ? "checkmark" : "xmark") + .font(.system(size: 8, weight: .heavy)) + .foregroundStyle(.white) + .frame(width: 17, height: 17) + .background(status == "won" ? WTheme.positive : WTheme.paused, in: Circle()) + .overlay(Circle().stroke(WTheme.groupedCard, lineWidth: 2)) + .offset(x: 3, y: 3) + .transition(.scale.combined(with: .opacity)) + } + } + VStack(alignment: .leading, spacing: 3) { Text(deal.name) - .font(.body.weight(.medium)) + .font(.system(size: 15.5, weight: .medium)) + .foregroundStyle(status == "lost" ? WTheme.inkSecondary : WTheme.ink) + .strikethrough(status == "lost", color: WTheme.inkTertiary) .lineLimit(1) - HStack(spacing: 6) { - if let contact = deal.contact { - Text(contact.displayName) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } - if showsStage, let stage { - CRMColorChip(text: stage.name, hex: stage.color) - } + if !details.isEmpty { + Text(details.joined(separator: " · ")) + .font(.system(size: 13)) + .foregroundStyle(WTheme.inkSecondary) + .lineLimit(1) } } Spacer(minLength: 8) VStack(alignment: .trailing, spacing: 4) { if let value = deal.value { Text(CRMFormat.currency(value, code: deal.currency)) - .font(.subheadline.weight(.semibold)) + .font(.system(size: 16, weight: .semibold, design: .rounded)) .monospacedDigit() - .foregroundStyle(.primary) + .foregroundStyle(status == "won" ? WTheme.positive : status == "lost" ? WTheme.inkTertiary : WTheme.ink) + .contentTransition(.numericText()) } - if (deal.status ?? "open") != "open" { - StatusPill(text: deal.status ?? "open", tone: deal.statusTone) + if showsStage, status == "open", let stage { + CRMColorChip(text: stage.name, hex: stage.color) } } } - .padding(.vertical, 6) + .padding(.vertical, 4) .contentShape(Rectangle()) .onTapGesture { if canWrite { editing = deal } } - .listRowInsets(EdgeInsets(top: 4, leading: 20, bottom: 4, trailing: 16)) - .listRowBackground(Color(.systemBackground)) + .animation(.snappy, value: status) + .listRowBackground(WTheme.groupedCard) + .alignmentGuide(.listRowSeparatorLeading) { _ in 52 } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + if canWrite, status == "open" { + Button { + Task { await setStatus(deal, status: "won") } + } label: { + Label("Won", systemImage: "trophy.fill") + } + .tint(WTheme.positive) + } + } .swipeActions(edge: .trailing, allowsFullSwipe: false) { if canWrite { Button { @@ -297,17 +428,23 @@ struct CRMDealsView: View { .contextMenu { if canWrite { stageMoveMenu(deal) - if (deal.status ?? "open") == "open" { + if status == "open" { Button { Task { await setStatus(deal, status: "won") } } label: { - Label("Mark won", systemImage: "checkmark.seal") + Label("Mark won", systemImage: "trophy") } Button(role: .destructive) { Task { await setStatus(deal, status: "lost") } } label: { Label("Mark lost", systemImage: "xmark.seal") } + } else { + Button { + Task { await setStatus(deal, status: "open") } + } label: { + Label("Reopen", systemImage: "arrow.uturn.backward") + } } } } @@ -345,7 +482,11 @@ struct CRMDealsView: View { private func setStatus(_ deal: CRMDeal, status: String) async { var body = CRMDealUpdateBody() body.status = status - try? await store.update(env.api, deal: deal, body: body) + do { + try await store.update(env.api, deal: deal, body: body) + if status == "won" { celebrate += 1 } + await store.load(env.api) + } catch {} } } diff --git a/ios/Warmbly/Features/CRM/CRMMeetingsView.swift b/ios/Warmbly/Features/CRM/CRMMeetingsView.swift index 7cd5f5a4a..b46de4ec8 100644 --- a/ios/Warmbly/Features/CRM/CRMMeetingsView.swift +++ b/ios/Warmbly/Features/CRM/CRMMeetingsView.swift @@ -1,14 +1,8 @@ import SwiftUI -/// Meetings browser presented as a full-screen cover from the More hub, -/// copying the campaign leads browser: its own NavigationStack, a slide-in -/// drawer (sky hero with the next meeting badge + upcoming/today/past scopes -/// with a sliding capsule, edge-swipe to open), a search pill with hamburger + -/// presence + circular close, a tracked-uppercase scope caption, and a dense -/// hairline list grouped under eyebrow captions (today gets a sky ping). Rows -/// show time + attendee + source + state pill with an inline join affordance -/// when a call link exists; cancel is gated behind manageContacts. Reloads on -/// the .meetings realtime pulse. +/// Meetings browser presented from the settings hub: a next-up hero with a +/// live countdown and join button, today/upcoming/canceled tiles, and a +/// day-grouped agenda. Cancel is gated behind manageContacts. struct CRMMeetingsView: View { @Environment(AppEnvironment.self) private var env @Environment(\.dismiss) private var dismiss @@ -16,6 +10,9 @@ struct CRMMeetingsView: View { @State private var pendingCancel: CRMMeeting? @State private var cancelError: String? @State private var sidebarOpen = false + @State private var shown = false + @State private var lastNext: CRMMeeting? + @FocusState private var searchFocused: Bool private var canWrite: Bool { env.session.can(.manageContacts) } private var isSearching: Bool { !store.query.trimmingCharacters(in: .whitespaces).isEmpty } @@ -79,8 +76,10 @@ struct CRMMeetingsView: View { withAnimation(CRMBrowser.spring) { sidebarOpen = true } } - private func select(_ scope: CRMMeetingScope) { + private func select(_ scope: CRMMeetingScope, close: Bool = true) { + if let next = store.nextUpcoming { lastNext = next } withAnimation(.spring(response: 0.38, dampingFraction: 0.8)) { store.scope = scope } + guard close else { return } Task { try? await Task.sleep(for: .milliseconds(280)) withAnimation(CRMBrowser.spring) { sidebarOpen = false } @@ -92,53 +91,113 @@ struct CRMMeetingsView: View { private var mainPane: some View { @Bindable var store = store return VStack(spacing: 0) { - CRMSearchBar( + GlassHeaderBar( + title: "Meetings", + subtitle: store.hasLoaded ? Text("\(store.count(for: .upcoming) ?? 0) upcoming") : nil, query: $store.query, - prompt: "Search meetings", - onMenu: { openSidebar() }, - menuLabel: "Open meetings menu" + searchPrompt: "Search meetings", + searchFocused: $searchFocused, + menuLabel: "Open meetings menu", + onMenu: openSidebar ) { - CRMCircleButton(symbol: "xmark", label: "Close meetings", weight: .semibold, size: 15) { - dismiss() - } + GlassCircleButton(symbol: "xmark", label: "Close meetings") { dismiss() } } - scopeCaption list } - .background(Color(.systemBackground)) + .background(WTheme.groupedPage) } - // MARK: Caption + // MARK: Next up - private var scopeCaption: some View { - HStack(spacing: 6) { - Text(isSearching ? "SEARCH RESULTS" : store.scope.title.uppercased()) - .font(.caption.weight(.semibold)) - .tracking(0.9) - .foregroundStyle(.secondary) - if !isSearching, let count = captionCount, count > 0 { - Text(WFormat.compact(count)) - .font(.caption.weight(.semibold)) - .monospacedDigit() - .foregroundStyle(.tertiary) - .contentTransition(.numericText()) - } - Spacer() - if isSearching, store.hasLoaded { - Text("\(store.visibleMeetings.count) found") - .font(.caption.weight(.semibold)) - .monospacedDigit() - .foregroundStyle(.secondary) - .contentTransition(.numericText()) + @ViewBuilder + private var heroCard: some View { + if let next = heroMeeting { + NextMeetingCard(meeting: next, shown: shown) + } else { + HStack(spacing: 14) { + SettingsIcon(symbol: "calendar", color: WTheme.accent, size: 46) + VStack(alignment: .leading, spacing: 3) { + Text("Your calendar is clear") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(WTheme.ink) + Text("Booked calls from Calendly and Cal.com land here.") + .font(.system(size: 13.5)) + .foregroundStyle(WTheme.inkSecondary) + } + Spacer(minLength: 0) } + .coverCard() } - .padding(.horizontal, 20) - .padding(.top, 8) - .padding(.bottom, 2) } - private var captionCount: Int? { - store.count(for: store.scope) ?? store.totalCount + /// The meeting the hero counts down to, kept while the past scope is open. + private var heroMeeting: CRMMeeting? { + store.nextUpcoming ?? lastNext + } + + private var statsRow: some View { + HStack(spacing: 10) { + statTile("Today", value: store.count(for: .today) ?? 0, symbol: "sun.max.fill", tone: .amber, index: 0) + statTile("Upcoming", value: store.count(for: .upcoming) ?? 0, symbol: "calendar.badge.clock", tone: .sky, index: 1) + statTile("Canceled", value: store.summary?.canceled ?? 0, symbol: "calendar.badge.minus", tone: .rose, index: 2) + } + } + + private func statTile(_ title: String, value: Int, symbol: String, tone: Tone, index: Int) -> some View { + VStack(alignment: .leading, spacing: 8) { + Image(systemName: symbol) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(tone.color) + .frame(width: 28, height: 28) + .background(tone.background, in: Circle()) + CountUpText(value: shown ? Double(value) : 0, format: { WFormat.compact(Int($0.rounded())) }) + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .monospacedDigit() + .animation(.spring(response: 0.9, dampingFraction: 0.9).delay(0.1 + Double(index) * 0.06), value: shown) + .foregroundStyle(WTheme.ink) + Text(title) + .font(.system(size: 12.5, weight: .medium)) + .foregroundStyle(WTheme.inkSecondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(WTheme.groupedCard, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .reveal(shown, index + 1) + } + + // MARK: Agenda + + private struct DayGroup: Identifiable { + let id: Date + let title: String + let isToday: Bool + let meetings: [CRMMeeting] + } + + private var dayGroups: [DayGroup] { + let calendar = Calendar.current + var order: [Date] = [] + var buckets: [Date: [CRMMeeting]] = [:] + for meeting in store.visibleMeetings { + let day = calendar.startOfDay(for: meeting.scheduledFor ?? .distantPast) + if buckets[day] == nil { order.append(day) } + buckets[day, default: []].append(meeting) + } + return order.map { day in + DayGroup(id: day, title: dayTitle(day), isToday: calendar.isDateInToday(day), meetings: buckets[day] ?? []) + } + } + + private func dayTitle(_ day: Date) -> String { + let calendar = Calendar.current + if day == .distantPast { return "Unscheduled" } + if calendar.isDateInToday(day) { return "Today" } + if calendar.isDateInTomorrow(day) { return "Tomorrow" } + if calendar.isDateInYesterday(day) { return "Yesterday" } + let sameYear = calendar.isDate(day, equalTo: .now, toGranularity: .year) + return sameYear + ? day.formatted(.dateTime.weekday(.wide).month(.wide).day()) + : day.formatted(.dateTime.month(.wide).day().year()) } // MARK: List @@ -146,27 +205,67 @@ struct CRMMeetingsView: View { @ViewBuilder private var list: some View { if store.isLoading, !store.hasLoaded { - ScrollView { SkeletonRows(rows: 8) } + ScrollView { AnalyticsLoadingCards().padding(16) } } else if let error = store.errorMessage, store.meetings.isEmpty { ErrorStateView(title: "Couldn't load meetings", message: error) { await store.load(env.api) } - } else if store.visibleMeetings.isEmpty { - emptyState } else { List { - if store.scope == .upcoming { - upcomingGroups + Section { + VStack(spacing: 12) { + if !isSearching { + heroCard + .reveal(shown, 0) + statsRow + } + PillSwitcher( + options: CRMMeetingScope.allCases, + selection: Binding(get: { store.scope }, set: { select($0, close: false) }), + label: \.title + ) + .padding(.top, 2) + } + .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 0, trailing: 0)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + if store.visibleMeetings.isEmpty { + Section { + emptyState + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } } else { - ForEach(store.visibleMeetings) { meeting in - row(meeting) + ForEach(dayGroups) { group in + Section { + ForEach(group.meetings) { meeting in + row(meeting) + } + } header: { + CoverSectionHeader( + title: group.title, + count: group.meetings.count, + color: group.isToday ? WTheme.accent : nil, + pulsing: group.isToday && store.scope != .past + ) + } + } + Section { + CRMEndMarker(endCount, "meeting") + .listRowBackground(Color.clear) } } - CRMEndMarker(endCount, "meeting") } - .listStyle(.plain) + .listStyle(.insetGrouped) + .listSectionSpacing(18) + .contentMargins(.horizontal, 16, for: .scrollContent) + .contentMargins(.top, 0, for: .scrollContent) + .scrollContentBackground(.hidden) .scrollDismissesKeyboard(.immediately) + .defaultScrollAnchor(SettingsDebug.anchor) .refreshable { await store.load(env.api) } + .onAppear { shown = true } } } @@ -176,39 +275,22 @@ struct CRMMeetingsView: View { return store.totalCount ?? store.visibleMeetings.count } - @ViewBuilder - private var upcomingGroups: some View { - let today = store.meetings.filter(\.isToday) - let later = store.meetings.filter { !$0.isToday } - if !today.isEmpty { - CRMListCaption(title: "Today", count: today.count, tone: .sky, ping: true) - ForEach(today) { meeting in - row(meeting) - } - } - if !later.isEmpty { - CRMListCaption(title: "Later", count: later.count) - ForEach(later) { meeting in - row(meeting) - } - } - } - @ViewBuilder private var emptyState: some View { if isSearching { - EmptyStateView(title: "No matching meetings", message: "Try a different search.") + CoverEmptyState(symbol: "magnifyingglass", color: WTheme.inkSecondary, title: "No matching meetings", message: "Try a different search.") } else { switch store.scope { case .upcoming: - EmptyStateView( + CoverEmptyState( + symbol: "calendar.badge.clock", title: "No upcoming meetings", - message: "Meetings booked through Calendly, Cal.com or logged manually show up here." + message: "Meetings booked through Calendly, Cal.com or logged by hand show up here." ) case .today: - EmptyStateView(title: "Nothing today", message: "Meetings scheduled for today show up here.") + CoverEmptyState(symbol: "sun.max.fill", color: WTheme.warning, title: "Nothing today", message: "A clear day. Good time to follow up on replies.") case .past: - EmptyStateView(title: "No past meetings", message: "Meetings that already happened show up here.") + CoverEmptyState(symbol: "clock.arrow.circlepath", color: WTheme.special, title: "No past meetings", message: "Meetings that already happened show up here.") } } } @@ -216,56 +298,77 @@ struct CRMMeetingsView: View { // MARK: Row private func row(_ meeting: CRMMeeting) -> some View { - HStack(spacing: 12) { - Circle() - .fill(meeting.statusTone.color.opacity(meeting.isToday && store.scope != .past ? 1 : 0.5)) - .frame(width: 6, height: 6) - VStack(alignment: .leading, spacing: 3) { - Text(meeting.displayTitle) - .font(.body.weight(.medium)) - .lineLimit(1) - HStack(spacing: 6) { - if let scheduled = meeting.scheduledFor { - Text(CRMFormat.meetingTime(scheduled)) - .font(.footnote) - .monospacedDigit() - .foregroundStyle(meeting.isToday ? WTheme.accent : .secondary) - } - if let invitee = meeting.inviteeLabel, meeting.eventName?.isEmpty == false { - Text(invitee) - .font(.footnote) - .foregroundStyle(.tertiary) - .lineLimit(1) - } + let canceled = meeting.status == "canceled" + let live = meeting.isLive + return HStack(spacing: 12) { + VStack(alignment: .trailing, spacing: 2) { + if let start = meeting.scheduledFor { + Text(start.formatted(date: .omitted, time: .shortened)) + .font(.system(size: 14.5, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(canceled ? WTheme.inkTertiary : WTheme.ink) + } else { + Text("TBD") + .font(.system(size: 14.5, weight: .semibold)) + .foregroundStyle(WTheme.inkTertiary) + } + if let minutes = meeting.durationMinutes { + Text(minutes >= 60 && minutes % 60 == 0 ? "\(minutes / 60) h" : "\(minutes) min") + .font(.system(size: 12)) + .monospacedDigit() + .foregroundStyle(WTheme.inkTertiary) } } - Spacer(minLength: 8) + .frame(width: 66, alignment: .trailing) + Capsule() + .fill(live ? WTheme.positive : meeting.statusTone.color.opacity(canceled ? 0.35 : 0.85)) + .frame(width: 3.5) + .padding(.vertical, 2) + VStack(alignment: .leading, spacing: 4) { + Text(meeting.displayTitle) + .font(.system(size: 15.5, weight: .medium)) + .foregroundStyle(canceled ? WTheme.inkTertiary : WTheme.ink) + .strikethrough(canceled, color: WTheme.inkTertiary) + .lineLimit(1) + HStack(spacing: 6) { + if let invitee = meeting.inviteeLabel, meeting.eventName?.isEmpty == false { + Text(invitee) + .lineLimit(1) + } + if meeting.source != "manual", !meeting.sourceLabel.isEmpty { + Text(meeting.sourceLabel) + .foregroundStyle(WTheme.inkTertiary) + } + } + .font(.system(size: 13)) + .foregroundStyle(WTheme.inkSecondary) + if live { + LiveTag() + } else if meeting.status != "booked", meeting.status != nil { + StatusPill(text: meeting.statusLabel.prefix(1).uppercased() + meeting.statusLabel.dropFirst(), tone: meeting.statusTone) + } + } + Spacer(minLength: 6) if let joinURL = meeting.joinURL, let url = URL(string: joinURL), - store.scope != .past, meeting.status != "canceled" { + store.scope != .past, !canceled { Link(destination: url) { Image(systemName: "video.fill") .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(WTheme.accent) - .frame(width: 32, height: 32) - .background(Tone.sky.background, in: Circle()) + .foregroundStyle(live ? .white : WTheme.accent) + .frame(width: 34, height: 34) + .background(live ? WTheme.positive : Tone.sky.background, in: Circle()) } - .buttonStyle(.plain) + .buttonStyle(TapScaleStyle()) .accessibilityLabel("Join meeting") } - VStack(alignment: .trailing, spacing: 4) { - if !meeting.sourceLabel.isEmpty { - Text(meeting.sourceLabel) - .font(.caption.weight(.medium)) - .foregroundStyle(.tertiary) - } - StatusPill(text: meeting.statusLabel, tone: meeting.statusTone) - } } - .padding(.vertical, 6) - .listRowInsets(EdgeInsets(top: 4, leading: 20, bottom: 4, trailing: 16)) - .listRowBackground(Color(.systemBackground)) + .fixedSize(horizontal: false, vertical: true) + .padding(.vertical, 4) + .listRowBackground(WTheme.groupedCard) + .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 14)) + .alignmentGuide(.listRowSeparatorLeading) { _ in 90 } .swipeActions(edge: .trailing, allowsFullSwipe: false) { - if canWrite, meeting.status != "canceled" { + if canWrite, !canceled { Button(role: .destructive) { pendingCancel = meeting } label: { @@ -284,7 +387,7 @@ struct CRMMeetingsView: View { Label("Reschedule", systemImage: "calendar") } } - if canWrite, meeting.status != "canceled" { + if canWrite, !canceled { Button(role: .destructive) { pendingCancel = meeting } label: { @@ -303,6 +406,160 @@ struct CRMMeetingsView: View { } } +// MARK: - Next meeting + +/// The hero: the next meeting with a live countdown, who it is with, and a +/// join button that turns green once the call is starting. +private struct NextMeetingCard: View { + let meeting: CRMMeeting + let shown: Bool + + var body: some View { + TimelineView(.periodic(from: .now, by: 15)) { context in + let now = context.date + let live = meeting.isLive(at: now) + let soon = (meeting.scheduledFor?.timeIntervalSince(now) ?? .infinity) < 10 * 60 + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 6) { + if live || soon { + PulsingDot(color: live ? WTheme.positive : WTheme.accent) + } + Text(live ? "Happening now" : "Next up") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(live ? WTheme.positive : WTheme.inkSecondary) + Spacer() + if meeting.source != "manual", !meeting.sourceLabel.isEmpty { + Text(meeting.sourceLabel) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(WTheme.inkTertiary) + } + } + Text(countdown(now: now, live: live)) + .font(.system(size: 34, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(live ? WTheme.positive : WTheme.ink) + .contentTransition(.numericText(countsDown: true)) + .animation(.snappy, value: countdown(now: now, live: live)) + HStack(spacing: 12) { + WAvatar(name: meeting.inviteeLabel ?? meeting.displayTitle, seed: meeting.inviteeEmail ?? meeting.id, size: 40) + VStack(alignment: .leading, spacing: 2) { + Text(meeting.displayTitle) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(WTheme.ink) + .lineLimit(1) + Text(subtitle) + .font(.system(size: 13)) + .foregroundStyle(WTheme.inkSecondary) + .lineLimit(1) + } + Spacer(minLength: 0) + } + if let joinURL = meeting.joinURL, let url = URL(string: joinURL) { + Link(destination: url) { + HStack(spacing: 8) { + Image(systemName: "video.fill") + Text(live ? "Join now" : "Join call") + } + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 46) + .background(live || soon ? WTheme.positive : WTheme.ink, in: Capsule()) + } + .buttonStyle(TapScaleStyle()) + .animation(.snappy, value: live || soon) + } else if let location = meeting.location, !location.isEmpty { + Label(location, systemImage: "mappin.and.ellipse") + .font(.system(size: 13.5)) + .foregroundStyle(WTheme.inkSecondary) + .lineLimit(1) + } + } + } + .coverCard() + } + + private var subtitle: String { + var parts: [String] = [] + if let invitee = meeting.inviteeLabel, meeting.eventName?.isEmpty == false { parts.append(invitee) } + if let start = meeting.scheduledFor { + var when = CRMFormat.meetingTime(start) + when = when.prefix(1).uppercased() + when.dropFirst() + if let end = meeting.endTime { + when += " to " + end.formatted(date: .omitted, time: .shortened) + } + parts.append(when) + } + return parts.joined(separator: " · ") + } + + private func countdown(now: Date, live: Bool) -> String { + guard let start = meeting.scheduledFor else { return "Not scheduled" } + if live { + guard let end = meeting.endTime else { return "Started" } + let left = max(1, Int(end.timeIntervalSince(now) / 60)) + return "\(left) min left" + } + let seconds = start.timeIntervalSince(now) + if seconds < 60 { return "Starting now" } + let minutes = Int(seconds / 60) + if minutes < 60 { return "In \(minutes) min" } + let hours = minutes / 60 + if hours < 24 { return minutes % 60 == 0 ? "In \(hours) h" : "In \(hours) h \(minutes % 60) min" } + let days = Calendar.current.dateComponents([.day], from: Calendar.current.startOfDay(for: now), to: Calendar.current.startOfDay(for: start)).day ?? hours / 24 + return days == 1 ? "Tomorrow" : "In \(days) days" + } +} + +/// A small green "Live" tag for a meeting in progress. +private struct LiveTag: View { + var body: some View { + HStack(spacing: 5) { + PulsingDot(color: WTheme.positive, size: 6) + Text("Live") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(WTheme.positive) + } + } +} + +/// A dot with a soft ring breathing out of it. +private struct PulsingDot: View { + var color: Color + var size: CGFloat = 7 + @State private var pulse = false + + var body: some View { + Circle() + .fill(color) + .frame(width: size, height: size) + .background { + Circle() + .fill(color.opacity(0.35)) + .scaleEffect(pulse ? 2.6 : 1) + .opacity(pulse ? 0 : 1) + } + .onAppear { + withAnimation(.easeOut(duration: 1.4).repeatForever(autoreverses: false)) { pulse = true } + } + } +} + +extension CRMMeeting { + var durationMinutes: Int? { + guard let start = scheduledFor, let end = endTime, end > start else { return nil } + return Int(end.timeIntervalSince(start) / 60) + } + + func isLive(at now: Date = .now) -> Bool { + guard status != "canceled", let start = scheduledFor else { return false } + let end = endTime ?? start.addingTimeInterval(30 * 60) + return start <= now && now < end + } + + var isLive: Bool { isLive() } +} + // MARK: - Drawer /// Meetings drawer: sky hero with a next-meeting badge (falling back to the diff --git a/ios/Warmbly/Features/CRM/CRMStore.swift b/ios/Warmbly/Features/CRM/CRMStore.swift index e7333e82a..5a96eabe1 100644 --- a/ios/Warmbly/Features/CRM/CRMStore.swift +++ b/ios/Warmbly/Features/CRM/CRMStore.swift @@ -295,12 +295,31 @@ final class CRMTasksStore { /// Flip completion. Completed <-> pending; setting completed stamps completed_at server-side. func setDone(_ api: APIClient, task: CRMTask, done: Bool) async throws { + // Optimistic: flip it now, put it back if the server refuses. + var local = task + local.status = done ? "completed" : "pending" + local.completedAt = done ? .now : nil + replace(local) var body = CRMTaskUpdateBody() - body.status = done ? "completed" : "pending" - let updated: CRMTask = try await api.patch("crm/tasks/\(task.id)", body: body) - withAnimation(.snappy) { + body.status = local.status + do { + let updated: CRMTask = try await api.patch("crm/tasks/\(task.id)", body: body) + replace(updated) + } catch { + replace(task) + throw error + } + } + + /// Tasks finished today, for the today ring. + var doneToday: Int { + tasks.count { $0.isDone && ($0.completedAt.map(Calendar.current.isDateInToday) ?? false) } + } + + private func replace(_ task: CRMTask) { + withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) { if let index = tasks.firstIndex(where: { $0.id == task.id }) { - tasks[index] = updated + tasks[index] = task } } } diff --git a/ios/Warmbly/Features/CRM/CRMTasksView.swift b/ios/Warmbly/Features/CRM/CRMTasksView.swift index d3e34a8d4..b8fdf544f 100644 --- a/ios/Warmbly/Features/CRM/CRMTasksView.swift +++ b/ios/Warmbly/Features/CRM/CRMTasksView.swift @@ -15,10 +15,19 @@ struct CRMTasksView: View { @State private var store = CRMTasksStore() @State private var showCreate = false @State private var sidebarOpen = false + @State private var shown = false + @State private var celebrate = 0 + @State private var completedPulse = 0 + /// Just-toggled tasks, held in their old place (by their old state) so the check animates before they move. + @State private var lingering: [String: CRMTask] = [:] + @FocusState private var searchFocused: Bool private var canWrite: Bool { env.session.can(.manageContacts) } private var isSearching: Bool { !store.query.trimmingCharacters(in: .whitespaces).isEmpty } - private var scopedTasks: [CRMTask] { store.tasks(in: store.scope) } + private var scopedTasks: [CRMTask] { + let ids = Set(store.tasks(in: store.scope).map(\.id)) + return store.tasks.filter { ids.contains($0.id) || lingering[$0.id] != nil } + } var body: some View { NavigationStack { @@ -69,55 +78,86 @@ struct CRMTasksView: View { private var mainPane: some View { @Bindable var store = store + let open = store.count(for: .open) return VStack(spacing: 0) { - CRMSearchBar( + GlassHeaderBar( + title: "Tasks", + subtitle: store.hasLoaded ? Text("\(open) open") : nil, query: $store.query, - prompt: "Search tasks", - onMenu: { openSidebar() }, - menuLabel: "Open tasks menu" + searchPrompt: "Search tasks", + searchFocused: $searchFocused, + menuLabel: "Open tasks menu", + onMenu: openSidebar ) { if canWrite { - CRMCircleButton(symbol: "plus", label: "New task") { - showCreate = true - } - } - CRMCircleButton(symbol: "xmark", label: "Close tasks", weight: .semibold, size: 15) { - dismiss() + HeaderPrimaryButton(symbol: "plus", label: "New task") { showCreate = true } } + GlassCircleButton(symbol: "xmark", label: "Close tasks") { dismiss() } } - scopeCaption list } - .background(Color(.systemBackground)) + .background(WTheme.groupedPage) + .overlay { ConfettiBurst(trigger: celebrate) } + .sensoryFeedback(.success, trigger: celebrate) + .sensoryFeedback(.impact(weight: .light), trigger: completedPulse) } - // MARK: Caption + // MARK: Today - private var scopeCaption: some View { - HStack(spacing: 6) { - Text(isSearching ? "SEARCH RESULTS" : store.scope.title.uppercased()) - .font(.caption.weight(.semibold)) - .tracking(0.9) - .foregroundStyle(.secondary) - if !isSearching, store.count(for: store.scope) > 0 { - Text(WFormat.compact(store.count(for: store.scope))) - .font(.caption.weight(.semibold)) - .monospacedDigit() - .foregroundStyle(.tertiary) - .contentTransition(.numericText()) + private var todayCard: some View { + let left = store.tasks(in: .dueToday).count + let done = store.doneToday + let total = left + done + let fraction = total > 0 ? Double(done) / Double(total) : 1 + let overdue = store.count(for: .overdue) + let upcoming = store.count(for: .upcoming) + let headline = left > 0 ? "\(left) left for today" : (total > 0 ? "All done for today" : "Nothing due today") + return HStack(spacing: 16) { + SweepRing(fraction: fraction, color: left == 0 ? WTheme.positive : WTheme.accent, shown: shown, lineWidth: 9) { + if left == 0 { + Image(systemName: "checkmark") + .font(.system(size: 26, weight: .bold)) + .foregroundStyle(WTheme.positive) + .symbolEffect(.bounce, value: celebrate) + .transition(.scale.combined(with: .opacity)) + } else { + VStack(spacing: -1) { + Text("\(done)/\(total)") + .font(.system(size: 19, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(WTheme.ink) + .contentTransition(.numericText()) + Text("done") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(WTheme.inkTertiary) + } + } } - Spacer() - if isSearching, store.hasLoaded { - Text("\(scopedTasks.count) found") - .font(.caption.weight(.semibold)) - .monospacedDigit() - .foregroundStyle(.secondary) + .frame(width: 86, height: 86) + .animation(.spring(response: 0.5, dampingFraction: 0.7), value: left == 0) + VStack(alignment: .leading, spacing: 5) { + Text(Date.now.formatted(.dateTime.weekday(.wide).month(.wide).day())) + .font(.system(size: 13.5, weight: .medium)) + .foregroundStyle(WTheme.inkSecondary) + Text(headline) + .font(.system(size: 21, weight: .semibold)) + .foregroundStyle(WTheme.ink) .contentTransition(.numericText()) + HStack(spacing: 10) { + if overdue > 0 { + Label("\(overdue) overdue", systemImage: "exclamationmark.circle.fill") + .foregroundStyle(WTheme.negative) + } + Label("\(upcoming) coming up", systemImage: "calendar") + .foregroundStyle(WTheme.inkSecondary) + } + .font(.system(size: 13, weight: .medium)) + .labelStyle(CompactLabelStyle()) } + Spacer(minLength: 0) } - .padding(.horizontal, 20) - .padding(.top, 8) - .padding(.bottom, 2) + .coverCard() + .animation(.snappy, value: left) } // MARK: Due-date groups @@ -125,21 +165,20 @@ struct CRMTasksView: View { private struct TaskBucket: Identifiable { let id: String let title: String - var tone: Tone? + var color: Color? var ping = false let tasks: [CRMTask] } - /// Buckets computed over the scoped tasks; scopes narrower than Open - /// naturally collapse to their own group(s). private var groups: [TaskBucket] { var result: [TaskBucket] = [] let scoped = scopedTasks - let overdue = scoped.filter(\.isOverdue) + let placed = { (task: CRMTask) in lingering[task.id] ?? task } + let overdue = scoped.filter { placed($0).isOverdue } if !overdue.isEmpty { - result.append(TaskBucket(id: "overdue", title: "Overdue", tone: .rose, ping: true, tasks: overdue)) + result.append(TaskBucket(id: "overdue", title: "Overdue", color: WTheme.negative, ping: true, tasks: overdue)) } - let open = scoped.filter { !$0.isDone && !$0.isOverdue } + let open = scoped.filter { !placed($0).isDone && !placed($0).isOverdue } let calendar = Calendar.current let today = open.filter { $0.dueDate.map(calendar.isDateInToday) ?? false } let tomorrow = open.filter { $0.dueDate.map(calendar.isDateInTomorrow) ?? false } @@ -148,22 +187,12 @@ struct CRMTasksView: View { return !calendar.isDateInToday(due) && !calendar.isDateInTomorrow(due) } let undated = open.filter { $0.dueDate == nil } - if !today.isEmpty { - result.append(TaskBucket(id: "today", title: "Due today", tone: .sky, tasks: today)) - } - if !tomorrow.isEmpty { - result.append(TaskBucket(id: "tomorrow", title: "Due tomorrow", tasks: tomorrow)) - } - if !later.isEmpty { - result.append(TaskBucket(id: "later", title: "Upcoming", tasks: later)) - } - if !undated.isEmpty { - result.append(TaskBucket(id: "undated", title: "No due date", tasks: undated)) - } - let done = scoped.filter(\.isDone) - if !done.isEmpty { - result.append(TaskBucket(id: "done", title: "Done", tone: .slate, tasks: done)) - } + if !today.isEmpty { result.append(TaskBucket(id: "today", title: "Today", color: WTheme.accent, tasks: today)) } + if !tomorrow.isEmpty { result.append(TaskBucket(id: "tomorrow", title: "Tomorrow", color: WTheme.special, tasks: tomorrow)) } + if !later.isEmpty { result.append(TaskBucket(id: "later", title: "Upcoming", color: WTheme.paused, tasks: later)) } + if !undated.isEmpty { result.append(TaskBucket(id: "undated", title: "No due date", tasks: undated)) } + let done = scoped.filter { placed($0).isDone } + if !done.isEmpty { result.append(TaskBucket(id: "done", title: "Done", color: WTheme.positive, tasks: done)) } return result } @@ -172,55 +201,86 @@ struct CRMTasksView: View { @ViewBuilder private var list: some View { if store.isLoading, !store.hasLoaded { - ScrollView { SkeletonRows(rows: 10) } + ScrollView { AnalyticsLoadingCards().padding(16) } } else if let error = store.errorMessage, store.tasks.isEmpty { ErrorStateView(title: "Couldn't load tasks", message: error) { await store.load(env.api) } - } else if scopedTasks.isEmpty { - emptyState } else { List { - ForEach(groups) { group in - CRMListCaption(title: group.title, count: group.tasks.count, tone: group.tone, ping: group.ping) - ForEach(group.tasks) { task in - row(task) + Section { + VStack(alignment: .leading, spacing: 14) { + if !isSearching { + todayCard + } + ChipFilterRow( + options: CRMTaskScope.allCases, + selection: Binding(get: { store.scope }, set: { store.scope = $0 }), + label: \.title, + count: { store.count(for: $0) }, + alert: { $0 == .overdue && store.count(for: .overdue) > 0 } + ) + } + .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 0, trailing: 0)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + if scopedTasks.isEmpty { + Section { + emptyState + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } else { + ForEach(groups) { group in + Section { + ForEach(group.tasks) { task in + row(task) + } + } header: { + CoverSectionHeader(title: group.title, count: group.tasks.count, color: group.color, pulsing: group.ping) + } } } - CRMEndMarker(scopedTasks.count, "task") + Section { + CRMEndMarker(scopedTasks.count, "task") + .listRowBackground(Color.clear) + } } - .listStyle(.plain) + .listStyle(.insetGrouped) + .listSectionSpacing(18) + .contentMargins(.horizontal, 16, for: .scrollContent) + .contentMargins(.top, 0, for: .scrollContent) + .scrollContentBackground(.hidden) .scrollDismissesKeyboard(.immediately) + .defaultScrollAnchor(SettingsDebug.anchor) .refreshable { await store.load(env.api) } + .onAppear { shown = true } } } @ViewBuilder private var emptyState: some View { if isSearching { - EmptyStateView(title: "No matching tasks", message: "Try a different search.") + CoverEmptyState(symbol: "magnifyingglass", color: WTheme.inkSecondary, title: "No matching tasks", message: "Try a different search.") } else { switch store.scope { case .open: - if canWrite { - EmptyStateView( - title: "No tasks yet", - message: "Track follow-ups and reminders here.", - ctaTitle: "New task" - ) { - showCreate = true - } - } else { - EmptyStateView(title: "No tasks yet", message: "Tasks your team creates will show up here.") - } + CoverEmptyState( + symbol: "checklist", + title: "No tasks yet", + message: "Follow-ups and reminders you create land here.", + actionTitle: canWrite ? "New task" : nil, + action: canWrite ? { showCreate = true } : nil + ) case .dueToday: - EmptyStateView(title: "Nothing due today", message: "Tasks with a due date of today show up here.") + CoverEmptyState(symbol: "sun.max.fill", color: WTheme.warning, title: "Nothing due today", message: "Enjoy the quiet, or get ahead on tomorrow.") case .overdue: - EmptyStateView(title: "Nothing overdue", message: "You're all caught up.") + CoverEmptyState(symbol: "checkmark.seal.fill", color: WTheme.positive, title: "Nothing overdue", message: "You're all caught up.") case .upcoming: - EmptyStateView(title: "Nothing upcoming", message: "Tasks due after today show up here.") + CoverEmptyState(symbol: "calendar", color: WTheme.special, title: "Nothing upcoming", message: "Tasks due after today show up here.") case .completed: - EmptyStateView(title: "Nothing completed yet", message: "Finished tasks land here.") + CoverEmptyState(symbol: "checkmark.circle.fill", color: WTheme.positive, title: "Nothing finished yet", message: "Tick a task off and it lands here.") } } } @@ -228,53 +288,56 @@ struct CRMTasksView: View { // MARK: Row private func row(_ task: CRMTask) -> some View { - HStack(spacing: 12) { - Button { + let priorityColor: Color? = switch task.priority { + case "urgent": WTheme.negative + case "high": Tone.orange.color + default: nil + } + let calendar = Calendar.current + let dueColor: Color = task.isOverdue ? WTheme.negative : (task.dueDate.map(calendar.isDateInToday) ?? false) ? WTheme.accent : WTheme.inkSecondary + return HStack(spacing: 8) { + TaskCheck(done: task.isDone) { Task { await toggle(task) } - } label: { - Image(systemName: task.isDone ? "checkmark.circle.fill" : "circle") - .font(.system(size: 22)) - .foregroundStyle(task.isDone ? WTheme.positive : (task.isOverdue ? WTheme.negative : Color.secondary)) - .contentTransition(.symbolEffect(.replace)) - .frame(width: 30, height: 30) - .contentShape(Rectangle()) } - .buttonStyle(.plain) .disabled(!canWrite) - .accessibilityLabel(task.isDone ? "Reopen task" : "Complete task") - - VStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: 4) { Text(task.title) - .font(.body.weight(.medium)) - .foregroundStyle(task.isDone ? .secondary : .primary) - .strikethrough(task.isDone, color: .secondary) - .lineLimit(1) - HStack(spacing: 6) { - if let due = task.dueDate { - Text(CRMFormat.dueLabel(due)) - .font(.footnote) + .font(.system(size: 15.5, weight: .medium)) + .foregroundStyle(task.isDone ? WTheme.inkTertiary : WTheme.ink) + .strikethrough(task.isDone, color: WTheme.inkTertiary) + .lineLimit(2) + HStack(spacing: 8) { + if let due = task.dueDate, !task.isDone { + Label(CRMFormat.dueLabel(due).prefix(1).uppercased() + CRMFormat.dueLabel(due).dropFirst(), systemImage: task.isOverdue ? "exclamationmark.circle" : "clock") + .labelStyle(CompactLabelStyle()) + .font(.system(size: 13)) .monospacedDigit() - .foregroundStyle(task.isOverdue ? WTheme.negative : .secondary) + .foregroundStyle(dueColor) } if let type = task.type, !type.isEmpty { CRMColorChip(text: type, hex: typeColor(type)) } } } - Spacer(minLength: 8) - if let priority = task.priority, priority != "medium", !task.isDone { - StatusPill(text: priority, tone: task.priorityTone) + Spacer(minLength: 6) + if let priorityColor, !task.isDone { + Image(systemName: "flag.fill") + .font(.system(size: 12)) + .foregroundStyle(priorityColor) + .accessibilityLabel("\(task.priority ?? "") priority") } } - .padding(.vertical, 6) - .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) - .listRowBackground(Color(.systemBackground)) + .padding(.vertical, 2) + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: task.isDone) + .listRowBackground(WTheme.groupedCard) + .listRowInsets(EdgeInsets(top: 6, leading: 6, bottom: 6, trailing: 14)) + .alignmentGuide(.listRowSeparatorLeading) { _ in 50 } .swipeActions(edge: .leading, allowsFullSwipe: true) { if canWrite { Button { Task { await toggle(task) } } label: { - Label(task.isDone ? "Reopen" : "Complete", systemImage: task.isDone ? "arrow.uturn.backward" : "checkmark") + Label(task.isDone ? "Reopen" : "Done", systemImage: task.isDone ? "arrow.uturn.backward" : "checkmark") } .tint(task.isDone ? WTheme.paused : WTheme.positive) } @@ -287,7 +350,18 @@ struct CRMTasksView: View { private func toggle(_ task: CRMTask) async { guard canWrite else { return } + let leftBefore = store.tasks(in: .dueToday).count + if !task.isDone { completedPulse += 1 } + lingering[task.id] = task + Task { + try? await Task.sleep(for: .milliseconds(900)) + _ = withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) { lingering.removeValue(forKey: task.id) } + } try? await store.setDone(env.api, task: task, done: !task.isDone) + // Clearing the last task due today earns a little celebration. + if !task.isDone, leftBefore > 0, store.tasks(in: .dueToday).isEmpty { + celebrate += 1 + } } } @@ -448,3 +522,70 @@ struct CRMTaskCreateSheet: View { } } } + +// MARK: - Check + +/// A round check that fills and draws its tick when done, with a ring that +/// ripples out as it lands. +struct TaskCheck: View { + let done: Bool + var tint: Color = WTheme.inkTertiary + let action: () -> Void + + @State private var ripple = false + + var body: some View { + Button { + if !done { + ripple = false + withAnimation(.easeOut(duration: 0.55)) { ripple = true } + } + action() + } label: { + ZStack { + Circle() + .strokeBorder(done ? WTheme.positive : tint.opacity(0.9), lineWidth: 2) + Circle() + .fill(WTheme.positive) + .scaleEffect(done ? 1 : 0.01) + .opacity(done ? 1 : 0) + TickShape() + .trim(from: 0, to: done ? 1 : 0) + .stroke(.white, style: StrokeStyle(lineWidth: 2.4, lineCap: .round, lineJoin: .round)) + .frame(width: 11, height: 9) + .animation(.easeOut(duration: 0.25).delay(done ? 0.12 : 0), value: done) + Circle() + .stroke(WTheme.positive, lineWidth: 2) + .scaleEffect(ripple ? 1.9 : 1) + .opacity(ripple ? 0 : (done ? 0.5 : 0)) + } + .frame(width: 24, height: 24) + .frame(width: 40, height: 40) + .contentShape(Rectangle()) + .animation(.spring(response: 0.32, dampingFraction: 0.58), value: done) + } + .buttonStyle(.plain) + .accessibilityLabel(done ? "Reopen task" : "Complete task") + } +} + +/// The tick inside a check. +struct TickShape: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.midY + rect.height * 0.05)) + path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.36, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + return path + } +} + +/// An icon and a title with a tight gap, for inline metadata. +struct CompactLabelStyle: LabelStyle { + func makeBody(configuration: Configuration) -> some View { + HStack(spacing: 4) { + configuration.icon.imageScale(.small) + configuration.title + } + } +} diff --git a/ios/Warmbly/Features/CRM/TemplatesListView.swift b/ios/Warmbly/Features/CRM/TemplatesListView.swift index 694aa0e46..306e1677a 100644 --- a/ios/Warmbly/Features/CRM/TemplatesListView.swift +++ b/ios/Warmbly/Features/CRM/TemplatesListView.swift @@ -1,71 +1,74 @@ import SwiftUI import WebKit -/// Reply templates browser pushed from the More tab, in the flat browser -/// language: search pill with presence, a tracked-uppercase caption row with -/// the live count, and dense hairline rows (name + subject + two-line snippet -/// + relative updated time) that push the rendered detail. Duplicate is gated -/// behind manageCampaigns. Reloads on the .templates pulse. +/// Reply templates pushed from settings: a hero with the count, native search, +/// and grouped cards showing each template's subject, snippet and the merge +/// fields it uses. Duplicate is gated behind manageCampaigns. struct TemplatesListView: View { @Environment(AppEnvironment.self) private var env @State private var store = TemplatesStore() @State private var openTemplate: EmailTemplate? + @State private var shown = false + @State private var duplicated = 0 private var canWrite: Bool { env.session.can(.manageCampaigns) } private var isSearching: Bool { !store.query.trimmingCharacters(in: .whitespaces).isEmpty } var body: some View { @Bindable var store = store - VStack(spacing: 0) { - CRMSearchBar(query: $store.query, prompt: "Search templates") - captionRow - list - } - .background(Color(.systemBackground)) - .navigationTitle("Templates") - .navigationBarTitleDisplayMode(.inline) - .navigationDestination(item: $openTemplate) { template in - TemplateDetailView(store: store, template: template) - } - .task(id: store.query) { - if store.hasLoaded { - try? await Task.sleep(for: .milliseconds(350)) + list + .background(WTheme.groupedPage) + .navigationTitle("Templates") + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(WTheme.groupedPage, for: .navigationBar) + .searchable(text: $store.query, placement: .navigationBarDrawer(displayMode: .automatic), prompt: "Search templates") + .navigationDestination(item: $openTemplate) { template in + TemplateDetailView(store: store, template: template) } - guard !Task.isCancelled else { return } - await store.load(env.api) - } - .onChange(of: env.realtime.pulse(for: .templates)) { - Task { await store.load(env.api) } - } + .sensoryFeedback(.success, trigger: duplicated) + .task(id: store.query) { + if store.hasLoaded { + try? await Task.sleep(for: .milliseconds(350)) + } + guard !Task.isCancelled else { return } + await store.load(env.api) + } + .onChange(of: env.realtime.pulse(for: .templates)) { + Task { await store.load(env.api) } + } + #if DEBUG + .onChange(of: store.hasLoaded) { + if ProcessInfo.processInfo.environment["WARMBLY_DEBUG_TEMPLATE"] != nil { openTemplate = store.templates.first } + } + #endif } - // MARK: Caption + // MARK: Hero - private var captionRow: some View { - HStack(spacing: 6) { - Text(isSearching ? "SEARCH RESULTS" : "REPLY TEMPLATES") - .font(.caption.weight(.semibold)) - .tracking(0.9) - .foregroundStyle(.secondary) - if !isSearching, !store.templates.isEmpty { - Text(WFormat.compact(store.templates.count)) - .font(.caption.weight(.semibold)) - .monospacedDigit() - .foregroundStyle(.tertiary) - .contentTransition(.numericText()) - } - Spacer() - if isSearching, store.hasLoaded { - Text("\(store.templates.count) found") - .font(.caption.weight(.semibold)) - .monospacedDigit() - .foregroundStyle(.secondary) - .contentTransition(.numericText()) + private var hero: some View { + let latest = store.templates.compactMap(\.updatedAt).max() + return HStack(spacing: 14) { + SettingsIcon(symbol: "text.bubble.fill", color: Tone.indigo.color, size: 50) + .symbolEffect(.bounce, value: shown) + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + CountUpText(value: shown ? Double(store.templates.count) : 0, format: { "\(Int($0.rounded()))" }) + .font(.system(size: 28, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(WTheme.ink) + .animation(.spring(response: 0.9, dampingFraction: 0.9).delay(0.1), value: shown) + Text(store.templates.count == 1 ? "reply template" : "reply templates") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(WTheme.inkSecondary) + } + Text(latest.map { "Last edited \(WFormat.relative($0))" } ?? "Insert one from any reply in the inbox.") + .font(.system(size: 13)) + .foregroundStyle(WTheme.inkTertiary) } + Spacer(minLength: 0) } - .padding(.horizontal, 20) - .padding(.top, 8) - .padding(.bottom, 2) + .coverCard() + .reveal(shown, 0) } // MARK: List @@ -73,90 +76,233 @@ struct TemplatesListView: View { @ViewBuilder private var list: some View { if store.isLoading, !store.hasLoaded { - ScrollView { SkeletonRows(rows: 8) } + ScrollView { AnalyticsLoadingCards().padding(16) } } else if let error = store.errorMessage, store.templates.isEmpty { ErrorStateView(title: "Couldn't load templates", message: error) { await store.load(env.api) } - } else if store.templates.isEmpty { - emptyState } else { List { - ForEach(store.templates) { template in - row(template) + if !isSearching { + Section { + hero + .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 0, trailing: 0)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } + if store.templates.isEmpty { + Section { + emptyState + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } else { + Section { + ForEach(Array(store.templates.enumerated()), id: \.element.id) { index, template in + row(template, index: index) + } + } header: { + CoverSectionHeader(title: isSearching ? "Results" : "All templates", count: store.templates.count) + } + Section { + CRMEndMarker(store.templates.count, "template") + } } - CRMEndMarker(store.templates.count, "template") } - .listStyle(.plain) + .listStyle(.insetGrouped) + .listSectionSpacing(18) + .contentMargins(.horizontal, 16, for: .scrollContent) + .contentMargins(.top, 4, for: .scrollContent) + .scrollContentBackground(.hidden) .scrollDismissesKeyboard(.immediately) + .defaultScrollAnchor(SettingsDebug.anchor) .refreshable { await store.load(env.api) } + .onAppear { shown = true } } } @ViewBuilder private var emptyState: some View { - EmptyStateView( - title: isSearching ? "No matching templates" : "No templates yet", - message: isSearching - ? "Try a different search." - : "Reply templates you save on the web dashboard show up here." - ) + if isSearching { + CoverEmptyState(symbol: "magnifyingglass", color: WTheme.inkSecondary, title: "No matching templates", message: "Try a different search.") + } else { + CoverEmptyState( + symbol: "text.bubble", + color: Tone.indigo.color, + title: "No templates yet", + message: "Save a reply you send often on the web dashboard and it shows up here." + ) + } } // MARK: Row - private func row(_ template: EmailTemplate) -> some View { - Button { + private func row(_ template: EmailTemplate, index: Int) -> some View { + let tone = TemplateTone.of(template.name) + return Button { openTemplate = template } label: { - VStack(alignment: .leading, spacing: 3) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(template.name) - .font(.body.weight(.medium)) - .lineLimit(1) - Spacer(minLength: 8) - if let updated = template.updatedAt { - Text(WFormat.relative(updated)) - .font(.caption) - .monospacedDigit() - .foregroundStyle(.tertiary) + HStack(alignment: .top, spacing: 12) { + Text(String(template.name.prefix(1)).uppercased()) + .font(.system(size: 17, weight: .semibold, design: .rounded)) + .foregroundStyle(tone.color) + .frame(width: 40, height: 40) + .background(tone.background, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(template.name) + .font(.system(size: 15.5, weight: .semibold)) + .foregroundStyle(WTheme.ink) + .lineLimit(1) + Spacer(minLength: 8) + if let updated = template.updatedAt { + Text(WFormat.relative(updated)) + .font(.system(size: 12)) + .monospacedDigit() + .foregroundStyle(WTheme.inkTertiary) + } } + if let subject = template.subject, !subject.isEmpty { + Text(EmailTemplate.tinted(subject)) + .font(.system(size: 13.5, weight: .medium)) + .foregroundStyle(WTheme.inkSecondary) + .lineLimit(1) + } + Text(EmailTemplate.tinted(template.snippet)) + .font(.system(size: 13.5)) + .foregroundStyle(WTheme.inkTertiary) + .lineLimit(2) } - if let subject = template.subject, !subject.isEmpty { - Text(subject) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } - Text(template.snippet) - .font(.footnote) - .foregroundStyle(.tertiary) - .lineLimit(2) } .padding(.vertical, 6) .contentShape(Rectangle()) } - .buttonStyle(.plain) - .listRowInsets(EdgeInsets(top: 4, leading: 20, bottom: 4, trailing: 16)) - .listRowBackground(Color(.systemBackground)) + .buttonStyle(MoreRowPressStyle()) + .listRowBackground(WTheme.groupedCard) + .listRowInsets(EdgeInsets(top: 8, leading: 14, bottom: 8, trailing: 14)) + .alignmentGuide(.listRowSeparatorLeading) { _ in 66 } .swipeActions(edge: .trailing, allowsFullSwipe: false) { if canWrite { Button { - Task { try? await store.duplicate(env.api, template: template) } + Task { await duplicate(template) } } label: { Label("Duplicate", systemImage: "plus.square.on.square") } .tint(WTheme.accent) } } + .contextMenu { + Button { + UIPasteboard.general.string = template.bodyPlain ?? template.snippet + } label: { + Label("Copy text", systemImage: "doc.on.doc") + } + if canWrite { + Button { + Task { await duplicate(template) } + } label: { + Label("Duplicate", systemImage: "plus.square.on.square") + } + } + } + } + + private func duplicate(_ template: EmailTemplate) async { + if (try? await store.duplicate(env.api, template: template)) != nil { + duplicated += 1 + } + } +} + +// MARK: - Merge fields + +extension EmailTemplate { + /// The `{{field}}` names a template fills in, in first-use order. + var mergeFields: [String] { + let text = [subject, bodyPlain ?? bodyHTML].compactMap { $0 }.joined(separator: " ") + guard let regex = try? Regex(#"\{\{\s*([A-Za-z0-9_.]+)[^}]*\}\}"#) else { return [] } + var seen: [String] = [] + for match in text.matches(of: regex) { + if let name = match.output[1].substring.map(String.init), !seen.contains(name) { + seen.append(name) + } + } + return seen + } +} + +extension EmailTemplate { + /// Text with its `{{field}}` tokens tinted, so they read as fill-ins. + static func tinted(_ text: String) -> AttributedString { + var result = AttributedString(text) + guard let regex = try? Regex(#"\{\{[^}]*\}\}"#) else { return result } + for match in text.matches(of: regex) { + if let range = Range(match.range, in: result) { + result[range].foregroundColor = Tone.indigo.color + } + } + return result + } + + /// A merge field as words: `originalSubject` and `first_name` become + /// "Original subject" and "First name". + static func fieldLabel(_ field: String) -> String { + var words = "" + for char in field { + if char == "_" || char == "." { + words.append(" ") + } else if char.isUppercase, !words.isEmpty, words.last != " " { + words.append(" ") + words.append(contentsOf: char.lowercased()) + } else { + words.append(char) + } + } + let lower = words.lowercased() + return lower.prefix(1).uppercased() + lower.dropFirst() + } +} + +/// A short row of merge-field chips, with a "+N" for the rest. +struct MergeFieldChips: View { + let fields: [String] + var limit = 99 + + var body: some View { + HStack(spacing: 5) { + ForEach(fields.prefix(limit), id: \.self) { field in + Text(EmailTemplate.fieldLabel(field)) + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(Tone.indigo.color) + .lineLimit(1) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Tone.indigo.background, in: Capsule()) + } + if fields.count > limit { + Text("+\(fields.count - limit)") + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(WTheme.inkTertiary) + } + } + } +} + +/// A stable tone per template name, so its monogram keeps its color. +private enum TemplateTone { + static func of(_ name: String) -> Tone { + let tones: [Tone] = [.sky, .indigo, .emerald, .orange, .amber, .rose] + let sum = name.unicodeScalars.reduce(0) { $0 &+ Int($1.value) } + return tones[abs(sum) % tones.count] } } // MARK: - Detail -/// Template detail: renders the body (POST /templates/:id/render with empty -/// variables, mirroring the send path) and falls back to the raw stored body if -/// render fails. Plain HTML shows in a WKWebView; plain text in a scroll view. +/// Template detail: the subject and merge fields over an email-style preview +/// of the rendered body (POST /templates/:id/render with empty variables, +/// falling back to the stored body). struct TemplateDetailView: View { @Environment(AppEnvironment.self) private var env let store: TemplatesStore @@ -165,6 +311,8 @@ struct TemplateDetailView: View { @State private var rendered: TemplateRenderResult? @State private var isRendering = false @State private var duplicating = false + @State private var copied = false + @State private var duplicated = 0 private var canWrite: Bool { env.session.can(.manageCampaigns) } @@ -184,68 +332,134 @@ struct TemplateDetailView: View { } var body: some View { - VStack(spacing: 0) { + VStack(spacing: 12) { header - Divider() preview + actions } + .padding(.horizontal, 16) + .padding(.top, 8) + // Clears the floating tab bar. + .padding(.bottom, 78) + .background(WTheme.groupedPage) .navigationTitle(template.name) .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(WTheme.groupedPage, for: .navigationBar) .presenceResource("template:\(template.id)") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - if canWrite { - if duplicating { - ProgressView().controlSize(.small) - } else { - Button { - duplicate() - } label: { - Image(systemName: "plus.square.on.square") - } - .accessibilityLabel("Duplicate template") - } - } - } - } + .sensoryFeedback(.success, trigger: duplicated) + .sensoryFeedback(.selection, trigger: copied) .task { await render() } } private var header: some View { - VStack(alignment: .leading, spacing: 6) { - EyebrowLabel("Subject") - Text(subject) - .font(.body.weight(.semibold)) + VStack(alignment: .leading, spacing: 8) { + Text("Subject") + .font(.system(size: 12.5, weight: .medium)) + .foregroundStyle(WTheme.inkTertiary) + Text(EmailTemplate.tinted(subject)) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(WTheme.ink) .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + let fields = template.mergeFields + if !fields.isEmpty { + HStack(spacing: 8) { + Text("Fills in") + .font(.system(size: 12.5, weight: .medium)) + .foregroundStyle(WTheme.inkTertiary) + ScrollView(.horizontal) { + MergeFieldChips(fields: fields) + } + .scrollIndicators(.hidden) + } + .padding(.top, 2) + } } .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.vertical, 12) + .coverCard(padding: 16) } @ViewBuilder private var preview: some View { - if isRendering, rendered == nil { - VStack { - Spacer() - ProgressView() - Spacer() + Group { + if isRendering, rendered == nil { + VStack(alignment: .leading, spacing: 10) { + ForEach(0..<5, id: \.self) { line in + RoundedRectangle(cornerRadius: 4) + .fill(WTheme.fill) + .frame(height: 12) + .frame(maxWidth: line == 4 ? 160 : .infinity) + } + Spacer() + } + .padding(18) + .skeletonShimmer() + } else if let html { + HTMLBodyView(html: html) + } else if let plain { + ScrollView { + Text(plain) + .font(.system(size: 15.5)) + .lineSpacing(3) + .foregroundStyle(WTheme.ink) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) + } + } else { + CoverEmptyState(symbol: "doc", color: WTheme.inkSecondary, title: "Empty template", message: "This template has no body yet.") + .frame(maxHeight: .infinity) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let html { - HTMLBodyView(html: html) - } else if let plain { - ScrollView { - Text(plain) - .font(.body) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(WTheme.groupedCard, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + .transition(.opacity) + .animation(.easeOut(duration: 0.25), value: rendered == nil) + } + + private var actions: some View { + HStack(spacing: 10) { + Button { + UIPasteboard.general.string = plain ?? template.snippet + withAnimation(.spring(response: 0.35, dampingFraction: 0.7)) { copied = true } + Task { + try? await Task.sleep(for: .seconds(1.6)) + withAnimation(.snappy) { copied = false } + } + } label: { + Label(copied ? "Copied" : "Copy text", systemImage: copied ? "checkmark" : "doc.on.doc") + .contentTransition(.symbolEffect(.replace)) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(copied ? WTheme.positive : WTheme.ink) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background(WTheme.groupedCard, in: Capsule()) + } + .buttonStyle(TapScaleStyle()) + if canWrite { + Button { + duplicate() + } label: { + HStack(spacing: 8) { + if duplicating { + ProgressView().controlSize(.small).tint(.white) + } else { + Image(systemName: "plus.square.on.square") + } + Text("Duplicate") + } + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(WTheme.inkInverse) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background(WTheme.ink, in: Capsule()) + } + .buttonStyle(TapScaleStyle()) + .disabled(duplicating) } - } else { - EmptyStateView(title: "Empty template", message: "This template has no body content.") } } @@ -264,7 +478,9 @@ struct TemplateDetailView: View { guard !duplicating else { return } duplicating = true Task { - try? await store.duplicate(env.api, template: template) + if (try? await store.duplicate(env.api, template: template)) != nil { + duplicated += 1 + } duplicating = false } } @@ -297,7 +513,7 @@ struct HTMLBodyView: UIViewRepresentable { \(body) """ diff --git a/ios/Warmbly/MainTabView.swift b/ios/Warmbly/MainTabView.swift index 0553bf025..0bbe0566e 100644 --- a/ios/Warmbly/MainTabView.swift +++ b/ios/Warmbly/MainTabView.swift @@ -4,46 +4,110 @@ enum AppTab: String, Hashable { case home, inbox, campaigns, contacts, more } +extension AppTab { + /// The tab to open on launch: Home, or WARMBLY_DEBUG_TAB in scripted debug runs. + static var launchTab: AppTab { + #if DEBUG + if let raw = ProcessInfo.processInfo.environment["WARMBLY_DEBUG_TAB"], let tab = AppTab(rawValue: raw) { + return tab + } + #endif + return .home + } +} + /// Home is the workspace pulse and the front door to mailboxes/analytics; /// Inbox carries the unread badge. Tabs the member lacks permission for are -/// hidden, like the web sidebar. +/// hidden, like the web sidebar. The system tab bar is replaced by `WTabBar`. struct MainTabView: View { @Environment(AppEnvironment.self) private var env - @State private var selection: AppTab = .home + @State private var selection: AppTab = .launchTab + @State private var tabBar = TabBarState() + @State private var showAssistant = false private var canInbox: Bool { env.session.can(.accessUnibox) } private var canCampaigns: Bool { env.session.can(.viewCampaigns) } private var canContacts: Bool { env.session.can(.viewContacts) } + private var canAssistant: Bool { env.session.can(.useAI) } + + /// The round button: the current screen's own action, else Remie. + private var barAction: TabBarAction? { + if let action = tabBar.screenAction { return action } + guard canAssistant else { return nil } + return TabBarAction(title: "Ask Remie", symbol: "sparkle", mark: true) { showAssistant = true } + } + + /// Scripted debug runs open Remie on launch, optionally with a first prompt. + private var debugPrompt: String? { + #if DEBUG + if let raw = ProcessInfo.processInfo.environment["WARMBLY_DEBUG_ASSISTANT"], !raw.isEmpty { + return raw == "1" ? "" : raw + } + #endif + return nil + } + + private var items: [WTabItem] { + var items = [WTabItem(tab: .home, title: "Home", symbol: "house")] + if canInbox { + items.append(WTabItem(tab: .inbox, title: "Inbox", symbol: "tray", badge: env.badges.uniboxUnread)) + } + if canCampaigns { + items.append(WTabItem(tab: .campaigns, title: "Campaigns", symbol: "paperplane")) + } + if canContacts { + items.append(WTabItem(tab: .contacts, title: "Contacts", symbol: "person.2")) + } + items.append(WTabItem(tab: .more, title: "More", symbol: "square.grid.2x2")) + return items + } var body: some View { TabView(selection: $selection) { - Tab("Home", systemImage: "house.fill", value: AppTab.home) { - HomeView() + Tab("Home", systemImage: "house", value: AppTab.home) { + HomeView().toolbarVisibility(.hidden, for: .tabBar) } if canInbox { - Tab("Inbox", systemImage: "tray.fill", value: AppTab.inbox) { - UniboxRootView() + Tab("Inbox", systemImage: "tray", value: AppTab.inbox) { + UniboxRootView().toolbarVisibility(.hidden, for: .tabBar) } - .badge(env.badges.uniboxUnread) } if canCampaigns { - Tab("Campaigns", systemImage: "paperplane.fill", value: AppTab.campaigns) { - CampaignsRootView() + Tab("Campaigns", systemImage: "paperplane", value: AppTab.campaigns) { + CampaignsRootView().toolbarVisibility(.hidden, for: .tabBar) } } if canContacts { - Tab("Contacts", systemImage: "person.2.fill", value: AppTab.contacts) { - ContactsRootView() + Tab("Contacts", systemImage: "person.2", value: AppTab.contacts) { + ContactsRootView().toolbarVisibility(.hidden, for: .tabBar) } } - Tab("More", systemImage: "circle.grid.2x2.fill", value: AppTab.more) { - MoreRootView() + Tab("More", systemImage: "square.grid.2x2", value: AppTab.more) { + MoreRootView().toolbarVisibility(.hidden, for: .tabBar) } } - .tabBarMinimizeBehavior(.onScrollDown) - .sensoryFeedback(.selection, trigger: selection) + .safeAreaInset(edge: .bottom, spacing: 0) { + if !tabBar.isHidden { + WTabBar(items: items, selection: $selection, action: barAction) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.spring(response: 0.34, dampingFraction: 0.86), value: tabBar.isHidden) + .environment(tabBar) + .fullScreenCover(isPresented: $showAssistant) { + AssistantView(page: selection.rawValue, initialPrompt: debugPrompt.flatMap { $0.isEmpty ? nil : $0 }) + } + .onAppear { + if debugPrompt != nil { showAssistant = true } + } .environment(\.switchTab) { tab in selection = tab } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in + tabBar.setKeyboard(true) + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in + tabBar.setKeyboard(false) + } } } diff --git a/ios/Warmbly/RootView.swift b/ios/Warmbly/RootView.swift index 3524546f6..d852404cb 100644 --- a/ios/Warmbly/RootView.swift +++ b/ios/Warmbly/RootView.swift @@ -29,19 +29,26 @@ struct RootView: View { } } -/// Splash: the brand's airy sky with the mark, shown while the persisted -/// session is validated. The mark breathes instead of showing a spinner; -/// matches the auth backdrop so the handoff is seamless. +/// Splash shown while the persisted session is validated: the breathing mark +/// on the theme's canvas, continuing the system launch color in either theme. struct LaunchView: View { + @Environment(\.colorScheme) private var scheme @State private var breathe = false var body: some View { ZStack { - SkyBackdrop() + if scheme == .dark { + SkyBackdrop() + } else { + Color("LaunchBackground").ignoresSafeArea() + } WarmblyLogo() - .fill(.white) + .fill(scheme == .dark ? Color.white : WTheme.ink) .frame(width: 56, height: 57) - .shadow(color: .white.opacity(breathe ? 0.5 : 0.12), radius: breathe ? 28 : 10) + .shadow( + color: (scheme == .dark ? Color.white : WTheme.accent).opacity(breathe ? 0.4 : 0.08), + radius: breathe ? 28 : 10 + ) .scaleEffect(breathe ? 1.06 : 0.96) .animation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true), value: breathe) .onAppear { breathe = true } @@ -59,23 +66,23 @@ struct UnreachableView: View { SkyBackdrop() VStack(spacing: 14) { WarmblyLogo() - .fill(.white) + .fill(WTheme.onSky) .frame(width: 56, height: 57) Text("Can't reach Warmbly") .font(.body.weight(.semibold)) - .foregroundStyle(.white) + .foregroundStyle(WTheme.onSky) Text("You're still signed in. Retrying automatically.") .font(.footnote) - .foregroundStyle(.white.opacity(0.85)) + .foregroundStyle(WTheme.onSky.opacity(0.85)) Button { Task { await env.session.bootstrap() } } label: { Text("Try again") .font(.subheadline.weight(.semibold)) - .foregroundStyle(.white) + .foregroundStyle(WTheme.onSky) .padding(.horizontal, 18) .padding(.vertical, 9) - .background(.white.opacity(0.18), in: Capsule()) + .background(WTheme.skyTile(0.18), in: Capsule()) } .buttonStyle(TapScaleStyle()) } diff --git a/ios/Warmbly/Warmbly.entitlements b/ios/Warmbly/Warmbly.entitlements index 80b5221de..1d3609592 100644 --- a/ios/Warmbly/Warmbly.entitlements +++ b/ios/Warmbly/Warmbly.entitlements @@ -4,6 +4,8 @@ aps-environment development + com.apple.developer.devicecheck.appattest-environment + development com.apple.developer.applesignin Default diff --git a/ios/Warmbly/WarmblyApp.swift b/ios/Warmbly/WarmblyApp.swift index 409a3285e..2200975f7 100644 --- a/ios/Warmbly/WarmblyApp.swift +++ b/ios/Warmbly/WarmblyApp.swift @@ -4,13 +4,15 @@ import SwiftUI struct WarmblyApp: App { @UIApplicationDelegateAdaptor(PushAppDelegate.self) private var pushDelegate @State private var env = AppEnvironment() + @AppStorage(AppearancePreference.storageKey) private var appearance = AppearancePreference.system @Environment(\.scenePhase) private var scenePhase var body: some Scene { WindowGroup { RootView() .environment(env) - .tint(WTheme.accent) + .tint(WTheme.ink) + .preferredColorScheme(appearance.colorScheme) } .onChange(of: scenePhase) { _, phase in if phase == .active { env.realtime.nudge() }