diff --git a/.agents/skills/android-surfaces/SKILL.md b/.agents/skills/android-surfaces/SKILL.md index 90ff254..bb3d423 100644 --- a/.agents/skills/android-surfaces/SKILL.md +++ b/.agents/skills/android-surfaces/SKILL.md @@ -20,14 +20,33 @@ Use this skill for `apps/phone_flutter/`, `apps/wear_android/`, and `apps/watchf ## Phone App - Own provider setup, credential entry, secure storage integration, platform transport, sync scheduling, diagnostics export, and Wear Data Layer send. +- **Dashboard status marks** follow `docs/product/PHONE_DASHBOARD_DESIGN.md` (**rules locked + 2026-08-13**): `Ok` draws nothing; a rollup counts the problems rendered below it and shows a + count where a leaf shows a glyph; family color stays + identity; rank from `ProviderStatus::severity`, never a new list. - Consume dashboard snapshots from Rust. -- Show today, week, month, provider list, provider details, charts, budgets, credits, sync status, and settings. +- Show today, week, month, provider plaques on Dashboard, charts, budgets, credits, sync + status, Providers connection hub, and Settings. - Phase 14 primary tabs: Dashboard → Watchface → Widget → Providers → Settings. Watchface owns - Wear/WFF ring-slot prefs; Widget owns phone home-widget prefs. Do not keep those controls in - Settings once the tabs land (Settings “Watch display” is transitional only). -- Do not add a phone Alerts tab. Active alerts render on the Dashboard; alert rules and budget - thresholds belong in Settings on connection rows / a global budget card (editable even when - Not connected). Providers consumes status; it does not own rule creation. + Wear/WFF ring-slot prefs; Widget owns phone home-widget prefs and the Android App Widget + (`PHONE_WIDGET_DESIGN.md` locked). Phone widget **hugs** complete rows/columns inside + the resize cell (olive `wrap_content`; hide trailing rows and right-hand columns that + would clip — credits → label → `%`; unused cell area is wallpaper until the user shrinks + the span), vertical+horizontal resize (default ~3 cells), day/night chrome, overlay face + watermark (no header band), expands Claude windows, keeps exhausted plan pools as + `0% left`, and appends per-provider purchased credits end-aligned (label centered). + Watchface still collapses Claude / omits exhausted. Do not keep + those controls in Settings. +- **Providers** owns the full connection catalog (Connected / Not connected), credentials / + auth, and **all user-configured** alert thresholds (plan/purchased per connection; + Today/Week/Month budgets on OpenAI Platform). One threshold per meter; UI speaks + **% left** (storage/Rust keep used% as `at`). Editable before first sync. Do not keep + alert rules under Settings. +- **Settings** is systemic only: global polling interval, diagnostics, data deletion, + legal, debug toggles — not connections, credentials, display surface toggles, or alerts. +- Do not add a phone Alerts tab. Active alerts render on the Dashboard. Thresholds are + **user-configured only** — never invent alerts from hard-coded 80%/100% utilization cutoffs. + Provider status chrome (Warning / RateLimited) is separate from the alerts list. - Keep analytics UI dense, clear, and operational rather than marketing-like. ## Wear OS App @@ -41,21 +60,27 @@ Use this skill for `apps/phone_flutter/`, `apps/wear_android/`, and `apps/watchf a local cooldown. Review art: `preview-glance-legend-*.png`. Compose: `GlanceLegendPage` (+ watch→phone refresh message). - **Watch face / WFF** (and any face-like complication preview) follow - `docs/product/WATCH_RING_DESIGN.md` (**locked 2026-07-25**): **inner/center = tightest** + `docs/product/WATCH_RING_DESIGN.md` (**locked 2026-07-25**; later revisions are listed in the + document header): **inner/center = tightest** remaining (plan `%` primary; credit request-runway secondary from internal costs — never show - request counts); arc = remaining; large time hero; sunk family strips (equal width; first strip - nearest center; stroke tuned so 3 strips clear arcs); family colors. Future multi-profile / - hatch / three-ring cap notes there are planning-only until a later phase. + request counts); arc = remaining (clockwise melt from 12); large time hero; sunk family strips + (equal width; first strip nearest center; stroke tuned so 3 strips clear arcs); family colors. + Hard cap: **three** concentric plan/budget rings (`watchRingSlotCount = 3`). Purchased meters + (Extra usage, on-demand, Codex credits) are not ring candidates — phone cards only; alerts + only via user thresholds on Providers. + Future multi-profile / hatch notes there are planning-only until a later phase. - Claude plan windows (`5h` / weekly / Opus / Sonnet) collapse to one watch ring on the phone (`allowance.claude.plan`); Glance shows the active window label. Phone dashboard still lists all. -- Prefer concentric percent layers from schema v4+ on the **face** (up to four selected metrics - today); never invent `Unknown` filler. Unselected, unavailable, or exhausted (`>= 100%`) +- Prefer concentric percent layers from schema v4+ on the **face** (up to three selected metrics); + never invent `Unknown` filler. Unselected, unavailable, or exhausted (`>= 100%`) layers do not render. -- Face center (first) strip / `creditsGlance` may show a compact remaining-credits aggregate when - reported and Settings shows purchased usage (not a ring; never LLM `TOK`). App Glance credits - stay **per provider** with an explicit `credits` label. -- Keep today, week, usage, providers, alerts (active list only — no rule editing), and last sync - as secondary detail screens. +- Face strips show compact remaining credits per provider family from `allowances` (`% · 500`), + same matching rule as Glance; accents use the family ColorRamp. `creditsGlance` is for + credits-only faces when there are no plan rings. +- Beside the Glance the app keeps exactly two surfaces: a **plan windows** page saying when each + window comes back (rules in `apps/wear_android/README.md`), and **Alerts** from the Glance pill + (active list only — no rule editing). The today, week, usage, providers, and last sync screens + were removed in Phase 17; do not bring a currency total back to the watch. - Store and render the latest successful watch summary. - Make stale data explicit. - Do not enter, display, or store provider credentials. @@ -63,19 +88,42 @@ Use this skill for `apps/phone_flutter/`, `apps/wear_android/`, and `apps/watchf ## Watch Face Format +- `watchface.xml` is written by `tools/render-watchface.mjs` (`just render-watchface`), together + with the ring-type drawables it names (`res/drawable-nodpi/`, ImageMagick as `just + export-icons` already uses). Change the generator, never the XML or the PNGs; + `just check-watchface` fails on drift. Geometry and canvas are both the **450**-unit design + language `WATCH_RING_DESIGN.md` locks — the canvas is what WFF renders at before scaling to + the screen, so raising it softens every edge on the face. Do not raise it to sharpen type: + WFF rounds each glyph of a `TextCircular` to a whole canvas unit, and the answer to that is + baking, not resolution. - Keep WFF declarative and minimal; follow the same concentric language as Wear (not side-by-side `RING 1` / `RING 2` placeholders). +- **A scene holds at most eight `ComplicationSlot` elements** — the same in format versions 2, 4 + and 5, so raising the version buys nothing. This face has used all eight since 2026-07-25 + (four rings, four strips; the fourth of each was undrawn until the split-band revision took + the fourth ring). Count before designing anything that needs a new slot — the validator never + says "too many slots": a ninth is reported as `Invalid content was found starting with element + 'ComplicationSlot'`, listing `PartText`, `PartImage`, `Condition` and friends as what it + expected instead. - WFF format version 2 (Wear OS 5+): concentric `RANGED_VALUE` arcs with `WeightedStroke` colors from Wear `ColorRamp` / `[COMPLICATION.RANGED_VALUE_COLORS]`; arc = remaining. - Keep track/progress `endAngle` under 360° (scale onto 359.9°) — a closed circle collapses - to a ROUND tip at 12 o'clock. Prefer simple Transform arithmetic over `clamp()`. + Keep track spans under 360° (scale onto 359.9°) — a closed circle collapses + to a ROUND tip. Remaining melts **clockwise from 12**: Transform `startAngle` + with `(1 - value/max) * 359.9` and fixed `endAngle` 359.9. Prefer simple + Transform arithmetic over `clamp()`. `BoundingArc` clips ring-slot content to the arc band — sunk `%` / credits strips must use - separate `BoundingBox` SHORT_TEXT slots of **equal width**, stacked inside the clear aperture. - Center (first) strip TEXT = full label (`100% · 500`); lower strips TEXT = remaining digits - with Template `%%`. Avoid `length(TITLE)` Conditions — they are unreliable on WFF. Draw strips - after `DigitalClock` so the clock does not cover them. -- Prefer live arcs for selected layers, optional `creditsGlance` on the center strip when present, - large time. Never LLM `TOK` on the face. + separate `BoundingBox` `RANGED_VALUE` slots of **equal width**, stacked inside the clear + aperture. Every strip TEXT = full label (`46%` or `100% · 500`); accents use + `[COMPLICATION.RANGED_VALUE_COLORS]`. Avoid `length(TITLE)` Conditions — they are unreliable + on WFF. Draw strips after `DigitalClock` so the clock does not cover them. +- A ring slot's TITLE carries the budget period (`D` / `7D` / `M`, empty for plan rings), and the + face repeats it around that band as background-colour cut-out type, branching on + `[COMPLICATION.TITLE]` equality to pick one baked image per ring and period. The type runs in + four sweeps with a bare break on each diagonal, aligned across rings; within a sweep tokens are + placed by rotation, and the repeat counts in `tools/render-watchface.mjs` are the fitted + constants both that generator and the review-art one read — copy, never hand-tune. +- Prefer live arcs for selected layers, optional `creditsGlance` on the **matching provider** + strip when present, large time. Never LLM `TOK` on the face. - Support tap-to-open into the Wear OS app where possible. - Keep ambient mode readable (dim arcs, strips off). diff --git a/.agents/skills/finalization/SKILL.md b/.agents/skills/finalization/SKILL.md index 89c7552..834389c 100644 --- a/.agents/skills/finalization/SKILL.md +++ b/.agents/skills/finalization/SKILL.md @@ -9,7 +9,7 @@ description: Post-change finalization checklist for WardPulse (Rust core, Flutte > > Run this after implementation or documentation work to keep the monorepo coherent. > -> Last Updated: 2026-07-24 +> Last Updated: 2026-08-01 ## 1. Scope Review @@ -44,17 +44,21 @@ description: Post-change finalization checklist for WardPulse (Rust core, Flutte ```bash cd core && cargo fmt --all -- --check cd core && cargo clippy --workspace --all-targets -- -D warnings +cd core && RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --document-private-items cd core && cargo test --workspace ``` - [ ] If Flutter code changed and the project is generated, run: ```bash -cd apps/phone_flutter && flutter analyze && flutter test +just check-phone ``` + This runs `dart format --set-exit-if-changed` over `apps/phone_flutter` and `bindings/dart`, + then `flutter analyze` and `flutter test`. Use `just fmt-phone` to apply formatting. + - [ ] If Android/Wear/WFF Gradle projects are generated, run the relevant Gradle test/build task. -- [ ] If schemas or fixtures changed, validate fixture shape manually or with `tools/validate-fixtures/` when available. +- [ ] If schemas or fixtures changed, run `just validate-fixtures`: it parses every file and checks each golden fixture against its schema. - [ ] If GitHub Actions workflows changed, run `actionlint .github/workflows/*.yml`. - [ ] If durable docs, Vocs pages, component READMEs, or site navigation changed, run: @@ -62,7 +66,36 @@ cd apps/phone_flutter && flutter analyze && flutter test just check-docs ``` -## 5. Documentation Review +## 5. Emulator Check + +Automated gates do not exercise the app. Unit tests use fakes, so they never load +`libward_pulse_ffi.so`, never resolve an exported symbol, and never render a screen. + +Run the app on an emulator when the change could only fail there: + +- [ ] FFI surface changed — exported symbol names, argument or return shapes, or the + Dart binding. Symbols resolve at **run time**, so a mismatch survives every build. +- [ ] The serialized snapshot contract changed — a new or renamed field, or a different + value shape. Host-side round trips do not prove the Android build agrees. +- [ ] UI, widget, watch face, or phone-to-watch payload behaviour changed. +- [ ] Background scheduling, secure storage, or WebView sign-in changed. + +Skip it, and say so, for changes that cannot reach a device: pure refactors behind an +unchanged contract, comments and docs, tests, CI wiring, lockfiles. + +```bash +adb devices -l # phone and Wear AVDs are usually already running +tools/build-android-rust/build.sh # Rust .so for Android ABIs — slow +cd apps/phone_flutter && flutter build apk --debug +adb -s install -r build/app/outputs/flutter-apk/app-debug.apk +adb -s shell am start -n app.wardpulse/.MainActivity +adb -s logcat -d | grep -i wardpulse +``` + +Report what was exercised on device and what was not. "Tests pass" is not evidence that +the app runs; only the device is. + +## 6. Documentation Review - [ ] `docs/README.md` links any new durable document. - [ ] The Vocs page and `docs/site/vocs.config.ts` navigation are updated for documentation exposed on the site. @@ -71,7 +104,7 @@ just check-docs - [ ] Product, provider, release, or security docs are updated when behavior or boundaries changed. - [ ] Placeholder TODOs are acceptable only for intentionally deferred platform generation work. -## 6. Staging And Commit Boundary +## 7. Staging And Commit Boundary **Finalization never creates a git commit.** @@ -85,7 +118,7 @@ this change set (`git add` the relevant paths). Stop there. - Draft the commit message for the staged set; leave the actual commit to the user or to a later explicit request. -## 7. Commit Message Draft +## 8. Commit Message Draft Draft commit messages in Conventional Commits form: @@ -148,15 +181,16 @@ chore(docs): plan adaptive provider UI, polling, and watch rings Record the dual plan/platform model and phases 9–13, set the global refresh floor to 5–60 minutes, and make finalization stage-only with an explicit no-commit rule. ``` -## 8. Handoff Summary +## 9. Handoff Summary When finalization completes, report: -1. Relevant checklist sections completed or skipped with reason. +1. Relevant checklist sections completed or skipped with reason, including whether the + change was exercised on an emulator. 2. Commands run and pass/fail status. 3. Important files changed. 4. Remaining risks or intentionally deferred work. 5. Git staging status after staging: what is staged, what remains unstaged or untracked. -6. Idiomatic draft commit message for the staged set, using section 7. +6. Idiomatic draft commit message for the staged set, using section 8. Remind the user that the changes are staged only and that no commit was created. diff --git a/.agents/skills/rust-core/SKILL.md b/.agents/skills/rust-core/SKILL.md index 0a3560b..ee6661c 100644 --- a/.agents/skills/rust-core/SKILL.md +++ b/.agents/skills/rust-core/SKILL.md @@ -18,6 +18,11 @@ Use this skill for work under `core/` and Rust-owned contracts. - Provider-specific raw data enters the core; normalized dashboard snapshots leave the core. - Rust owns budgets, credits, projections, alerts, model breakdowns, status mapping, and watch summary derivation. +- Alert evaluation uses **user-configured** rules/thresholds from platform preferences + (`calculate_alerts(snapshot, settings)`). Each rule is a single used-% threshold (`at`). + Providers owns all alert rules (plan/purchased + OpenAI Platform budgets). Do not invent + dashboard alerts from allowance or budget `ProviderStatus` alone; status chrome and the + alerts list are separate. - Platform code owns HTTP clients, TLS, retries, background execution, secure credential retrieval, encrypted storage, and UI state. - Prefer deterministic functions that are easy to fixture-test. - Preserve previous successful snapshot semantics when modeling sync failure states. @@ -40,6 +45,7 @@ After Rust changes, run: ```bash cd core && cargo fmt --all -- --check cd core && cargo clippy --workspace --all-targets -- -D warnings +cd core && RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --document-private-items cd core && cargo test --workspace ``` diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000..2b7a412 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index 03552bc..d1e5f19 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -30,9 +30,12 @@ jobs: with: toolchain: ${{ matrix.rust }} components: rustfmt, clippy - - name: Validate JSON syntax + - name: Validate shared contracts working-directory: . run: python3 tools/validate-fixtures/validate_json.py - run: cargo fmt --all -- --check - run: cargo clippy --workspace --all-targets -- -D warnings + - run: cargo doc --workspace --no-deps --document-private-items + env: + RUSTDOCFLAGS: -D warnings - run: cargo test --workspace diff --git a/.github/workflows/designs.yml b/.github/workflows/designs.yml new file mode 100644 index 0000000..7b4e57e --- /dev/null +++ b/.github/workflows/designs.yml @@ -0,0 +1,36 @@ +name: Review Art + +on: + pull_request: + branches: [main] + paths: + - "tools/render-watch-ring-designs.mjs" + - "tools/render-wear-glance-designs.mjs" + - "tools/render-phone-widget-designs.mjs" + - "tools/render-brand-icons.mjs" + - "tools/design-output.mjs" + - "apps/*/design/**" + - "brand/icons/**" + - ".github/workflows/designs.yml" + +permissions: + contents: read + +jobs: + designs: + name: Check against generators + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + - name: Install Noto and Pillow + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends fonts-noto-core python3-pil + - run: node tools/render-watch-ring-designs.mjs --check + - run: node tools/render-wear-glance-designs.mjs --check + - run: node tools/render-phone-widget-designs.mjs --check + - run: node tools/render-brand-icons.mjs --check diff --git a/.github/workflows/phone-android.yml b/.github/workflows/phone-android.yml index ee2762f..c3247b2 100644 --- a/.github/workflows/phone-android.yml +++ b/.github/workflows/phone-android.yml @@ -44,7 +44,13 @@ jobs: targets: aarch64-linux-android,x86_64-linux-android - run: cargo install cargo-ndk --version 4.1.2 --locked working-directory: . + - name: Validate shared contracts + run: python3 tools/validate-fixtures/validate_json.py + working-directory: . - run: flutter pub get --enforce-lockfile + - run: dart format --output=none --set-exit-if-changed lib test + - run: dart format --output=none --set-exit-if-changed lib + working-directory: bindings/dart - run: flutter analyze - run: flutter test - run: tools/build-android-rust/build.sh diff --git a/.github/workflows/watchface.yml b/.github/workflows/watchface.yml index 36abe1c..9497f4c 100644 --- a/.github/workflows/watchface.yml +++ b/.github/workflows/watchface.yml @@ -5,6 +5,8 @@ on: branches: [main] paths: - "apps/watchface_wff/**" + - "tools/render-watchface.mjs" + - "tools/design-output.mjs" - "tools/validate-watchface/**" - ".github/workflows/watchface.yml" @@ -21,6 +23,9 @@ jobs: working-directory: apps/watchface_wff steps: - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc - uses: actions/setup-java@v5 with: distribution: temurin @@ -32,4 +37,6 @@ jobs: packages: "platforms;android-37.1 build-tools;36.0.0" - run: tools/validate-watchface/validate.sh working-directory: . + - run: node tools/render-watchface.mjs --check + working-directory: . - run: ./gradlew --no-daemon lintDebug assembleDebug bundleDebug diff --git a/.github/workflows/wear-android.yml b/.github/workflows/wear-android.yml index 526cb43..d606a68 100644 --- a/.github/workflows/wear-android.yml +++ b/.github/workflows/wear-android.yml @@ -31,4 +31,7 @@ jobs: uses: android-actions/setup-android@v4 with: packages: "platforms;android-37.1 build-tools;36.0.0" + - name: Validate shared contracts + run: python3 tools/validate-fixtures/validate_json.py + working-directory: . - run: ./gradlew --no-daemon lintDebug testDebugUnitTest assembleDebug assembleDebugAndroidTest diff --git a/.gitignore b/.gitignore index ca6be0f..1897a8c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ !.env.example *.local *.log +LOCAL_ARTIFACTS/ +/prepush.sh # Rust target/ @@ -28,6 +30,8 @@ docs/site/.vocs/ .packages build/ coverage/ +# Library package — consumers resolve deps; app lock lives under apps/phone_flutter/. +bindings/dart/pubspec.lock # Android / Gradle .gradle/ diff --git a/AGENTS.md b/AGENTS.md index bbab53d..d54a68a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,191 @@ - Do not treat WardPulse brand assets as covered by Apache-2.0 unless a file explicitly says so. - Keep OpenPencil `.fig` sources with their owner and regenerate runtime exports instead of editing them by hand. +## Repository Map + +Use this map before exploring. It is the navigation source of truth; update it when +ownership or layout changes. `justfile` at the root is the command index (`just --list`). + +| Subsystem | Path | Purpose | +| --- | --- | --- | +| Rust domain core | `core/ward-pulse-core/` | Deterministic domain logic: snapshot building, alerts, budgets, projections | +| Provider adapters | `core/ward-pulse-providers/` | Parse per-provider usage reports into provider snapshots | +| FFI crate | `core/ward-pulse-ffi/` | C-ABI JSON-string exports of core + providers for the phone | +| CLI | `core/ward-pulse-cli/` | Prints the mock dashboard snapshot (`just snapshot-core`, stdout only) | +| Bindings | `bindings/dart/` | `ward_pulse_bindings` package; loads `libward_pulse_ffi.so` via `dart:ffi` (`kotlin`/`swift` siblings are `.gitkeep` placeholders) | +| Phone shell | `apps/phone_flutter/` | Flutter dashboard, provider connections, settings, watch/widget sync | +| Wear OS app | `apps/wear_android/` | Compose Glance legend, summary store, complications | +| Watch face | `apps/watchface_wff/` | Declarative Watch Face Format package | +| Schemas | `schemas/` | JSON Schemas for snapshots, accounts, usage buckets, budgets, watch summary | +| Fixtures | `fixtures/` | Sanitized provider payloads + cross-language golden snapshots | +| Tools | `tools/` | Review-art generators, icon export, Rust-for-Android build, validators | +| Brand assets | `brand/` | WardPulse identity sources and locked previews (ownership: `docs/DESIGN_ASSETS.md`) | +| Docs | `docs/` | Durable documentation; `docs/product/` holds locked design baselines | +| Skills | `.agents/skills/` | Repo workflow skills (`.claude/skills` is a symlink to it) | +| Local scratch | `LOCAL_ARTIFACTS/` | Gitignored working notes and plans; never committed | + +### Rust workspace (`core/`) + +Run all cargo commands from `core/` — there is no root `Cargo.toml`. Tests are in-module +(`#[cfg(test)] mod tests` at the bottom of each `mod.rs`); there are no `tests/` directories. + +- Types live in `ward-pulse-core/src/model/mod.rs` (`DashboardSnapshot`, `ProviderSnapshot`, + `Money`). Time handling: `time.rs`. +- `dashboard/mod.rs` builds snapshots and aggregate totals; `alerts/mod.rs` applies alert + settings and local budget limits; `budget/` evaluates spend against a limit; `projection/` + projects spend linearly over a period. +- One adapter module per family: `openai/`, `codex/`, `claude/`, `cursor/`, `mock/`. + `claude/platform.rs` and `cursor/platform.rs` normalize the Anthropic organization and + Cursor team Admin APIs; `mock/demo.rs` invents demo data. Shared: `allowance.rs`, `poll.rs`. +- FFI surface: `ward-pulse-ffi/src/lib.rs` (single file). Adding a core capability the phone + needs means touching ffi + `bindings/dart/lib/src/ward_pulse_bindings.dart` + + `apps/phone_flutter/lib/dashboard/phone_live_bindings.dart`. + +### Phone (`apps/phone_flutter/`) + +Entry: `lib/main.dart` → `lib/app/ward_pulse_app.dart`. Tests: flat `test/` +(`_test.dart`, usually matching a lib file). Config: `pubspec.yaml` (path dependency on +`bindings/dart`). + +- `lib/dashboard/` — screen, per-provider repositories, `phone_live_bindings.dart` (FFI wiring). +- `lib/providers/` — Providers tab: connection rows, credential stores/dialogs, + `alert_threshold_dialogs.dart`. +- `lib/settings/` — preference stores; `watch_ring_preferences.dart` owns the ring catalog, + selection, and ring-id migrations. +- `lib/sync/` — headless provider polling and phone→watch transport + (`watch_sync_service.dart`, scheduler, reporting clients). +- `lib/watchface/` — Watchface tab; `lib/widget/` — home-widget payload and preferences; + `lib/charts/` — history chart and budget bar. +- Android host code: `android/app/src/main/kotlin/app/wardpulse/` (`MainActivity`, + `WardPulseAppWidget`, `WatchRefreshListenerService`). + +### Wear (`apps/wear_android/`) + +Kotlin lives under `app/src/main/java/app/wardpulse/wear/` (ignore the empty top-level +`data/`, `ui/`, `test/` directories). Unit tests: `app/src/test/`; device tests: +`app/src/androidTest/`. Review art: `design/`. + +- `ui/` — `WardPulseApp.kt` (Glance page, plan windows page, Alerts), + `GlanceModels.kt`/`GlanceLegend.kt`, `PlanWindows.kt` (when each window comes back), + `RingFamily.kt` (ring-id → family color). +- `data/WatchSummaryStore.kt` — persisted summary; owns `SCHEMA_VERSION` (must match the + `schemaVersion` written by phone `watch_sync_service.dart`; a mismatch discards the payload). +- `sync/` — `WatchSummaryListenerService.kt`, `WatchDataContract.kt` (paths/keys), + `PhoneRefreshRequester.kt`. `complication/` — watch complications. + +### Watch face (`apps/watchface_wff/`) + +One declarative file, `src/main/res/raw/watchface.xml`, plus the ring-type drawables it names +(`res/drawable-nodpi/`) — all **generated** by `tools/render-watchface.mjs` +(`just render-watchface`, needs ImageMagick); edit the generator, never its output. +`just check-watchface` fails on drift. Metadata in `res/xml/watch_face_info.xml`. Validate with +`just validate-watchface`. Geometry and language rules are locked in +`docs/product/WATCH_RING_DESIGN.md` — read it before changing the face. + +### Shared contracts and goldens + +- `schemas/*.schema.json` — JSON Schemas; validate with `just validate-fixtures`. +- `fixtures/providers//` — sanitized provider payloads used by Rust and phone tests. +- `fixtures/snapshots/` — cross-language goldens; `dashboard_today.json` is compared in + FFI/CLI tests and loaded by 12+ phone tests. Regenerate via `just snapshot-core > fixtures/snapshots/dashboard_today.json`; + the other snapshot fixtures have no generator and are edited by hand. + +### Design and docs + +- Locked baselines in `docs/product/`: `WATCH_RING_DESIGN.md` (face), `WEAR_GLANCE_DESIGN.md` + (Glance), `PHONE_WIDGET_DESIGN.md` (widget), `PHONE_DASHBOARD_DESIGN.md` (Dashboard status + marks — rules, no review board); asset ownership in `docs/DESIGN_ASSETS.md`; + phases in `docs/DEVELOPMENT_PLAN.md`; provider API notes in `docs/product/PROVIDER_NOTES.md`. +- Review art is generated, never hand-edited: `tools/render-watch-ring-designs.mjs` (face), + `tools/render-wear-glance-designs.mjs` (Glance), `tools/render-phone-widget-designs.mjs` + (widget), `tools/render-brand-icons.mjs` + `just export-icons` (brand marks). Redraw all four + with `just render-designs`; `just check-designs` fails when the tree no longer matches them. +- Android toolchain and device workflow: `docs/ANDROID_TOOLCHAIN.md`. + +## Task → first files + +### Provider parsing or a new provider capability +Start with: +- `core/ward-pulse-providers/src//mod.rs` +- `fixtures/providers//` +- `docs/product/PROVIDER_NOTES.md` + +### Dashboard aggregation, alerts, budgets +Start with: +- `core/ward-pulse-core/src/dashboard/mod.rs` +- `core/ward-pulse-core/src/alerts/mod.rs` +- `core/ward-pulse-core/src/model/mod.rs` + +### Exposing core data to the phone (FFI) +Start with: +- `core/ward-pulse-ffi/src/lib.rs` +- `bindings/dart/lib/src/ward_pulse_bindings.dart` +- `apps/phone_flutter/lib/dashboard/phone_live_bindings.dart` + +### Phone UI (dashboard, providers, settings) +Start with: +- `docs/product/PHONE_DASHBOARD_DESIGN.md` (status marks — read it before adding one) +- `apps/phone_flutter/lib/dashboard/dashboard_screen.dart` +- `apps/phone_flutter/lib/providers/providers_screen.dart` +- `apps/phone_flutter/lib/settings/settings_screen.dart` + +### Watch ring catalog, selection, payload +Start with: +- `apps/phone_flutter/lib/settings/watch_ring_preferences.dart` +- `apps/phone_flutter/lib/sync/watch_sync_service.dart` +- `apps/phone_flutter/lib/watchface/watchface_screen.dart` + +### Phone→watch transport or summary schema +Start with: +- `apps/phone_flutter/lib/sync/watch_sync_service.dart` +- `apps/wear_android/app/src/main/java/app/wardpulse/wear/data/WatchSummaryStore.kt` +- `apps/wear_android/app/src/main/java/app/wardpulse/wear/sync/WatchDataContract.kt` + +### Wear UI (Glance, rings, complications) +Start with: +- `apps/wear_android/app/src/main/java/app/wardpulse/wear/ui/WardPulseApp.kt` +- `apps/wear_android/app/src/main/java/app/wardpulse/wear/ui/GlanceModels.kt` +- `apps/wear_android/app/src/main/java/app/wardpulse/wear/ui/RingFamily.kt` + +### Watch face rendering +Start with: +- `docs/product/WATCH_RING_DESIGN.md` +- `tools/render-watchface.mjs` (writes `apps/watchface_wff/src/main/res/raw/watchface.xml` and + the ring-type drawables beside it) + +### Phone home widget +Start with: +- `apps/phone_flutter/lib/widget/phone_widget_payload.dart` +- `apps/phone_flutter/android/app/src/main/kotlin/app/wardpulse/WardPulseAppWidget.kt` +- `docs/product/PHONE_WIDGET_DESIGN.md` + +### Review art or design changes +Start with: +- the owning design doc in `docs/product/` +- the matching `tools/render-*-designs.mjs` generator + +### Tests +Start with: +- Rust: `mod tests` at the bottom of the module you changed (run from `core/`) +- Phone: `apps/phone_flutter/test/_test.dart` (usually mirrors the lib file name) +- Wear: `apps/wear_android/app/src/test/` (unit) or `app/src/androidTest/` (device) + +## Context and File Reading Policy + +- Use the Repository Map and Task → first files before exploring the tree. +- Search for symbols (grep) before opening files; open a file only when the search confirms it + is relevant. +- Prefer targeted reads (line ranges around the match) over full-file reads; most files here + are read for one function or one table. +- Do not reread files that have not changed since the last read. +- Do not read generated, build, lock, or vendor content (`core/target/`, `*/build/`, + `node_modules/`, `Cargo.lock`, `package-lock.json`, `pubspec.lock`, `bindings/*/generated/`) + unless the task is specifically about it. +- Use subagents for broad repository exploration; keep the main session's context on files + relevant to the current change. +- Never sacrifice correctness to save context: verify claims in the actual code before acting + on them, and prefer one extra targeted read over a guess. + ## Required Checks After Rust changes, run: @@ -29,10 +214,12 @@ After Rust changes, run: ```bash cd core && cargo fmt --all -- --check cd core && cargo clippy --workspace --all-targets -- -D warnings +cd core && RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --document-private-items cd core && cargo test --workspace ``` -After schema or fixture changes, validate JSON syntax and keep examples sanitized: +After schema or fixture changes — and after touching the payload version in either shell — +validate the shared contracts and keep examples sanitized: ```bash just validate-fixtures @@ -58,3 +245,5 @@ just check-docs - Keep component-specific guidance beside its owner and expose it through thin pages in `docs/site/`. - Update `docs/site/vocs.config.ts` when site navigation changes. - Update local skills in `.agents/skills/` when repository workflow or ownership boundaries change. +- `.claude/skills` is a symlink to `.agents/skills/` so Claude Code discovers the same set. Edit + the skills through `.agents/skills/`; never add a second copy under `.claude/`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md index d73e258..9654cbb 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ This repository is intentionally organized as one product monorepo with separate - `apps/phone_flutter/` contains the Flutter phone app shell. - `apps/wear_android/` contains the native Kotlin/Compose for Wear OS shell. - `apps/watchface_wff/` contains the declarative Watch Face Format package. -- `schemas/` contains shared JSON schemas for snapshots, accounts, usage buckets, and budgets. +- `schemas/` contains shared JSON schemas for payloads crossing a language or device boundary. - `fixtures/` contains sanitized provider fixtures and stable dashboard snapshots. - `bindings/` contains platform binding wrappers. - `brand/` contains protected product identity, shared OpenPencil sources, and store artwork. @@ -21,8 +21,9 @@ This repository is intentionally organized as one product monorepo with separate ## Current Phase -Phase 7 provider reporting and Phase 8 hardening are in progress. OpenAI Platform organization -reporting is separate from Codex subscription usage; both are fetched directly by the phone. +Phase 13 (configurable watch rings / WFF face) is in progress on device. Provider reporting +for Codex, Claude, Cursor plan, and OpenAI Platform continues in parallel; Cursor plan uses +in-app WebView sign-in (not OAuth). ## Useful Commands @@ -39,6 +40,7 @@ just build-android-rust just run-phone just check-wear just validate-watchface +just render-watchface just check-watchface just docs-dev just check-docs @@ -56,6 +58,7 @@ Direct Rust commands work from `core/`: ```sh cargo fmt --all -- --check cargo clippy --workspace --all-targets -- -D warnings +RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --document-private-items cargo test --workspace cargo run --quiet -p ward-pulse-cli ``` diff --git a/apps/phone_flutter/android/app/build.gradle.kts b/apps/phone_flutter/android/app/build.gradle.kts index 0332134..d8f5aa9 100644 --- a/apps/phone_flutter/android/app/build.gradle.kts +++ b/apps/phone_flutter/android/app/build.gradle.kts @@ -12,6 +12,11 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 + // Demanded by the flutter_local_notifications AAR, whose scheduling API + // is built on java.time. WardPulse posts immediately and wakes through + // WorkManager instead, but the requirement is on the library, not on + // how much of it we use. + isCoreLibraryDesugaringEnabled = true } defaultConfig { @@ -48,4 +53,5 @@ flutter { dependencies { implementation("com.google.android.gms:play-services-wearable:20.0.1") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") } diff --git a/apps/phone_flutter/android/app/src/main/AndroidManifest.xml b/apps/phone_flutter/android/app/src/main/AndroidManifest.xml index a09112b..b0277c8 100644 --- a/apps/phone_flutter/android/app/src/main/AndroidManifest.xml +++ b/apps/phone_flutter/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,9 @@ + + + + + + + + + = WIDTH_FULL_DP -> ColumnMode.Full + available >= WIDTH_LABEL_DP -> ColumnMode.WithLabel + else -> ColumnMode.PercentOnly + } + } + + private fun availableHeightDp( + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + ): Int { + val options = appWidgetManager.getAppWidgetOptions(appWidgetId) + return tighterBound( + options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0), + options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0), + DEFAULT_HEIGHT_DP, + ) + } + + private fun availableWidthDp( + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + ): Int { + val options = appWidgetManager.getAppWidgetOptions(appWidgetId) + return tighterBound( + options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 0), + options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, 0), + DEFAULT_WIDTH_DP, + ) + } + + private fun tighterBound( + min: Int, + max: Int, + fallback: Int, + ): Int = + when { + min > 0 && max > 0 -> minOf(min, max) + min > 0 -> min + max > 0 -> max + else -> fallback + } + + /** Matches [R.layout.ward_pulse_app_widget]: 6+6 pad, ~17dp row, 5dp gaps. */ + private fun heightNeededDp(rows: Int): Int { + if (rows <= 0) { + return 0 + } + return CHROME_PAD_V_DP + + ROW_CONTENT_DP + + (rows - 1) * (ROW_CONTENT_DP + ROW_GAP_DP) + + FIT_SLACK_DP + } + + private fun buildViews( + context: Context, + appWidgetId: Int, + data: SharedPreferences, + maxRows: Int, + columns: ColumnMode, + ): RemoteViews { + val views = RemoteViews(context.packageName, R.layout.ward_pulse_app_widget) + + val stale = data.getString("stale", "0") == "1" + views.setViewVisibility( + R.id.widget_stale, + if (stale) View.VISIBLE else View.GONE, + ) + + val storedCount = data.getInt("row_count", 0).coerceIn(0, MAX_ROW_COUNT) + val rowCount = storedCount.coerceAtMost(maxRows) + val empty = data.getString("empty", if (storedCount == 0) "1" else "0") == "1" || + rowCount == 0 + views.setViewVisibility( + R.id.widget_empty, + if (empty) View.VISIBLE else View.GONE, + ) + + val showLabel = columns != ColumnMode.PercentOnly + val showCredits = columns == ColumnMode.Full + + val rowIds = + intArrayOf( + R.id.widget_row_0, + R.id.widget_row_1, + R.id.widget_row_2, + R.id.widget_row_3, + R.id.widget_row_4, + R.id.widget_row_5, + ) + val percentIds = + intArrayOf( + R.id.widget_row_0_percent, + R.id.widget_row_1_percent, + R.id.widget_row_2_percent, + R.id.widget_row_3_percent, + R.id.widget_row_4_percent, + R.id.widget_row_5_percent, + ) + val creditsIds = + intArrayOf( + R.id.widget_row_0_credits, + R.id.widget_row_1_credits, + R.id.widget_row_2_credits, + R.id.widget_row_3_credits, + R.id.widget_row_4_credits, + R.id.widget_row_5_credits, + ) + val labelIds = + intArrayOf( + R.id.widget_row_0_label, + R.id.widget_row_1_label, + R.id.widget_row_2_label, + R.id.widget_row_3_label, + R.id.widget_row_4_label, + R.id.widget_row_5_label, + ) + val accentIds = + intArrayOf( + R.id.widget_row_0_accent, + R.id.widget_row_1_accent, + R.id.widget_row_2_accent, + R.id.widget_row_3_accent, + R.id.widget_row_4_accent, + R.id.widget_row_5_accent, + ) + + for (i in 0 until MAX_ROW_COUNT) { + if (!empty && i < rowCount) { + val percent = data.getString("row_${i}_percent", "") ?: "" + val credits = data.getString("row_${i}_credits", "") ?: "" + val label = data.getString("row_${i}_label", "") ?: "" + // Colourless on purpose: a row whose accent did not arrive names no + // family, and blue is a product colour again since 2026-08-13. + val colorHex = data.getString("row_${i}_color", "ff8a968f") ?: "ff8a968f" + val color = colorHex.toLongOrNull(16)?.toInt() ?: 0xFF8A968F.toInt() + views.setViewVisibility(rowIds[i], View.VISIBLE) + views.setTextViewText(percentIds[i], percent) + views.setTextViewText(creditsIds[i], credits) + // The payload carries `Family · Pool`; the label column is two lines, + // so the separator becomes the line break instead of eating width. + views.setTextViewText(labelIds[i], label.replace(" · ", "\n")) + views.setViewVisibility( + labelIds[i], + if (showLabel) View.VISIBLE else View.GONE, + ) + views.setViewVisibility( + creditsIds[i], + if (showCredits) View.VISIBLE else View.GONE, + ) + views.setInt(accentIds[i], "setBackgroundColor", color) + } else { + views.setViewVisibility(rowIds[i], View.GONE) + } + } + + val launchIntent = + Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pending = + PendingIntent.getActivity( + context, + appWidgetId, + launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + views.setOnClickPendingIntent(R.id.widget_root, pending) + return views + } + + private enum class ColumnMode { + PercentOnly, + WithLabel, + Full, + } + + private companion object { + const val MAX_ROW_COUNT = 6 + const val DEFAULT_HEIGHT_DP = 110 + const val DEFAULT_WIDTH_DP = 180 + + /** Card paddingTop + paddingBottom. */ + const val CHROME_PAD_V_DP = 12 + /** Card paddingStart + paddingEnd. */ + const val CHROME_PAD_H_DP = 20 + /** Accent bar + percent marginStart. */ + const val ACCENT_DP = 11 + const val PERCENT_COL_DP = 70 + const val LABEL_COL_DP = 100 + const val CREDITS_COL_DP = 88 + const val COL_GAP_DP = 6 + const val ROW_CONTENT_DP = 31 + const val ROW_GAP_DP = 5 + const val FIT_SLACK_DP = 6 + + /** % left only. */ + const val WIDTH_PERCENT_DP = + CHROME_PAD_H_DP + ACCENT_DP + PERCENT_COL_DP + FIT_SLACK_DP + + /** % left + label. */ + const val WIDTH_LABEL_DP = WIDTH_PERCENT_DP + COL_GAP_DP + LABEL_COL_DP + + /** % left + label + credits. */ + const val WIDTH_FULL_DP = WIDTH_LABEL_DP + COL_GAP_DP + CREDITS_COL_DP + } +} diff --git a/apps/phone_flutter/android/app/src/main/res/drawable/ward_pulse_widget_background.xml b/apps/phone_flutter/android/app/src/main/res/drawable/ward_pulse_widget_background.xml new file mode 100644 index 0000000..7f9a5b0 --- /dev/null +++ b/apps/phone_flutter/android/app/src/main/res/drawable/ward_pulse_widget_background.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/apps/phone_flutter/android/app/src/main/res/drawable/wardpulse_mono.png b/apps/phone_flutter/android/app/src/main/res/drawable/wardpulse_mono.png new file mode 100644 index 0000000..e56b308 Binary files /dev/null and b/apps/phone_flutter/android/app/src/main/res/drawable/wardpulse_mono.png differ diff --git a/apps/phone_flutter/android/app/src/main/res/layout/ward_pulse_app_widget.xml b/apps/phone_flutter/android/app/src/main/res/layout/ward_pulse_app_widget.xml new file mode 100644 index 0000000..ce18ab4 --- /dev/null +++ b/apps/phone_flutter/android/app/src/main/res/layout/ward_pulse_app_widget.xml @@ -0,0 +1,348 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/phone_flutter/android/app/src/main/res/layout/ward_pulse_app_widget_preview.xml b/apps/phone_flutter/android/app/src/main/res/layout/ward_pulse_app_widget_preview.xml new file mode 100644 index 0000000..2ebc44d --- /dev/null +++ b/apps/phone_flutter/android/app/src/main/res/layout/ward_pulse_app_widget_preview.xml @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/phone_flutter/android/app/src/main/res/values-night/colors_widget.xml b/apps/phone_flutter/android/app/src/main/res/values-night/colors_widget.xml new file mode 100644 index 0000000..5773f73 --- /dev/null +++ b/apps/phone_flutter/android/app/src/main/res/values-night/colors_widget.xml @@ -0,0 +1,12 @@ + + + + #101412 + #24A7B09E + #F4FBF8 + #A7B09E + #E8915A + #8A968F + + #70C5CDD1 + diff --git a/apps/phone_flutter/android/app/src/main/res/values/colors_widget.xml b/apps/phone_flutter/android/app/src/main/res/values/colors_widget.xml new file mode 100644 index 0000000..158d2e8 --- /dev/null +++ b/apps/phone_flutter/android/app/src/main/res/values/colors_widget.xml @@ -0,0 +1,12 @@ + + + + #F2F4F1 + #1A5F675C + #1A1F1A + #5F675C + #C46A3A + #8A968F + + #665F675C + diff --git a/apps/phone_flutter/android/app/src/main/res/values/strings_widget.xml b/apps/phone_flutter/android/app/src/main/res/values/strings_widget.xml new file mode 100644 index 0000000..f4004f2 --- /dev/null +++ b/apps/phone_flutter/android/app/src/main/res/values/strings_widget.xml @@ -0,0 +1,7 @@ + + + WardPulse + Glanceable remaining usage on the home screen + Stale + No metrics + diff --git a/apps/phone_flutter/android/app/src/main/res/xml/ward_pulse_app_widget_info.xml b/apps/phone_flutter/android/app/src/main/res/xml/ward_pulse_app_widget_info.xml new file mode 100644 index 0000000..77d4bb1 --- /dev/null +++ b/apps/phone_flutter/android/app/src/main/res/xml/ward_pulse_app_widget_info.xml @@ -0,0 +1,20 @@ + + + diff --git a/apps/phone_flutter/design/README.md b/apps/phone_flutter/design/README.md new file mode 100644 index 0000000..041dc39 --- /dev/null +++ b/apps/phone_flutter/design/README.md @@ -0,0 +1,20 @@ +# Phone widget review art + +**Baseline locked 2026-07-27**, revised **2026-08-02** (two-line label) — see +[`docs/product/PHONE_WIDGET_DESIGN.md`](../../../docs/product/PHONE_WIDGET_DESIGN.md). + +Primary preview: `widget-medium.svg` (light). Dark: `widget-dark.svg`. + +```sh +xdg-open apps/phone_flutter/design/widget-medium.svg +xdg-open apps/phone_flutter/design/widget-dark.svg +``` + +Variants: `widget-medium`, `widget-dark`, `widget-small`, `widget-empty`, `widget-stale`. +Quiet face watermark sits top-end, or bottom-end where a badge already owns the corner. + +Generated, not hand-drawn: + +```sh +node tools/render-phone-widget-designs.mjs +``` diff --git a/apps/phone_flutter/design/widget-dark.svg b/apps/phone_flutter/design/widget-dark.svg new file mode 100644 index 0000000..a507c0c --- /dev/null +++ b/apps/phone_flutter/design/widget-dark.svg @@ -0,0 +1,22 @@ + + + + + WARDPULSE + + + + 22% left + Claude + Weekly plan + 80 credits + + 46% left + Codex + Weekly plan + 320 credits + + 71% left + Cursor Models + 2.1K credits + diff --git a/apps/phone_flutter/design/widget-empty.svg b/apps/phone_flutter/design/widget-empty.svg new file mode 100644 index 0000000..f2cbfc5 --- /dev/null +++ b/apps/phone_flutter/design/widget-empty.svg @@ -0,0 +1,9 @@ + + + + + WARDPULSE + + + No metrics + diff --git a/apps/phone_flutter/design/widget-medium.svg b/apps/phone_flutter/design/widget-medium.svg new file mode 100644 index 0000000..bee9c11 --- /dev/null +++ b/apps/phone_flutter/design/widget-medium.svg @@ -0,0 +1,22 @@ + + + + + WARDPULSE + + + + 22% left + Claude + Weekly plan + 80 credits + + 46% left + Codex + Weekly plan + 320 credits + + 71% left + Cursor Models + 2.1K credits + diff --git a/apps/phone_flutter/design/widget-small.svg b/apps/phone_flutter/design/widget-small.svg new file mode 100644 index 0000000..83fd74c --- /dev/null +++ b/apps/phone_flutter/design/widget-small.svg @@ -0,0 +1,18 @@ + + + + + WARDPULSE + + + + 22% left + Claude + Weekly plan + 80 credits + + 46% left + Codex + Weekly plan + 320 credits + diff --git a/apps/phone_flutter/design/widget-stale.svg b/apps/phone_flutter/design/widget-stale.svg new file mode 100644 index 0000000..bfd0675 --- /dev/null +++ b/apps/phone_flutter/design/widget-stale.svg @@ -0,0 +1,17 @@ + + + + + WARDPULSE + + + STALE + + 22% left + Claude + Weekly plan + + 46% left + Codex + Weekly plan + diff --git a/apps/phone_flutter/lib/app/catalog_outline.dart b/apps/phone_flutter/lib/app/catalog_outline.dart new file mode 100644 index 0000000..a7a7beb --- /dev/null +++ b/apps/phone_flutter/lib/app/catalog_outline.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import '../settings/metric_catalog_groups.dart'; +import '../settings/watch_ring_preferences.dart'; + +/// Lays a metric catalog out as an outline: a heading per kind of connection, a +/// sub-heading per connection, and the caller's row under it. +/// +/// Both pickers use it so they cannot drift apart — the Watchface tab kept a +/// flat list for a while after the Widget tab grew headings, and that gap is +/// what this widget exists to close. The rows differ (their slot budgets do), +/// so the caller builds them; everything above a row is the same on both. +class CatalogOutline extends StatelessWidget { + const CatalogOutline({ + super.key, + required this.catalog, + required this.rowBuilder, + }); + + final List catalog; + + /// Builds one row; [title] is the metric's name under its heading. + final Widget Function(WatchRingMetric metric, String title) rowBuilder; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final group in groupMetricCatalog(catalog)) ...[ + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + group.title, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), + ), + for (final connection in group.connections) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Text( + connection.title, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + for (final metric in connection.metrics) + rowBuilder(metric, metricRowTitle(metric)), + ], + ], + ], + ); + } +} diff --git a/apps/phone_flutter/lib/app/pull_to_refresh_list.dart b/apps/phone_flutter/lib/app/pull_to_refresh_list.dart new file mode 100644 index 0000000..1321f03 --- /dev/null +++ b/apps/phone_flutter/lib/app/pull_to_refresh_list.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; + +/// A tab list whose pull opens room at the top and spins in it. +/// +/// Material's `RefreshIndicator` floats its spinner over the first rows, which +/// reads as an overlay landing on the content. Here the list is pushed down by +/// exactly the height the spinner needs while the reload runs, so the spinner +/// turns in a gap of its own and no row is covered. +/// +/// The gesture keeps Android's own physics on purpose: a sliver refresh control +/// opens its gap out of overscroll, which needs bouncing physics, and then a +/// hard flick back to the top arms a reload nobody asked for. +class PullToRefreshList extends StatefulWidget { + const PullToRefreshList({ + super.key, + required this.onRefresh, + required this.children, + this.padding = const EdgeInsets.fromLTRB(16, 8, 16, 24), + }); + + /// Runs the reload; the gap stays open until this completes. + final Future Function() onRefresh; + + final List children; + final EdgeInsets padding; + + @override + State createState() => _PullToRefreshListState(); +} + +class _PullToRefreshListState extends State { + /// Tall enough for the spinner and its shadow, short enough that the first + /// row stays in sight while it turns. + static const _gap = 64.0; + + bool _refreshing = false; + + Future _refresh() async { + setState(() => _refreshing = true); + try { + await widget.onRefresh(); + } finally { + if (mounted) { + setState(() => _refreshing = false); + } + } + } + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + onRefresh: _refresh, + // Turns in the middle of the gap the list opens under it. + displacement: _gap / 2, + child: AnimatedPadding( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + padding: EdgeInsets.only(top: _refreshing ? _gap : 0), + child: ListView( + // A tab that fits its viewport still has to answer the gesture. + physics: const AlwaysScrollableScrollPhysics(), + padding: widget.padding, + children: widget.children, + ), + ), + ); + } +} diff --git a/apps/phone_flutter/lib/app/surface_order.dart b/apps/phone_flutter/lib/app/surface_order.dart new file mode 100644 index 0000000..2f08e77 --- /dev/null +++ b/apps/phone_flutter/lib/app/surface_order.dart @@ -0,0 +1,59 @@ +import '../dashboard/dashboard_models.dart'; +import '../dashboard/provider_status_severity.dart'; + +/// Worst status first: a provider asking for attention outranks a healthy one. +/// +/// Ranked by the one scale the product has — [providerStatusSeverity], mirrored +/// from the Rust core. Naming "the bad ones" here instead is how five +/// disagreeing status scales appeared once already. +int compareByStatus(ProviderStatus left, ProviderStatus right) { + return providerStatusSeverity(right).compareTo(providerStatusSeverity(left)); +} + +/// Larger spend first, reading `null` as nothing spent. +/// +/// Amounts compare only inside one currency: the core refuses to add across +/// them (`MoneyTotal::add` in `core/ward-pulse-core/src/dashboard/mod.rs`) and +/// the device carries no exchange rate, so two currencies tie here and the next +/// key decides. Nothing spent has no currency of its own and compares against +/// any amount. +int compareBySpend(Money? left, Money? right) { + if (left != null && right != null && left.currency != right.currency) { + return 0; + } + return (right?.minorUnits ?? 0).compareTo(left?.minorUnits ?? 0); +} + +/// An order that moves only when the rows themselves change. +/// +/// A key that shifts on every poll — spend on the Dashboard — would slide a +/// card out from under the reader's finger. So a ranking is computed once for a +/// given set of keys and held, and a new ranking is taken only when that set +/// differs. +/// +/// What counts as a change is therefore the caller's to state: it writes into +/// each key exactly what must rerank the list — the row's identity, plus the +/// state it reports — and leaves out the figures this class exists to absorb. +/// +/// Keys, not rows: a tab can hold an order without handing over the private +/// widgets it builds from it. +class FrozenOrder { + List? _held; + + /// The order to render — the held one for as long as [ranked] names the same + /// keys, whatever their new positions are. + /// + /// [ranked] carries one key per row: a repeated key would be a row counted + /// twice by the caller rather than an order to hold. + List hold(List ranked) { + final held = _held; + if (held != null && + held.length == ranked.length && + held.toSet().containsAll(ranked)) { + return held; + } + // Copied, and unmodifiable on the way out: an order this class exists to + // keep still must not be sortable through the list it was handed. + return _held = List.unmodifiable(ranked); + } +} diff --git a/apps/phone_flutter/lib/app/ward_pulse_app.dart b/apps/phone_flutter/lib/app/ward_pulse_app.dart index bd2a43f..c1d9c04 100644 --- a/apps/phone_flutter/lib/app/ward_pulse_app.dart +++ b/apps/phone_flutter/lib/app/ward_pulse_app.dart @@ -1,24 +1,37 @@ import 'dart:async'; +import 'dart:developer' as developer; import 'package:flutter/material.dart'; +import '../dashboard/apply_alert_settings.dart'; import '../dashboard/dashboard_models.dart'; import '../dashboard/dashboard_repository.dart'; import '../dashboard/dashboard_screen.dart'; +import '../dashboard/provider_status_color.dart'; +import '../dashboard/provider_status_severity.dart'; import '../providers/claude_account_service.dart'; import '../providers/codex_account_service.dart'; import '../providers/provider_connection.dart'; import '../providers/provider_credential_store.dart'; import '../providers/providers_screen.dart'; +import '../settings/alert_threshold_preferences.dart'; import '../settings/settings_screen.dart'; -import '../settings/consumption_display_preferences.dart'; import '../settings/debug_data_preferences.dart'; +import '../settings/recovery_notification_preferences.dart'; import '../settings/refresh_interval_preferences.dart'; import '../settings/watch_ring_preferences.dart'; import '../sync/headless_provider_sync.dart'; import '../sync/manual_refresh_window.dart'; import '../sync/provider_sync_scheduler.dart'; +import '../sync/recovery_notifications.dart'; +import '../sync/recovery_wake.dart'; +import '../sync/recovery_watchlist.dart'; import '../sync/watch_sync_service.dart'; +import '../watchface/watchface_screen.dart'; +import '../widget/phone_widget_preferences.dart'; +import '../widget/phone_widget_sync.dart'; +import '../widget/widget_screen.dart'; +import 'surface_order.dart'; import 'ward_pulse_theme.dart'; class WardPulseApp extends StatelessWidget { @@ -26,29 +39,43 @@ class WardPulseApp extends StatelessWidget { super.key, required this.repository, this.watchSyncService = const MethodChannelWatchSyncService(), + this.phoneWidgetSyncService = const DisabledPhoneWidgetSyncService(), this.credentialStore = const EmptyProviderCredentialStore(), this.codexAccountService = const EmptyCodexAccountService(), this.claudeAccountService = const EmptyClaudeAccountService(), - this.displayPreferenceStore = - const DefaultConsumptionDisplayPreferenceStore(), this.refreshIntervalStore = const DefaultRefreshIntervalPreferenceStore(), this.watchRingPreferenceStore = const DefaultWatchRingPreferenceStore(), + this.phoneWidgetPreferenceStore = const DefaultPhoneWidgetPreferenceStore(), + this.alertThresholdStore = const DefaultAlertThresholdPreferenceStore(), + this.applyAlertSettings = applyUserAlertSettings, this.syncScheduler = const DisabledProviderSyncScheduler(), this.debugDataAvailable = false, this.debugDataPreferenceStore = const DisabledDebugDataPreferenceStore(), + this.recoveryWatchlistStore = const DisabledRecoveryWatchlistStore(), + this.recoveryNotifier = const SilentRecoveryNotifier(), + this.recoveryWake = const DisabledRecoveryWakeScheduler(), + this.recoveryNotificationStore = + const DefaultRecoveryNotificationPreferenceStore(), }); final DashboardRepository repository; final WatchSyncService watchSyncService; + final PhoneWidgetSyncService phoneWidgetSyncService; final ProviderCredentialStore credentialStore; final CodexAccountService codexAccountService; final ClaudeAccountService claudeAccountService; - final ConsumptionDisplayPreferenceStore displayPreferenceStore; final RefreshIntervalPreferenceStore refreshIntervalStore; final WatchRingPreferenceStore watchRingPreferenceStore; + final PhoneWidgetPreferenceStore phoneWidgetPreferenceStore; + final AlertThresholdPreferenceStore alertThresholdStore; + final ApplyAlertSettings applyAlertSettings; final ProviderSyncScheduler syncScheduler; final bool debugDataAvailable; final DebugDataPreferenceStore debugDataPreferenceStore; + final RecoveryWatchlistStore recoveryWatchlistStore; + final RecoveryNotifier recoveryNotifier; + final RecoveryWakeScheduler recoveryWake; + final RecoveryNotificationPreferenceStore recoveryNotificationStore; @override Widget build(BuildContext context) { @@ -60,15 +87,22 @@ class WardPulseApp extends StatelessWidget { home: DashboardHost( repository: repository, watchSyncService: watchSyncService, + phoneWidgetSyncService: phoneWidgetSyncService, credentialStore: credentialStore, codexAccountService: codexAccountService, claudeAccountService: claudeAccountService, - displayPreferenceStore: displayPreferenceStore, refreshIntervalStore: refreshIntervalStore, watchRingPreferenceStore: watchRingPreferenceStore, + phoneWidgetPreferenceStore: phoneWidgetPreferenceStore, + alertThresholdStore: alertThresholdStore, + applyAlertSettings: applyAlertSettings, syncScheduler: syncScheduler, debugDataAvailable: debugDataAvailable, debugDataPreferenceStore: debugDataPreferenceStore, + recoveryWatchlistStore: recoveryWatchlistStore, + recoveryNotifier: recoveryNotifier, + recoveryWake: recoveryWake, + recoveryNotificationStore: recoveryNotificationStore, ), ); } @@ -79,53 +113,72 @@ class DashboardHost extends StatefulWidget { super.key, required this.repository, required this.watchSyncService, + required this.phoneWidgetSyncService, required this.credentialStore, required this.codexAccountService, required this.claudeAccountService, - required this.displayPreferenceStore, required this.refreshIntervalStore, required this.watchRingPreferenceStore, + required this.phoneWidgetPreferenceStore, + required this.alertThresholdStore, + required this.applyAlertSettings, required this.syncScheduler, required this.debugDataAvailable, required this.debugDataPreferenceStore, + required this.recoveryWatchlistStore, + required this.recoveryNotifier, + required this.recoveryWake, + required this.recoveryNotificationStore, }); final DashboardRepository repository; final WatchSyncService watchSyncService; + final PhoneWidgetSyncService phoneWidgetSyncService; final ProviderCredentialStore credentialStore; final CodexAccountService codexAccountService; final ClaudeAccountService claudeAccountService; - final ConsumptionDisplayPreferenceStore displayPreferenceStore; final RefreshIntervalPreferenceStore refreshIntervalStore; final WatchRingPreferenceStore watchRingPreferenceStore; + final PhoneWidgetPreferenceStore phoneWidgetPreferenceStore; + final AlertThresholdPreferenceStore alertThresholdStore; + final ApplyAlertSettings applyAlertSettings; final ProviderSyncScheduler syncScheduler; final bool debugDataAvailable; final DebugDataPreferenceStore debugDataPreferenceStore; + final RecoveryNotificationPreferenceStore recoveryNotificationStore; + final RecoveryWakeScheduler recoveryWake; + final RecoveryNotifier recoveryNotifier; + final RecoveryWatchlistStore recoveryWatchlistStore; @override State createState() => _DashboardHostState(); } class _DashboardHostState extends State { - static const _settingsIndex = 2; - - /// Account ids carried by each platform connection's normalized report. - static const _platformAccountIds = { - 'openai-local': ProviderConnections.openAiPlatform, - 'anthropic-local': ProviderConnections.anthropicPlatform, - 'cursor-team-local': ProviderConnections.cursorPlatform, - }; + static const _dashboardIndex = 0; + static const _watchfaceIndex = 1; + static const _widgetIndex = 2; + static const _providersIndex = 3; + static const _settingsIndex = 4; late Future _snapshot = _loadSnapshot(); DashboardSnapshot? _currentSnapshot; - ConsumptionDisplayPreferences _displayPreferences = - const ConsumptionDisplayPreferences(); + + /// Holds the Dashboard's card order for the run, so a poll that only moves + /// spend leaves every card where the reader last saw it. Shared with the + /// app-bar badge, which must point at the topmost flagged card. + final _dashboardOrder = FrozenOrder(); RefreshIntervalPreference _refreshInterval = const RefreshIntervalPreference(); WatchRingPreferences _ringPreferences = const WatchRingPreferences(); - Map _platformLabels = const {}; + PhoneWidgetPreferences _widgetPreferences = const PhoneWidgetPreferences(); + AlertThresholdPreferences _alertThresholds = + const AlertThresholdPreferences(); + Future _alertThresholdWrite = Future.value(); bool _mockDataEnabled = false; + bool _recoveryNotificationsEnabled = true; int _selectedIndex = 0; + ({String provider, int token})? _reveal; StreamSubscription? _syncTicks; var _autoSyncInFlight = false; @@ -185,15 +238,6 @@ class _DashboardHostState extends State { super.dispose(); } - Future _readDisplayPreferences() async { - try { - final value = await widget.displayPreferenceStore.read(); - _displayPreferences = value; - } catch (_) { - // The default plan view remains available if local preferences fail. - } - } - Future _readRefreshInterval() async { try { _refreshInterval = await widget.refreshIntervalStore.read(); @@ -210,46 +254,61 @@ class _DashboardHostState extends State { } } - Future _readConnectionMetadata() async { + Future _readWidgetPreferences() async { try { - final labels = {}; - for (final entry in _platformAccountIds.entries) { - final label = await widget.credentialStore.readLabel(entry.value); - if (label != null) { - labels[entry.key] = label; - } - } - _platformLabels = labels; + _widgetPreferences = await widget.phoneWidgetPreferenceStore.read(); } catch (_) { - _platformLabels = const {}; + _widgetPreferences = const PhoneWidgetPreferences(); } } - Future _readDebugDataPreference() async { - if (!widget.debugDataAvailable) { - _mockDataEnabled = false; - return; + Future _readAlertThresholds() async { + try { + _alertThresholds = await widget.alertThresholdStore.read(); + } catch (_) { + _alertThresholds = const AlertThresholdPreferences(); } + } + + Future _readRecoveryNotificationPreference() async { try { - _mockDataEnabled = - await widget.debugDataPreferenceStore.readMockDataEnabled(); + _recoveryNotificationsEnabled = + await widget.recoveryNotificationStore.read(); } catch (_) { - _mockDataEnabled = false; + // A store that will not answer leaves the switch where it defaults, on: + // the feature exists to interrupt, and Android still gates the post. + _recoveryNotificationsEnabled = true; } } - Future _updateDisplayPreferences( - ConsumptionDisplayPreferences value, - ) async { - await widget.displayPreferenceStore.write(value); + /// Writes the switch, and asks Android for the permission when it goes on. + /// + /// The asking happens here because only a foreground Activity can ask, while + /// the poll that posts runs in a background isolate with none. + /// Answers whether a recovery may actually reach the reader now: the switch + /// is the reader's wish, and Android has the last word on it. + Future _updateRecoveryNotifications(bool enabled) async { + await widget.recoveryNotificationStore.write(enabled); + final granted = + enabled ? await widget.recoveryNotifier.requestPermission() : true; if (mounted) { setState(() { - _displayPreferences = value; + _recoveryNotificationsEnabled = enabled; }); } - final snapshot = _currentSnapshot; - if (snapshot != null) { - unawaited(_syncWatch(snapshot)); + return granted; + } + + Future _readDebugDataPreference() async { + if (!widget.debugDataAvailable) { + _mockDataEnabled = false; + return; + } + try { + _mockDataEnabled = + await widget.debugDataPreferenceStore.readMockDataEnabled(); + } catch (_) { + _mockDataEnabled = false; } } @@ -276,19 +335,85 @@ class _DashboardHostState extends State { } } + Future _updateWidgetPreferences(PhoneWidgetPreferences value) async { + await widget.phoneWidgetPreferenceStore.write(value); + if (mounted) { + setState(() { + _widgetPreferences = value; + }); + } + final snapshot = _currentSnapshot; + if (snapshot != null) { + unawaited(_syncPhoneWidget(snapshot)); + } + } + + Future _updateAlertThresholds( + AlertThresholdPreferences Function(AlertThresholdPreferences current) + update, + ) async { + final previous = _alertThresholdWrite; + final done = Completer(); + _alertThresholdWrite = done.future; + await previous; + try { + final value = update(_alertThresholds); + final snapshot = _currentSnapshot; + // Evaluate before persisting so a core/FFI failure does not leave the + // store ahead of in-memory prefs / dashboard alerts. + final withAlerts = + snapshot == null ? null : widget.applyAlertSettings(snapshot, value); + await widget.alertThresholdStore.write(value); + if (mounted) { + setState(() { + _alertThresholds = value; + if (withAlerts != null) { + _currentSnapshot = withAlerts; + _snapshot = Future.value(withAlerts); + } + }); + } else { + _alertThresholds = value; + if (withAlerts != null) { + _currentSnapshot = withAlerts; + } + } + if (withAlerts != null) { + unawaited(_syncWatch(withAlerts)); + } + } finally { + done.complete(); + } + } + Future _loadSnapshot() async { - await _readDisplayPreferences(); await _readRefreshInterval(); await _readRingPreferences(); - await _readConnectionMetadata(); + await _readWidgetPreferences(); + await _readAlertThresholds(); await _readDebugDataPreference(); + await _readRecoveryNotificationPreference(); // Rescheduling before the load keeps the next tick a full interval away, so // no connection is polled faster than its floor, and a failed load still // retries on the next tick. Headless WorkManager uses ≥15 minutes. unawaited(_scheduleAutoSync(_refreshInterval.interval)); - final snapshot = await widget.repository.load(); + final snapshot = widget.applyAlertSettings( + await widget.repository.load(), + _alertThresholds, + ); _currentSnapshot = snapshot; + unawaited( + syncPlanRecoveries( + snapshot, + widget.recoveryWatchlistStore, + widget.recoveryNotifier, + widget.recoveryWake, + mockData: _mockDataEnabled, + notifications: _recoveryNotificationsEnabled, + ), + ); unawaited(_syncWatch(snapshot)); + unawaited(_syncPhoneWidget(snapshot)); return snapshot; } @@ -305,7 +430,10 @@ class _DashboardHostState extends State { try { // Do not invalidate: load() already refetches, and clearing caches would // drop stale-with-issue recovery on a failed automatic tick. - final snapshot = await widget.repository.load(); + final snapshot = widget.applyAlertSettings( + await widget.repository.load(), + _alertThresholds, + ); _currentSnapshot = snapshot; if (mounted) { setState(() { @@ -313,6 +441,7 @@ class _DashboardHostState extends State { }); } await _syncWatch(snapshot); + await _syncPhoneWidget(snapshot); } catch (_) { // Automatic sync failures keep the last successful snapshot visible. } finally { @@ -340,12 +469,24 @@ class _DashboardHostState extends State { } } + Future _syncPhoneWidget(DashboardSnapshot snapshot) async { + try { + await widget.phoneWidgetSyncService.sync(snapshot, _widgetPreferences); + } catch (error) { + // Launcher widget updates must not block the phone dashboard. + developer.log( + 'dashboard refresh could not update the widget: $error', + name: phoneWidgetLogName, + ); + } + } + Future _queueWatchSummary(DashboardSnapshot snapshot) { return widget.watchSyncService.sync( snapshot, - _displayPreferences, _ringPreferences, manualRefreshAnchorAt: _manualRefreshAnchorAt(snapshot), + mockDataMode: _mockDataEnabled, ); } @@ -357,16 +498,73 @@ class _DashboardHostState extends State { await _queueWatchSummary(snapshot); } + /// Pull-to-refresh runs the same reload the app-bar action does; the gesture + /// only has to hold the spinner until the snapshot lands. + Future _pullToRefresh() async { + _reload(); + try { + await _snapshot; + } catch (_) { + // The failure state already tells the reader; the gesture just ends. + } + } + void _reload() { + // Mock demo is cached across auto-sync; refresh should draw a new seed. + // Live repos must not clear connection recovery caches on every refresh. + if (_mockDataEnabled) { + widget.repository.invalidate(); + } setState(() { _snapshot = _loadSnapshot(); }); } - void _openSettings() { + /// The top mark is a way in, not a copy: it opens the Dashboard when another + /// tab is showing and then brings the first unhealthy section into view + /// (PHONE_DASHBOARD_DESIGN.md). + void _revealProblem(String provider) { setState(() { - _selectedIndex = _settingsIndex; + _selectedIndex = _dashboardIndex; + _reveal = (provider: provider, token: (_reveal?.token ?? 0) + 1); }); + // A request is answered once. Left standing, it would scroll again every + // time the reader came back to the tab, long after they asked. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() => _reveal = null); + } + }); + } + + void _openProviders() { + setState(() { + _selectedIndex = _providersIndex; + }); + } + + void _onCredentialsChanged() { + widget.repository.invalidate(); + _reload(); + } + + /// Worst status each family is reporting, for the Providers order. + /// + /// Accounts only: that tab manages connections, so a plan meter crossing its + /// own threshold is not what should pull a card to the top of it. + Map _providerStatuses( + DashboardSnapshot? snapshot, + ) { + final byFamily = >{}; + for (final account in snapshot?.accounts ?? const []) { + if (providerFamilyOf(account.provider) case final family?) { + byFamily.putIfAbsent(family, () => []).add(account.status); + } + } + return { + for (final entry in byFamily.entries) + entry.key: worstProviderStatus(entry.value), + }; } @override @@ -381,12 +579,9 @@ class _DashboardHostState extends State { title: const Text('WardPulse'), actions: [ if (snapshot != null) - Padding( - padding: const EdgeInsetsDirectional.only(end: 12), - child: StatusPill( - status: snapshot.overallStatus, - tooltip: snapshot.syncTooltip, - ), + _AppBarProblems( + problems: dashboardProblems(snapshot, _dashboardOrder), + onReveal: _revealProblem, ), IconButton( tooltip: 'Refresh', @@ -397,45 +592,60 @@ class _DashboardHostState extends State { ), body: SafeArea( child: switch (state.connectionState) { - _ when _selectedIndex == _settingsIndex => SettingsScreen( - key: const ValueKey('settings'), + _ when _selectedIndex == _watchfaceIndex => WatchfaceScreen( + key: const ValueKey('watchface'), + onRefresh: _pullToRefresh, + snapshot: snapshot, + ringPreferences: _ringPreferences, + onRingPreferencesChanged: _updateRingPreferences, + ), + _ when _selectedIndex == _widgetIndex => WidgetScreen( + key: const ValueKey('widget'), + onRefresh: _pullToRefresh, snapshot: snapshot, + preferences: _widgetPreferences, + onPreferencesChanged: _updateWidgetPreferences, + ), + _ when _selectedIndex == _providersIndex => ProvidersScreen( + key: const ValueKey('providers'), + onRefresh: _pullToRefresh, credentialStore: widget.credentialStore, codexAccountService: widget.codexAccountService, claudeAccountService: widget.claudeAccountService, - displayPreferences: _displayPreferences, - onDisplayPreferencesChanged: _updateDisplayPreferences, + onCredentialsChanged: _onCredentialsChanged, + alertThresholds: _alertThresholds, + onAlertThresholdsChanged: _updateAlertThresholds, + statuses: _providerStatuses(snapshot), + ), + _ when _selectedIndex == _settingsIndex => SettingsScreen( + key: const ValueKey('settings'), + onRefresh: _pullToRefresh, + snapshot: snapshot, refreshInterval: _refreshInterval, onRefreshIntervalChanged: _updateRefreshInterval, ringPreferences: _ringPreferences, - onRingPreferencesChanged: _updateRingPreferences, onSyncWatch: _onSettingsSyncWatch, + recoveryNotificationsEnabled: _recoveryNotificationsEnabled, + onRecoveryNotificationsChanged: _updateRecoveryNotifications, debugDataAvailable: widget.debugDataAvailable, mockDataEnabled: _mockDataEnabled, onMockDataEnabledChanged: _updateMockDataEnabled, - onCredentialsChanged: () { - unawaited(() async { - await _readConnectionMetadata(); - if (!mounted) { - return; - } - widget.repository.invalidate(); - _reload(); - }()); - }, ), - ConnectionState.waiting => const _LoadingView(), + // Only the very first load has nothing to show; a reload keeps + // the dashboard up rather than blanking it. + ConnectionState.waiting when snapshot == null => + const _LoadingView(), _ when state.hasError => _ErrorView( failure: _dashboardFailure(state.error), onRetry: _reload, - onOpenSettings: _openSettings, + onOpenProviders: _openProviders, ), - _ when snapshot != null => _SelectedSurface( - selectedIndex: _selectedIndex, + _ when snapshot != null => DashboardScreen( + onRefresh: _pullToRefresh, snapshot: snapshot, - displayPreferences: _displayPreferences, - platformLabels: _platformLabels, - onOpenSettings: _openSettings, + onOpenProviders: _openProviders, + reveal: _reveal, + order: _dashboardOrder, ), _ => _ErrorView( failure: const DashboardLoadException(), @@ -456,6 +666,16 @@ class _DashboardHostState extends State { selectedIcon: Icon(Icons.dashboard), label: 'Dashboard', ), + NavigationDestination( + icon: Icon(Icons.watch_outlined), + selectedIcon: Icon(Icons.watch), + label: 'Watchface', + ), + NavigationDestination( + icon: Icon(Icons.widgets_outlined), + selectedIcon: Icon(Icons.widgets), + label: 'Widget', + ), NavigationDestination( icon: Icon(Icons.hub_outlined), selectedIcon: Icon(Icons.hub), @@ -474,38 +694,6 @@ class _DashboardHostState extends State { } } -class _SelectedSurface extends StatelessWidget { - const _SelectedSurface({ - required this.selectedIndex, - required this.snapshot, - required this.displayPreferences, - this.platformLabels = const {}, - this.onOpenSettings, - }); - - final int selectedIndex; - final DashboardSnapshot snapshot; - final ConsumptionDisplayPreferences displayPreferences; - final Map platformLabels; - final VoidCallback? onOpenSettings; - - @override - Widget build(BuildContext context) { - return switch (selectedIndex) { - 0 => DashboardScreen( - snapshot: snapshot, - displayPreferences: displayPreferences, - onOpenSettings: onOpenSettings, - ), - _ => ProvidersScreen( - snapshot: snapshot, - displayPreferences: displayPreferences, - platformLabels: platformLabels, - ), - }; - } -} - class _LoadingView extends StatelessWidget { const _LoadingView(); @@ -519,17 +707,17 @@ class _ErrorView extends StatelessWidget { const _ErrorView({ required this.failure, required this.onRetry, - this.onOpenSettings, + this.onOpenProviders, }); final DashboardLoadException failure; final VoidCallback onRetry; - final VoidCallback? onOpenSettings; + final VoidCallback? onOpenProviders; @override Widget build(BuildContext context) { if (failure.issue == DashboardSyncIssue.noProviders) { - return ConnectProviderPrompt(onOpenSettings: onOpenSettings); + return ConnectProviderPrompt(onOpenProviders: onOpenProviders); } final colors = Theme.of(context).colorScheme; @@ -603,3 +791,40 @@ void _showErrorDetails(BuildContext context, String details) { ), ); } + +/// The app bar's rollup: how many sections a tap can take the reader to, in the +/// worst of their colors. Silent when nothing below deviates. +class _AppBarProblems extends StatelessWidget { + const _AppBarProblems({required this.problems, required this.onReveal}); + + final DashboardProblems problems; + final void Function(String provider) onReveal; + + @override + Widget build(BuildContext context) { + if (problems.first case final provider?) { + final chip = providerStatusChipColors( + Theme.of(context).colorScheme, + problems.status, + ); + return Padding( + padding: const EdgeInsetsDirectional.only(end: 12), + child: Tooltip( + message: 'Go to ${problems.status.label.toLowerCase()}', + child: InkResponse( + onTap: () => onReveal(provider), + child: Padding( + padding: const EdgeInsets.all(6), + child: Badge( + backgroundColor: chip.fill, + textColor: chip.ink, + label: Text('${problems.sections}'), + ), + ), + ), + ), + ); + } + return const SizedBox.shrink(); + } +} diff --git a/apps/phone_flutter/lib/charts/budget_progress_bar.dart b/apps/phone_flutter/lib/charts/budget_progress_bar.dart deleted file mode 100644 index 3d62def..0000000 --- a/apps/phone_flutter/lib/charts/budget_progress_bar.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../dashboard/dashboard_models.dart'; -import '../dashboard/provider_status_color.dart'; - -class BudgetProgressBar extends StatelessWidget { - const BudgetProgressBar({super.key, required this.state}); - - final BudgetState state; - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - final value = state.usedFraction; - - return ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - minHeight: 8, - value: value ?? 0, - color: budgetMetricColor(colors, state.status), - backgroundColor: colors.surfaceContainerHighest, - ), - ); - } -} diff --git a/apps/phone_flutter/lib/charts/usage_history_chart.dart b/apps/phone_flutter/lib/charts/usage_history_chart.dart index f5a334b..dcaf02f 100644 --- a/apps/phone_flutter/lib/charts/usage_history_chart.dart +++ b/apps/phone_flutter/lib/charts/usage_history_chart.dart @@ -22,7 +22,26 @@ class UsageHistoryChart extends StatelessWidget { final colors = Theme.of(context).colorScheme; final totalCost = _totalCost(buckets); final totalTokens = _totalTokens(buckets); - final usesTokens = totalCost == null && totalTokens != null; + final totalRequests = _totalRequests(buckets); + // Prefer money, then tokens, then request counts (Cursor Admin is request-only). + final values = [ + for (final bucket in buckets) + if (totalCost != null) + bucket.cost?.minorUnits ?? 0 + else if (totalTokens != null) + bucket.totalTokens ?? 0 + else if (totalRequests != null) + bucket.requests ?? 0 + else + 0, + ]; + final totalLabel = + totalCost?.label ?? + (totalTokens != null + ? '${formatCount(totalTokens)} tokens' + : totalRequests != null + ? '${formatCount(totalRequests)} requests' + : 'No totals'); final showDates = _spansMultipleUtcDays(buckets); return Card( @@ -52,14 +71,7 @@ class UsageHistoryChart extends StatelessWidget { child: CustomPaint( key: const ValueKey('usage-history-bars'), painter: _UsageHistoryPainter( - values: buckets - .map( - (bucket) => - usesTokens - ? bucket.totalTokens ?? 0 - : bucket.cost?.minorUnits ?? 0, - ) - .toList(growable: false), + values: values, color: accent ?? colors.primary, baselineColor: colors.outlineVariant, ), @@ -71,12 +83,7 @@ class UsageHistoryChart extends StatelessWidget { Expanded( child: Text(_rangeLabel(buckets.first.startAt, showDates)), ), - Text( - totalCost?.label ?? - (totalTokens == null - ? 'No totals' - : '${formatCount(totalTokens)} tokens'), - ), + Text(totalLabel), Expanded( child: Text( _rangeLabel(buckets.last.endAt, showDates), @@ -198,6 +205,17 @@ int? _totalTokens(List buckets) { ); } +int? _totalRequests(List buckets) { + if (buckets.isEmpty || buckets.every((bucket) => bucket.requests == null)) { + return null; + } + + return buckets.fold( + 0, + (total, bucket) => total + (bucket.requests ?? 0), + ); +} + bool _spansMultipleUtcDays(List buckets) { if (buckets.isEmpty) { return false; diff --git a/apps/phone_flutter/lib/dashboard/apply_alert_settings.dart b/apps/phone_flutter/lib/dashboard/apply_alert_settings.dart new file mode 100644 index 0000000..b83c263 --- /dev/null +++ b/apps/phone_flutter/lib/dashboard/apply_alert_settings.dart @@ -0,0 +1,58 @@ +import 'package:ward_pulse_bindings/ward_pulse_bindings.dart'; + +import '../settings/alert_threshold_preferences.dart'; +import 'dashboard_models.dart'; + +/// Applies [settings] to [snapshot] (Rust core in production). +typedef ApplyAlertSettings = + DashboardSnapshot Function( + DashboardSnapshot snapshot, + AlertThresholdPreferences settings, + ); + +/// Applies phone alert prefs through the Rust core. +/// +/// When no rules are enabled, clears leftover alerts without calling FFI +/// (default-off path for first-run hosts and widget tests). +DashboardSnapshot applyUserAlertSettings( + DashboardSnapshot snapshot, + AlertThresholdPreferences settings, +) { + if (!settings.hasEnabledRules) { + if (snapshot.alerts.isEmpty) { + return snapshot; + } + return DashboardSnapshot( + generatedAt: snapshot.generatedAt, + overallStatus: snapshot.overallStatus, + accounts: snapshot.accounts, + todayTotal: snapshot.todayTotal, + weekTotal: snapshot.weekTotal, + monthTotal: snapshot.monthTotal, + alerts: const [], + watchSummary: snapshot.watchSummary, + syncIssue: snapshot.syncIssue, + syncDetails: snapshot.syncDetails, + ); + } + + final applied = DashboardSnapshot.fromJsonString( + applyAlertSettingsJson(snapshot.toJsonString(), settings.encode()), + ); + // Phone-only sync metadata is not part of the Rust snapshot contract. + if (snapshot.syncIssue == null && snapshot.syncDetails == null) { + return applied; + } + return DashboardSnapshot( + generatedAt: applied.generatedAt, + overallStatus: applied.overallStatus, + accounts: applied.accounts, + todayTotal: applied.todayTotal, + weekTotal: applied.weekTotal, + monthTotal: applied.monthTotal, + alerts: applied.alerts, + watchSummary: applied.watchSummary, + syncIssue: snapshot.syncIssue, + syncDetails: snapshot.syncDetails, + ); +} diff --git a/apps/phone_flutter/lib/dashboard/claude_dashboard_repository.dart b/apps/phone_flutter/lib/dashboard/claude_dashboard_repository.dart index 5d3c0f6..a0aefee 100644 --- a/apps/phone_flutter/lib/dashboard/claude_dashboard_repository.dart +++ b/apps/phone_flutter/lib/dashboard/claude_dashboard_repository.dart @@ -27,7 +27,10 @@ DashboardRepository claudeDashboardRepository({ ); } -DashboardLoadException _mapClaudeError(Object error, ProviderSyncLogger logger) { +DashboardLoadException _mapClaudeError( + Object error, + ProviderSyncLogger logger, +) { if (error is! ClaudeAccountException) { final details = loadFailureDetails(error); logger.record(ProviderSyncEvent.invalidResponse, details: details); diff --git a/apps/phone_flutter/lib/dashboard/dashboard_models.dart b/apps/phone_flutter/lib/dashboard/dashboard_models.dart index 6411f9a..21f0344 100644 --- a/apps/phone_flutter/lib/dashboard/dashboard_models.dart +++ b/apps/phone_flutter/lib/dashboard/dashboard_models.dart @@ -22,18 +22,6 @@ extension ProviderStatusLabel on ProviderStatus { ProviderStatus.unknown => 'Unknown', }; } - - String get description { - return switch (this) { - ProviderStatus.ok => 'Provider data is current.', - ProviderStatus.warning => 'Usage is approaching a configured limit.', - ProviderStatus.error => 'Provider sync failed.', - ProviderStatus.rateLimited => 'Provider rate limit reached.', - ProviderStatus.authRequired => 'Provider authentication is required.', - ProviderStatus.stale => 'Showing data from the last successful sync.', - ProviderStatus.unknown => 'Provider status is unavailable.', - }; - } } enum DashboardSyncIssue { @@ -59,9 +47,9 @@ extension DashboardSyncIssueMessage on DashboardSyncIssue { String get message { return switch (this) { DashboardSyncIssue.noProviders => - 'Connect a provider in Settings to load dashboard data.', + 'Connect a provider on the Providers tab to load dashboard data.', DashboardSyncIssue.credentialUnavailable => - 'The saved key could not be read. Re-enter it in Settings.', + 'The saved key could not be read. Re-enter it on the Providers tab.', DashboardSyncIssue.authentication => 'The provider rejected the credential. Check that you pasted the full key or token.', DashboardSyncIssue.permissionDenied => @@ -73,7 +61,7 @@ extension DashboardSyncIssueMessage on DashboardSyncIssue { DashboardSyncIssue.invalidResponse => 'The provider returned an unsupported reporting response.', DashboardSyncIssue.codexAuthentication => - 'Codex sign-in expired. Reconnect your ChatGPT account in Settings.', + 'Codex sign-in expired. Reconnect your ChatGPT account on the Providers tab.', DashboardSyncIssue.codexPermissionDenied => 'This Codex account cannot access usage reporting.', DashboardSyncIssue.codexUnavailable => @@ -81,7 +69,7 @@ extension DashboardSyncIssueMessage on DashboardSyncIssue { DashboardSyncIssue.codexInvalidResponse => 'Codex returned an unsupported usage response.', DashboardSyncIssue.claudeAuthentication => - 'Claude sign-in expired. Reconnect your Claude account in Settings.', + 'Claude sign-in expired. Reconnect your Claude account on the Providers tab.', DashboardSyncIssue.claudePermissionDenied => 'This Claude account cannot access usage reporting.', DashboardSyncIssue.claudeUnavailable => @@ -224,6 +212,7 @@ class ProviderSnapshot { const ProviderSnapshot({ required this.accountId, required this.provider, + this.connection, required this.status, required this.today, required this.week, @@ -238,6 +227,12 @@ class ProviderSnapshot { final String accountId; final String provider; + + /// Connection storage key stamped by the Rust adapter, when present. + /// + /// Carried verbatim so the round trip back into the core keeps alert rules + /// keyed by connection rather than by provider family. + final String? connection; final ProviderStatus status; final BudgetState today; final BudgetState week; @@ -253,6 +248,7 @@ class ProviderSnapshot { return ProviderSnapshot( accountId: json['accountId'] as String, provider: json['provider'] as String, + connection: json['connection'] as String?, status: _statusFromJson(json['status']), today: BudgetState.fromJson(_jsonMap(json['today'])), week: BudgetState.fromJson(_jsonMap(json['week'])), @@ -275,6 +271,7 @@ class ProviderSnapshot { Map toJson() => { 'accountId': accountId, 'provider': provider, + if (connection != null) 'connection': connection, 'status': status.name, 'today': today.toJson(), 'week': week.toJson(), @@ -287,28 +284,7 @@ class ProviderSnapshot { 'lastError': lastError?.toJson(), }; - String get providerLabel { - return switch (provider) { - 'openai' => 'OpenAI', - 'codex' => 'Codex', - 'claude' => 'Claude', - 'cursor' => 'Cursor', - 'mock' => 'Mock', - _ => provider, - }; - } - - /// User-facing title for provider lists and details. - /// - /// [platformLabel] is phone-local display metadata for platform Admin API - /// key connections and is never part of the watch payload. - String displayTitle({String? platformLabel}) { - final label = platformLabel?.trim(); - if (label != null && label.isNotEmpty) { - return label; - } - return providerLabel; - } + String get providerLabel => providerDisplayLabel(provider); ProviderSnapshot _withStatus(ProviderStatus value) { return ProviderSnapshot( @@ -594,15 +570,6 @@ class BudgetState { 'status': status.name, }; - double? get usedFraction { - final value = usedPercent; - if (value == null) { - return null; - } - - return (value / 100).clamp(0.0, 1.0).toDouble(); - } - String get periodLabel { return switch (period) { 'today' => 'Today', @@ -611,32 +578,6 @@ class BudgetState { _ => period, }; } - - String get usedPercentLabel { - final value = usedPercent; - if (value == null) { - return 'Unknown'; - } - - final places = value.truncateToDouble() == value ? 0 : 1; - return '${value.toStringAsFixed(places)}%'; - } - - /// Why this budget card shows its status — shown on StatusPill tap/hover. - String get statusExplanation { - if (status != ProviderStatus.unknown) { - return status.description; - } - if (spent != null && limit == null) { - return 'Organization/platform API spend has no local budget limit yet, ' - 'so percent used and status stay Unknown. Subscription credit ' - 'purchases are not included in these totals.'; - } - if (spent == null) { - return 'No organization/platform API spend was reported for this period.'; - } - return status.description; - } } class Money { @@ -797,6 +738,16 @@ class WatchSummary { } } +/// Display name for a raw provider key (`cursor` → `Cursor`). +String providerDisplayLabel(String provider) => switch (provider) { + 'openai' => 'OpenAI', + 'codex' => 'Codex', + 'claude' => 'Claude', + 'cursor' => 'Cursor', + 'mock' => 'Mock', + _ => provider, +}; + /// Device-local wall clock for sync / reset labels. String formatLocal(DateTime value) { final local = value.toLocal(); diff --git a/apps/phone_flutter/lib/dashboard/dashboard_repository.dart b/apps/phone_flutter/lib/dashboard/dashboard_repository.dart index 2200a01..3e6eb7e 100644 --- a/apps/phone_flutter/lib/dashboard/dashboard_repository.dart +++ b/apps/phone_flutter/lib/dashboard/dashboard_repository.dart @@ -43,13 +43,53 @@ final class RustDashboardRepository extends DashboardRepository { } } +typedef DebugDashboardJsonLoader = String Function(int seed); + +/// Debug Mock data: full multi-provider fixture dashboard. +/// +/// Cached until [invalidate] so automatic sync ticks stay stable; phone refresh +/// and the Mock data toggle clear the cache and draw a new seed. +final class DemoDashboardRepository extends DashboardRepository { + DemoDashboardRepository({ + this.loadDebugDashboardJson = loadDebugDashboardSnapshotJson, + this.seedForLoad, + }); + + final DebugDashboardJsonLoader loadDebugDashboardJson; + final int Function()? seedForLoad; + DashboardSnapshot? _cached; + + @override + Future load() async { + final cached = _cached; + if (cached != null) { + return cached; + } + try { + final seed = seedForLoad?.call() ?? DateTime.now().microsecondsSinceEpoch; + final snapshot = DashboardSnapshot.fromJsonString( + loadDebugDashboardJson(seed), + ); + _cached = snapshot; + return snapshot; + } catch (_) { + throw const DashboardLoadException(); + } + } + + @override + void invalidate() { + _cached = null; + } +} + final class DebugDashboardRepository extends DashboardRepository { - const DebugDashboardRepository({ + DebugDashboardRepository({ required DashboardRepository live, required DebugDataPreferenceStore preferences, - DashboardRepository mock = const RustDashboardRepository(), + DashboardRepository? mock, }) : _live = live, - _mock = mock, + _mock = mock ?? DemoDashboardRepository(), _preferences = preferences; final DashboardRepository _live; diff --git a/apps/phone_flutter/lib/dashboard/dashboard_screen.dart b/apps/phone_flutter/lib/dashboard/dashboard_screen.dart index fba2c4c..0454d8b 100644 --- a/apps/phone_flutter/lib/dashboard/dashboard_screen.dart +++ b/apps/phone_flutter/lib/dashboard/dashboard_screen.dart @@ -1,28 +1,107 @@ import 'package:flutter/material.dart'; -import '../charts/budget_progress_bar.dart'; +import '../app/pull_to_refresh_list.dart'; +import '../app/surface_order.dart'; import '../charts/usage_history_chart.dart'; -import '../settings/consumption_display_preferences.dart'; import 'connected_capabilities.dart'; import 'dashboard_models.dart'; import 'provider_status_color.dart'; +import 'provider_status_severity.dart'; +import 'status_pill.dart'; + +/// What the app bar summarizes: the sections a tap can scroll to, never a +/// status computed over something the screen does not show +/// (PHONE_DASHBOARD_DESIGN.md). +typedef DashboardProblems = + ({int sections, ProviderStatus status, String? first}); + +DashboardProblems dashboardProblems( + DashboardSnapshot snapshot, [ + FrozenOrder? order, +]) { + // The same [order] the screen renders with: `first` is what the app bar + // scrolls to, and reading it off a different order would skip past a card + // sitting higher up. + final flagged = _providerDashboardSections( + snapshot.accounts, + order, + ).where((section) => section.flagged > 0).toList(growable: false); + return ( + sections: flagged.length, + status: worstProviderStatus(flagged.map((section) => section.status)), + first: flagged.isEmpty ? null : flagged.first.provider, + ); +} -class DashboardScreen extends StatelessWidget { +class DashboardScreen extends StatefulWidget { const DashboardScreen({ super.key, required this.snapshot, - this.displayPreferences = const ConsumptionDisplayPreferences(), - this.onOpenSettings, + this.onOpenProviders, + this.reveal, + this.order, + required this.onRefresh, }); final DashboardSnapshot snapshot; - final ConsumptionDisplayPreferences displayPreferences; - final VoidCallback? onOpenSettings; + final VoidCallback? onOpenProviders; + + /// Keeps the cards where the reader last saw them. Owned by the shell, which + /// shares it with the app-bar problem badge; `null` ranks afresh every build. + final FrozenOrder? order; + + /// A section to bring into view, and the tap that asked for it. The token is + /// what makes a second tap on the same provider scroll again after the reader + /// has wandered off. + final ({String provider, int token})? reveal; + + /// Pull-to-refresh reload, shared with the app-bar action. + final Future Function() onRefresh; + + @override + State createState() => _DashboardScreenState(); +} + +class _DashboardScreenState extends State { + final _sectionKeys = {}; + + @override + void initState() { + super.initState(); + // Arriving from another tab builds this screen fresh, so the first request + // lands here rather than in didUpdateWidget. + _scheduleReveal(widget.reveal); + } + + @override + void didUpdateWidget(DashboardScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.reveal != oldWidget.reveal) { + _scheduleReveal(widget.reveal); + } + } + + void _scheduleReveal(({String provider, int token})? reveal) { + if (reveal == null) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_sectionKeys[reveal.provider]?.currentContext case final target?) { + Scrollable.ensureVisible( + target, + duration: const Duration(milliseconds: 250), + alignment: 0.1, + ); + } + }); + } @override Widget build(BuildContext context) { + final snapshot = widget.snapshot; + final onOpenProviders = widget.onOpenProviders; if (snapshot.accounts.isEmpty) { - return ConnectProviderPrompt(onOpenSettings: onOpenSettings); + return ConnectProviderPrompt(onOpenProviders: onOpenProviders); } final caps = ConnectedCapabilities.fromAccounts(snapshot.accounts); @@ -31,7 +110,7 @@ class DashboardScreen extends StatelessWidget { .toList(growable: false); final providerSections = _providerDashboardSections( snapshot.accounts, - displayPreferences, + widget.order, ); final hasVisibleAllowances = providerSections.any( (section) => section.allowances.isNotEmpty, @@ -39,14 +118,11 @@ class DashboardScreen extends StatelessWidget { final hasPurchasedAllowance = allAllowances.any( (allowance) => allowance.source == AllowanceSource.purchased, ); - final showMissingPurchased = - caps.showAllowances && - displayPreferences.purchased && - !hasPurchasedAllowance; - final showPlatformSpend = displayPreferences.platform; - - return ListView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + final showMissingPurchased = caps.showAllowances && !hasPurchasedAllowance; + final showPlatformSpend = true; + + return PullToRefreshList( + onRefresh: widget.onRefresh, children: [ _SyncHeader(snapshot: snapshot), const SizedBox(height: 16), @@ -61,6 +137,7 @@ class DashboardScreen extends StatelessWidget { if (providerSections.isNotEmpty) ...[ _ProviderDashboardSections( sections: providerSections, + sectionKeys: _sectionKeys, footer: showMissingPurchased && hasVisibleAllowances ? const MissingPurchasedUsageCard() @@ -72,8 +149,8 @@ class DashboardScreen extends StatelessWidget { _CapabilityGapRow( title: 'Plan usage', explanation: - 'Connect a Codex subscription in Settings to see plan limits.', - onOpenSettings: onOpenSettings, + 'Connect a Codex subscription on the Providers tab to see plan limits.', + onOpenProviders: onOpenProviders, ), const SizedBox(height: 16), ], @@ -92,8 +169,8 @@ class DashboardScreen extends StatelessWidget { _CapabilityGapRow( title: 'Spend', explanation: - 'Connect OpenAI Platform reporting in Settings to see cost and limits.', - onOpenSettings: onOpenSettings, + 'Connect OpenAI Platform reporting on the Providers tab to see cost and limits.', + onOpenProviders: onOpenProviders, ), ], ], @@ -102,9 +179,9 @@ class DashboardScreen extends StatelessWidget { } class ConnectProviderPrompt extends StatelessWidget { - const ConnectProviderPrompt({super.key, this.onOpenSettings}); + const ConnectProviderPrompt({super.key, this.onOpenProviders}); - final VoidCallback? onOpenSettings; + final VoidCallback? onOpenProviders; @override Widget build(BuildContext context) { @@ -120,17 +197,17 @@ class ConnectProviderPrompt extends StatelessWidget { ), const SizedBox(height: 8), Text( - 'Add an OpenAI, Anthropic, or Cursor connection in Settings.', + 'Add an OpenAI, Anthropic, or Cursor connection on the Providers tab.', textAlign: TextAlign.center, style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), - if (onOpenSettings != null) ...[ + if (onOpenProviders != null) ...[ const SizedBox(height: 16), FilledButton( - onPressed: onOpenSettings, - child: const Text('Open Settings'), + onPressed: onOpenProviders, + child: const Text('Open Providers'), ), ], ], @@ -144,12 +221,12 @@ class _CapabilityGapRow extends StatelessWidget { const _CapabilityGapRow({ required this.title, required this.explanation, - this.onOpenSettings, + this.onOpenProviders, }); final String title; final String explanation; - final VoidCallback? onOpenSettings; + final VoidCallback? onOpenProviders; Future _showHelp(BuildContext context) async { final open = await showDialog( @@ -163,16 +240,16 @@ class _CapabilityGapRow extends StatelessWidget { onPressed: () => Navigator.of(context).pop(false), child: const Text('Close'), ), - if (onOpenSettings != null) + if (onOpenProviders != null) FilledButton( onPressed: () => Navigator.of(context).pop(true), - child: const Text('Open Settings'), + child: const Text('Open Providers'), ), ], ), ); if (open == true) { - onOpenSettings?.call(); + onOpenProviders?.call(); } } @@ -244,11 +321,7 @@ class MissingPurchasedUsageCard extends StatelessWidget { } class AllowanceSummaryCard extends StatelessWidget { - const AllowanceSummaryCard({ - super.key, - required this.allowance, - this.accent, - }); + const AllowanceSummaryCard({super.key, required this.allowance, this.accent}); final AllowanceState allowance; @@ -268,7 +341,10 @@ class AllowanceSummaryCard extends StatelessWidget { AllowanceSource.plan => '${allowance.remainingPercentLabel} left', AllowanceSource.purchased when allowance.unlimited => 'Unlimited', AllowanceSource.purchased => - allowance.remaining?.label ?? 'Balance unavailable', + allowance.remaining?.label ?? + (allowance.usedPercent != null + ? '${allowance.usedPercentLabel} used' + : 'Balance unavailable'), }; final detail = switch (allowance.source) { AllowanceSource.plan when allowance.resetsAt != null => @@ -289,7 +365,11 @@ class AllowanceSummaryCard extends StatelessWidget { Expanded( child: Text(allowance.label, style: textTheme.titleMedium), ), - StatusPill(status: allowance.status), + // A mark is an exception, never the norm: a column of healthy + // checkmarks outweighs the one warning the screen was opened + // for (PHONE_DASHBOARD_DESIGN.md). + if (allowance.status != ProviderStatus.ok) + StatusPill(status: allowance.status), ], ), const SizedBox(height: 14), @@ -321,38 +401,10 @@ class AllowanceSummaryCard extends StatelessWidget { } } -class StatusPill extends StatelessWidget { - const StatusPill({super.key, required this.status, this.tooltip}); - - final ProviderStatus status; - final String? tooltip; - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - final message = tooltip ?? status.description; - - return Tooltip( - message: message, - // Tap works on phone; hover still works on desktop/emulator with pointer. - triggerMode: TooltipTriggerMode.tap, - showDuration: const Duration(seconds: 6), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - _statusIcon(status), - color: providerStatusColor(colors, status), - size: 18, - ), - const SizedBox(width: 6), - Text(status.label), - ], - ), - ); - } -} - +/// Spend of one period across every connection — money only. +/// +/// Limits are per connection, so the total has no ceiling to measure against: +/// no bar, no percentage, and no status pill that would read `Unknown` forever. class BudgetSummaryCard extends StatelessWidget { const BudgetSummaryCard({ super.key, @@ -367,11 +419,6 @@ class BudgetSummaryCard extends StatelessWidget { Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; final spentLabel = state.spent?.label; - final limit = state.limit; - final remaining = state.remaining; - final usedPercentLabel = - state.usedPercent == null ? null : state.usedPercentLabel; - final progress = state.usedFraction; return Card( child: Padding( @@ -379,43 +426,11 @@ class BudgetSummaryCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Expanded(child: Text(title, style: textTheme.titleMedium)), - StatusPill( - status: state.status, - tooltip: state.statusExplanation, - ), - ], - ), - if (spentLabel != null) ...[ - const SizedBox(height: 14), - Text(spentLabel, style: textTheme.headlineSmall), - ], - if (limit != null) ...[ - const SizedBox(height: 4), - Text('Limit ${limit.label}'), - ], - if (progress != null) ...[ - const SizedBox(height: 14), - BudgetProgressBar(state: state), - ], - if (usedPercentLabel != null || remaining != null) ...[ - const SizedBox(height: 10), - Row( - children: [ - if (usedPercentLabel != null) - Expanded(child: Text('$usedPercentLabel used')), - if (remaining != null) - Flexible( - child: Text( - 'Left ${remaining.label}', - textAlign: TextAlign.end, - ), - ), - ], - ), - ], + Text(title, style: textTheme.titleMedium), + const SizedBox(height: 14), + spentLabel == null + ? const Text('No spend reported') + : Text(spentLabel, style: textTheme.headlineSmall), ], ), ), @@ -489,6 +504,8 @@ class _BudgetCards extends StatelessWidget { builder: (context, constraints) { if (constraints.maxWidth < 760) { return Column( + // A money-only card has no full-width row left to stretch it. + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final card in cards) ...[ card, @@ -514,49 +531,179 @@ class _BudgetCards extends StatelessWidget { class _ProviderDashboardSection { const _ProviderDashboardSection({ - required this.account, + required this.provider, + required this.providerLabel, + required this.status, + required this.spent, + required this.flagged, required this.allowances, + required this.buckets, + required this.modelBreakdown, }); - final ProviderSnapshot account; + final String provider; + final String providerLabel; + final ProviderStatus status; + + /// What this family spent this month, or `null` when its accounts disagree on + /// a currency. Carried because ordering needs it and a section spans several + /// accounts, while spend is reported per account. + final Money? spent; + + /// How many things this section is reporting as unhealthy: one per card that + /// deviates, or one for an account that deviates without a card of its own — + /// a platform connection reports spend, not meters, and its trouble would + /// otherwise have nowhere to show. + final int flagged; final List allowances; + final List buckets; + final List modelBreakdown; - bool get showUsageHistory => account.buckets.isNotEmpty; + bool get showUsageHistory => buckets.isNotEmpty; - bool get showModelUsage => account.modelBreakdown.isNotEmpty; + bool get showModelUsage => modelBreakdown.isNotEmpty; } -/// Per-account dashboard groups in snapshot order (not cross-provider sums). +/// One plaque per provider family (plan + platform share a header). /// -/// Each provider plaque owns its allowances, usage history, and model breakdown. +/// Claude/Cursor use one `ProviderKind` for both connections; grouping by kind +/// keeps allowances, history, and model breakdown under a single accent bar. List<_ProviderDashboardSection> _providerDashboardSections( - List accounts, - ConsumptionDisplayPreferences displayPreferences, -) { - final sections = <_ProviderDashboardSection>[]; + List accounts, [ + FrozenOrder? order, +]) { + final grouped = >{}; for (final account in accounts) { - final allowances = - account.allowances - .where((allowance) => displayPreferences.allows(allowance.source)) - .toList(growable: false); - final section = _ProviderDashboardSection( - account: account, - allowances: allowances, - ); - if (allowances.isEmpty && - !section.showUsageHistory && - !section.showModelUsage) { + grouped + .putIfAbsent(account.provider, () => []) + .add(account); + } + + final sections = <_ProviderDashboardSection>[]; + for (final group in grouped.values) { + final allowances = group + .expand((account) => account.allowances) + .toList(growable: false); + final buckets = group + .expand((account) => account.buckets) + .toList(growable: false); + final modelBreakdown = group + .expand((account) => account.modelBreakdown) + .toList(growable: false); + if (allowances.isEmpty && buckets.isEmpty && modelBreakdown.isEmpty) { continue; } - sections.add(section); + sections.add( + _ProviderDashboardSection( + provider: group.first.provider, + providerLabel: group.first.providerLabel, + spent: _monthSpend(group), + // A rollup covers what this section renders — its cards and the + // accounts behind them. Cards alone are not enough: an account can read + // healthy while a card crossed its own threshold, and it can read + // warning while reporting no card at all + // (PHONE_DASHBOARD_DESIGN.md). + status: worstProviderStatus([ + ...group.map((account) => account.status), + ...allowances.map((allowance) => allowance.status), + ]), + flagged: group.fold(0, (total, account) { + final cards = + account.allowances + .where((allowance) => allowance.status != ProviderStatus.ok) + .length; + if (cards > 0) { + return total + cards; + } + return total + (account.status == ProviderStatus.ok ? 0 : 1); + }), + allowances: allowances, + buckets: buckets, + modelBreakdown: modelBreakdown, + ), + ); + } + + sections.sort(_compareSections); + if (order == null) { + return sections; } + + // Held so a poll that only moves spend cannot slide a card out from under a + // finger; the ranking above decides again the moment a family's state moves. + final held = order.hold([for (final section in sections) _orderKey(section)]); + final position = { + for (var index = 0; index < held.length; index++) held[index]: index, + }; + sections.sort( + (left, right) => + position[_orderKey(left)]!.compareTo(position[_orderKey(right)]!), + ); return sections; } +/// What a held order is held against: the family, and the state it reports. +/// +/// Spend is deliberately absent — it is the number this freezing exists to +/// absorb. Status is deliberately present: a family falling into error climbs +/// the poll it happens, rather than waiting for another family to appear. +String _orderKey(_ProviderDashboardSection section) { + return '${section.provider} ${section.status.name}'; +} + +/// What needs action first, then what costs most, then a stable name. +int _compareSections( + _ProviderDashboardSection left, + _ProviderDashboardSection right, +) { + final statusCmp = compareByStatus(left.status, right.status); + if (statusCmp != 0) { + return statusCmp; + } + final spentCmp = compareBySpend(left.spent, right.spent); + if (spentCmp != 0) { + return spentCmp; + } + // The provider key, not the label: a copy edit must not move a card. + return left.provider.compareTo(right.provider); +} + +/// This month's spend for a whole family, or `null` when its accounts report in +/// different currencies — the core refuses to add those, and so does the order. +Money? _monthSpend(List group) { + Money? total; + for (final account in group) { + final spent = account.month.spent; + if (spent == null) { + continue; + } + if (total == null) { + total = spent; + continue; + } + if (total.currency != spent.currency) { + return null; + } + total = Money( + minorUnits: total.minorUnits + spent.minorUnits, + currency: total.currency, + ); + } + return total; +} + class _ProviderDashboardSections extends StatelessWidget { - const _ProviderDashboardSections({required this.sections, this.footer}); + const _ProviderDashboardSections({ + required this.sections, + required this.sectionKeys, + this.footer, + }); final List<_ProviderDashboardSection> sections; + + /// Owned by the screen's state so a key survives rebuilds and stays a valid + /// scroll target between the tap and the frame that answers it. + final Map sectionKeys; final Widget? footer; @override @@ -566,12 +713,12 @@ class _ProviderDashboardSections extends StatelessWidget { children: [ for (final (index, section) in sections.indexed) ...[ if (index > 0) const SizedBox(height: 20), - _ProviderDashboardSectionView(section: section), - ], - if (footer case final footer?) ...[ - const SizedBox(height: 12), - footer, + KeyedSubtree( + key: sectionKeys.putIfAbsent(section.provider, GlobalKey.new), + child: _ProviderDashboardSectionView(section: section), + ), ], + if (footer case final footer?) ...[const SizedBox(height: 12), footer], ], ); } @@ -585,7 +732,11 @@ class _ProviderDashboardSectionView extends StatelessWidget { @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; - final accent = providerFamilyColor(section.account.provider); + final chip = providerStatusChipColors( + Theme.of(context).colorScheme, + section.status, + ); + final accent = providerFamilyColor(section.provider); final cards = [ for (final allowance in section.allowances) AllowanceSummaryCard(allowance: allowance, accent: accent), @@ -606,12 +757,21 @@ class _ProviderDashboardSectionView extends StatelessWidget { ), const SizedBox(width: 10), Expanded( - child: Text( - section.account.providerLabel, - style: textTheme.titleMedium, - ), + child: Text(section.providerLabel, style: textTheme.titleMedium), ), - StatusPill(status: section.account.status), + // A rollup differs from a leaf in form: the card carries the glyph + // that states a fact, the header carries how many facts are below. + // The accent bar keeps naming the family, never the status. + if (section.flagged > 0) + Tooltip( + message: section.status.label, + triggerMode: TooltipTriggerMode.tap, + child: Badge( + backgroundColor: chip.fill, + textColor: chip.ink, + label: Text('${section.flagged}'), + ), + ), ], ), if (cards.isNotEmpty) ...[ @@ -643,19 +803,13 @@ class _ProviderDashboardSectionView extends StatelessWidget { ], if (section.showUsageHistory) ...[ const SizedBox(height: 12), - UsageHistoryChart( - buckets: section.account.buckets, - accent: accent, - ), + UsageHistoryChart(buckets: section.buckets, accent: accent), ], if (section.showModelUsage) ...[ const SizedBox(height: 12), _SectionHeader(title: 'Model usage'), const SizedBox(height: 8), - _ModelUsagePanel( - models: section.account.modelBreakdown, - accent: accent, - ), + _ModelUsagePanel(models: section.modelBreakdown, accent: accent), ], ], ); @@ -820,15 +974,3 @@ class _EmptyAlertsCard extends StatelessWidget { ); } } - -IconData _statusIcon(ProviderStatus status) { - return switch (status) { - ProviderStatus.ok => Icons.check_circle, - ProviderStatus.warning => Icons.warning_amber, - ProviderStatus.error => Icons.error, - ProviderStatus.rateLimited => Icons.speed, - ProviderStatus.authRequired => Icons.key, - ProviderStatus.stale => Icons.schedule, - ProviderStatus.unknown => Icons.help_outline, - }; -} diff --git a/apps/phone_flutter/lib/dashboard/merging_connection_repository.dart b/apps/phone_flutter/lib/dashboard/merging_connection_repository.dart index f26986b..c4d905b 100644 --- a/apps/phone_flutter/lib/dashboard/merging_connection_repository.dart +++ b/apps/phone_flutter/lib/dashboard/merging_connection_repository.dart @@ -57,9 +57,7 @@ final class MergingConnectionRepository extends DashboardRepository { } catch (error) { return _recover( fallback, - _mapError( - StateError('Normalize failed: ${loadFailureDetails(error)}'), - ), + _mapError(StateError('Normalize failed: ${loadFailureDetails(error)}')), ); } diff --git a/apps/phone_flutter/lib/dashboard/plan_recoveries.dart b/apps/phone_flutter/lib/dashboard/plan_recoveries.dart new file mode 100644 index 0000000..44314e7 --- /dev/null +++ b/apps/phone_flutter/lib/dashboard/plan_recoveries.dart @@ -0,0 +1,145 @@ +import 'dart:convert'; + +import 'package:ward_pulse_bindings/ward_pulse_bindings.dart'; + +import 'dashboard_models.dart'; + +/// One plan window, named the only way it is unique: allowance ids repeat +/// across accounts, so the account travels with them. +typedef WindowKey = ({String accountId, String allowanceId}); + +/// A window that was spent and has room again. +/// +/// `accountId` keys the notification so a repeat replaces it; it is never part +/// of what a reader sees. `provider` is: two subscriptions can both call a +/// window "Weekly plan", and the family is what tells them apart. +typedef PlanRecovery = + ({ + String accountId, + String provider, + String allowanceId, + String label, + DateTime? resetsAt, + }); + +/// Windows `snapshot` reports as spent (Rust core in production; a test seam +/// everywhere else, since host tests load no `.so`). +typedef ReadExhaustedWindows = + List Function(DashboardSnapshot snapshot); + +/// Windows from `exhausted` that `snapshot` reports usable again (Rust core in +/// production; a test seam everywhere else, since host tests load no `.so`). +typedef ReadPlanRecoveries = + List Function( + DashboardSnapshot snapshot, + List exhausted, + ); + +/// Asks the core which windows are spent right now. +/// +/// The rule lives there and only there: the phone remembers the answer between +/// polls but never decides what "exhausted" means. +List exhaustedWindows(DashboardSnapshot snapshot) { + return _decodeWindowKeys(exhaustedWindowsJson(snapshot.toJsonString())); +} + +/// Asks the core which of [exhausted] have room again in [snapshot]. +List planRecoveries( + DashboardSnapshot snapshot, + List exhausted, +) { + final recoveries = jsonDecode( + planRecoveriesJson(snapshot.toJsonString(), encodeWindowKeys(exhausted)), + ); + return [ + for (final recovery in recoveries as List) + planRecoveryFromJson(recovery as Map), + ]; +} + +/// The soonest a window in [exhausted] is expected to roll [after] a given +/// instant, or `null` when none of them says. +/// +/// Only instants still ahead count. A reset already behind means the provider +/// has not caught up with its own clock — Cursor aggregates about hourly — and +/// booking a wake for it would put the phone in a five-minute loop against a +/// window that keeps reading spent. The ordinary cadence covers that case. +/// +/// Reading, not judging: the core decides which windows are spent, and the +/// shell decides when to look again — scheduling is the shell's side of the +/// boundary, as the poll cadence already is. +DateTime? nextResetAmong( + DashboardSnapshot snapshot, + List exhausted, { + required DateTime after, +}) { + DateTime? soonest; + for (final account in snapshot.accounts) { + for (final allowance in account.allowances) { + final isExhausted = exhausted.any( + (key) => + key.accountId == account.accountId && + key.allowanceId == allowance.id, + ); + final resetsAt = allowance.resetsAt; + if (!isExhausted || resetsAt == null || !resetsAt.isAfter(after)) { + continue; + } + if (soonest == null || resetsAt.isBefore(soonest)) { + soonest = resetsAt; + } + } + } + return soonest; +} + +/// Window keys as the core reads them, ready to be stored between polls. +String encodeWindowKeys(List keys) { + return jsonEncode([ + for (final key in keys) + {'accountId': key.accountId, 'allowanceId': key.allowanceId}, + ]); +} + +/// Inverse of [encodeWindowKeys]; anything unreadable reads as "nothing was +/// remembered", which costs one missed recovery rather than a crash on launch. +/// +/// Unreadable covers more than unparseable text: a store holding `null`, an +/// object, or entries without the two fields parses fine and then fails on the +/// cast, and that failure would land on a background poll where nobody sees it. +List decodeWindowKeys(String json) { + try { + return _decodeWindowKeys(json); + } on FormatException { + return const []; + } on TypeError { + return const []; + } +} + +List _decodeWindowKeys(String json) { + return [ + for (final key in jsonDecode(json) as List) + ( + accountId: (key as Map)['accountId'] as String, + allowanceId: key['allowanceId'] as String, + ), + ]; +} + +/// One recovery as the core writes it. Exposed for the round-trip test that +/// keeps this side and the Rust side describing the same payload. +PlanRecovery planRecoveryFromJson(Map json) { + final resetsAt = json['resetsAt']; + return ( + accountId: json['accountId'] as String, + provider: json['provider'] as String, + allowanceId: json['allowanceId'] as String, + label: json['label'] as String, + // `tryParse`, because the core passes provider instants through rather than + // rejecting them (`DateTimeUtc`). An unreadable one leaves the window with + // no reset instant — still real news, just nothing to schedule a wake for — + // while throwing here would take the whole poll's bookkeeping with it. + resetsAt: resetsAt is String ? DateTime.tryParse(resetsAt) : null, + ); +} diff --git a/apps/phone_flutter/lib/dashboard/provider_status_color.dart b/apps/phone_flutter/lib/dashboard/provider_status_color.dart index c7a3800..b103392 100644 --- a/apps/phone_flutter/lib/dashboard/provider_status_color.dart +++ b/apps/phone_flutter/lib/dashboard/provider_status_color.dart @@ -7,7 +7,16 @@ const _familyCodex = Color(0xFF65D78A); const _familyClaude = Color(0xFFE8915A); const _familyCursor = Color(0xFF67E8D4); const _familyOpenAi = Color(0xFF65D78A); -const familyBudgetColor = Color(0xFF8AB4F8); + +/// The Cursor plan's own models — the one pool that leaves the family colour +/// (`WATCH_RING_DESIGN.md`, Split band). +const familyCursorOwnColor = Color(0xFF7E93B8); + +/// Demo data keeps the blue it has always had; it is not an unresolved family. +const _familyMock = Color(0xFF8AB4F8); + +/// Deliberately colourless: an id that names no family should not ship. +const familyFallbackColor = Color(0xFF8A968F); Color providerStatusColor(ColorScheme colors, ProviderStatus status) { return switch (status) { @@ -21,6 +30,39 @@ Color providerStatusColor(ColorScheme colors, ProviderStatus status) { }; } +/// Fill and ink for a status chip, as one pair so the two cannot drift apart. +/// +/// [providerStatusColor] is an ink color — right for a glyph, wrong as a fill: +/// `outline` behind text has no partner in the scheme, and Material's `Badge` +/// defaults its label to `onError`, which suits only the two statuses that +/// paint themselves `error`. Container roles are the scheme's own answer for a +/// tinted chip: each carries its own legible ink in both themes. +({Color fill, Color ink}) providerStatusChipColors( + ColorScheme colors, + ProviderStatus status, +) { + return switch (status) { + ProviderStatus.ok => ( + fill: colors.primaryContainer, + ink: colors.onPrimaryContainer, + ), + ProviderStatus.warning || + ProviderStatus.rateLimited || + ProviderStatus.stale => ( + fill: colors.tertiaryContainer, + ink: colors.onTertiaryContainer, + ), + ProviderStatus.error || ProviderStatus.authRequired => ( + fill: colors.errorContainer, + ink: colors.onErrorContainer, + ), + ProviderStatus.unknown => ( + fill: colors.surfaceContainerHighest, + ink: colors.onSurfaceVariant, + ), + }; +} + /// Accent for a provider family (matches `WATCH_RING_DESIGN.md`). Color providerFamilyColor(String provider) { return switch (provider) { @@ -28,15 +70,7 @@ Color providerFamilyColor(String provider) { 'codex' => _familyCodex, 'claude' => _familyClaude, 'cursor' => _familyCursor, - 'mock' => familyBudgetColor, - _ => familyBudgetColor, - }; -} - -/// Platform/budget metric fill: budget blue when healthy, else status color. -Color budgetMetricColor(ColorScheme colors, ProviderStatus status) { - return switch (status) { - ProviderStatus.ok => familyBudgetColor, - _ => providerStatusColor(colors, status), + 'mock' => _familyMock, + _ => familyFallbackColor, }; } diff --git a/apps/phone_flutter/lib/dashboard/provider_status_severity.dart b/apps/phone_flutter/lib/dashboard/provider_status_severity.dart new file mode 100644 index 0000000..0885bd1 --- /dev/null +++ b/apps/phone_flutter/lib/dashboard/provider_status_severity.dart @@ -0,0 +1,36 @@ +import 'dashboard_models.dart'; + +/// Most severe status, or `unknown` when there is nothing to judge. +/// +/// Mirrors `ProviderStatus::worst` in `core/ward-pulse-core/src/model/mod.rs`. +/// `provider_status_severity_test.dart` reads that source and fails if the two +/// orders drift apart. +ProviderStatus worstProviderStatus(Iterable statuses) { + if (statuses.isEmpty) { + return ProviderStatus.unknown; + } + return statuses.reduce( + (worst, status) => + providerStatusSeverity(status) > providerStatusSeverity(worst) + ? status + : worst, + ); +} + +/// Aggregation rank. Ranks are distinct, so [worstProviderStatus] never depends +/// on input order. `unknown` outranks `ok`: a surface that reported nothing must +/// not read as healthy. +/// +/// Public so that a surface ordering its rows by "needs action" ranks them on +/// this scale instead of inventing a second one. +int providerStatusSeverity(ProviderStatus status) { + return switch (status) { + ProviderStatus.ok => 1, + ProviderStatus.unknown => 2, + ProviderStatus.stale => 3, + ProviderStatus.warning => 4, + ProviderStatus.rateLimited => 5, + ProviderStatus.authRequired => 6, + ProviderStatus.error => 7, + }; +} diff --git a/apps/phone_flutter/lib/dashboard/status_pill.dart b/apps/phone_flutter/lib/dashboard/status_pill.dart new file mode 100644 index 0000000..226e654 --- /dev/null +++ b/apps/phone_flutter/lib/dashboard/status_pill.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +import 'dashboard_models.dart'; +import 'provider_status_color.dart'; + +/// One provider status as a glyph, with the word behind a tap. +/// +/// Chrome shared by the Dashboard cards and the Settings diagnostics rows, so +/// it lives beside the status colour and severity rules rather than inside the +/// screen that happened to need it first. +class StatusPill extends StatelessWidget { + const StatusPill({super.key, required this.status, this.tooltip}); + + final ProviderStatus status; + final String? tooltip; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + // Short word (OK / Warning) by default; sync callers may override. + final message = tooltip ?? status.label; + + return Tooltip( + message: message, + // Tap works on phone; hover still works on desktop/emulator with pointer. + triggerMode: TooltipTriggerMode.tap, + showDuration: const Duration(seconds: 6), + child: Padding( + // Keep chrome compact while giving the icon a usable tap target. + padding: const EdgeInsets.all(6), + child: Icon( + _statusIcon(status), + color: providerStatusColor(colors, status), + size: 18, + semanticLabel: message, + ), + ), + ); + } +} + +IconData _statusIcon(ProviderStatus status) { + return switch (status) { + ProviderStatus.ok => Icons.check_circle, + ProviderStatus.warning => Icons.warning_amber, + ProviderStatus.error => Icons.error, + ProviderStatus.rateLimited => Icons.speed, + ProviderStatus.authRequired => Icons.key, + ProviderStatus.stale => Icons.schedule, + ProviderStatus.unknown => Icons.help_outline, + }; +} diff --git a/apps/phone_flutter/lib/main.dart b/apps/phone_flutter/lib/main.dart index 320fd17..27541f5 100644 --- a/apps/phone_flutter/lib/main.dart +++ b/apps/phone_flutter/lib/main.dart @@ -4,12 +4,17 @@ import 'package:flutter/widgets.dart'; import 'app/ward_pulse_app.dart'; import 'dashboard/dashboard_repository.dart'; import 'dashboard/phone_live_bindings.dart'; -import 'settings/consumption_display_preferences.dart'; +import 'settings/alert_threshold_preferences.dart'; import 'settings/debug_data_preferences.dart'; import 'settings/refresh_interval_preferences.dart'; import 'settings/watch_ring_preferences.dart'; +import 'widget/phone_widget_preferences.dart'; +import 'widget/phone_widget_sync.dart'; import 'sync/headless_provider_sync.dart'; import 'sync/provider_sync_scheduler.dart'; +import 'sync/recovery_notifications.dart'; +import 'sync/recovery_wake.dart'; +import 'sync/recovery_watchlist.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -22,12 +27,17 @@ Future main() async { credentialStore: live.credentialStore, codexAccountService: live.codexAccountService, claudeAccountService: live.claudeAccountService, - displayPreferenceStore: SecureConsumptionDisplayPreferenceStore(), refreshIntervalStore: SecureRefreshIntervalPreferenceStore(), watchRingPreferenceStore: SecureWatchRingPreferenceStore(), + phoneWidgetPreferenceStore: SecurePhoneWidgetPreferenceStore(), + phoneWidgetSyncService: HomeWidgetPhoneWidgetSyncService(), + alertThresholdStore: SecureAlertThresholdPreferenceStore(), syncScheduler: TimerProviderSyncScheduler(), debugDataAvailable: kDebugMode, debugDataPreferenceStore: debugDataPreferenceStore, + recoveryWatchlistStore: SecureRecoveryWatchlistStore(), + recoveryNotifier: LocalRecoveryNotifier(), + recoveryWake: const WorkmanagerRecoveryWakeScheduler(), repository: kDebugMode ? DebugDashboardRepository( diff --git a/apps/phone_flutter/lib/providers/alert_threshold_dialogs.dart b/apps/phone_flutter/lib/providers/alert_threshold_dialogs.dart new file mode 100644 index 0000000..bf77513 --- /dev/null +++ b/apps/phone_flutter/lib/providers/alert_threshold_dialogs.dart @@ -0,0 +1,253 @@ +import 'package:flutter/material.dart'; + +import '../settings/alert_percent_threshold_editor.dart'; +import '../settings/alert_threshold_preferences.dart'; +import 'provider_connection.dart'; + +/// Edits the alert rules of one connection. +/// +/// Which rules apply depends on what the connection reports: a subscription +/// plan exposes usage windows, an organization key exposes spend. Pops the +/// edited thresholds, or null when dismissed. +class ConnectionAlertsDialog extends StatefulWidget { + const ConnectionAlertsDialog({ + super.key, + required this.connectionTitle, + required this.kind, + required this.thresholds, + }); + + final String connectionTitle; + final ConnectionKind kind; + final ConnectionAlertThresholds thresholds; + + @override + State createState() => _ConnectionAlertsDialogState(); +} + +class _ConnectionAlertsDialogState extends State { + late var _draft = widget.thresholds; + + @override + Widget build(BuildContext context) { + final isPlan = widget.kind == ConnectionKind.plan; + return _ConnectionDialog( + title: 'Alerts · ${widget.connectionTitle}', + intro: + isPlan + ? 'Opt-in · fire when remaining capacity reaches the chosen % ' + 'left. Off until you pick a value.' + : 'Opt-in · fire when this connection’s remaining budget reaches ' + 'the chosen % left. Other providers do not count.', + onSave: () => Navigator.of(context).pop(_draft), + editors: isPlan ? _planEditors() : _budgetEditors(), + ); + } + + List _planEditors() { + return [ + AlertPercentThresholdEditor( + title: 'Plan', + value: _draft.plan, + onChanged: + (plan) => setState(() => _draft = _draft.copyWith(plan: plan)), + ), + AlertPercentThresholdEditor( + title: 'Purchased', + value: _draft.purchased, + onChanged: + (purchased) => + setState(() => _draft = _draft.copyWith(purchased: purchased)), + ), + ]; + } + + List _budgetEditors() { + return [ + for (final period in _BudgetPeriod.values) + AlertPercentThresholdEditor( + title: period.label, + value: period.thresholdOf(_draft), + // The limit comes first: without it there is no percentage to alert on. + disabledReason: + period.limitOf(_draft.budget) == null + ? 'Set this period’s limit under Budget limits' + : null, + onChanged: + (value) => + setState(() => _draft = period.withThreshold(_draft, value)), + ), + ]; + } +} + +/// Edits the [ConnectionBudget] of one connection. +/// +/// Pops the edited budget, or null when dismissed. +class ConnectionBudgetDialog extends StatefulWidget { + const ConnectionBudgetDialog({ + super.key, + required this.connectionTitle, + required this.budget, + }); + + final String connectionTitle; + final ConnectionBudget budget; + + @override + State createState() => _ConnectionBudgetDialogState(); +} + +class _ConnectionBudgetDialogState extends State { + late var _draft = widget.budget; + late final _limits = { + for (final period in _BudgetPeriod.values) + period: TextEditingController(text: _amountText(period.limitOf(_draft))), + }; + + @override + void dispose() { + for (final controller in _limits.values) { + controller.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _ConnectionDialog( + title: 'Budget · ${widget.connectionTitle}', + intro: + 'Your own ceiling on this connection’s spend — providers report the ' + 'spend, never the limit. Empty means no limit.', + onSave: () => Navigator.of(context).pop(_draft), + editors: [ + for (final period in _BudgetPeriod.values) + TextField( + controller: _limits[period], + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: period.label, + border: const OutlineInputBorder(), + isDense: true, + ), + // No setState: the controllers own the text this build reads. + onChanged: + (text) => _draft = period.withLimit(_draft, _minorUnits(text)), + ), + ], + ); + } +} + +/// Budget periods in the order the dialog shows them. +enum _BudgetPeriod { + today('Today'), + week('Week'), + month('Month'); + + const _BudgetPeriod(this.label); + + final String label; + + AlertPercentThreshold thresholdOf(ConnectionAlertThresholds rules) => + switch (this) { + _BudgetPeriod.today => rules.today, + _BudgetPeriod.week => rules.week, + _BudgetPeriod.month => rules.month, + }; + + int? limitOf(ConnectionBudget budget) => switch (this) { + _BudgetPeriod.today => budget.today, + _BudgetPeriod.week => budget.week, + _BudgetPeriod.month => budget.month, + }; + + ConnectionAlertThresholds withThreshold( + ConnectionAlertThresholds rules, + AlertPercentThreshold value, + ) => switch (this) { + _BudgetPeriod.today => rules.copyWith(today: value), + _BudgetPeriod.week => rules.copyWith(week: value), + _BudgetPeriod.month => rules.copyWith(month: value), + }; + + ConnectionBudget withLimit(ConnectionBudget budget, int? minorUnits) => + switch (this) { + _BudgetPeriod.today => ConnectionBudget( + today: minorUnits, + week: budget.week, + month: budget.month, + ), + _BudgetPeriod.week => ConnectionBudget( + today: budget.today, + week: minorUnits, + month: budget.month, + ), + _BudgetPeriod.month => ConnectionBudget( + today: budget.today, + week: budget.week, + month: minorUnits, + ), + }; +} + +/// Minor units are the storage unit; the field takes major units. +String _amountText(int? minorUnits) => + minorUnits == null ? '' : (minorUnits / 100).toStringAsFixed(2); + +int? _minorUnits(String text) { + final value = double.tryParse(text.trim().replaceAll(',', '.')); + if (value == null || value <= 0) { + return null; + } + return (value * 100).round(); +} + +/// Shell both dialogs use: intro copy, spaced editors, Cancel/Save. +class _ConnectionDialog extends StatelessWidget { + const _ConnectionDialog({ + required this.title, + required this.intro, + required this.editors, + required this.onSave, + }); + + final String title; + final String intro; + final List editors; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AlertDialog( + title: Text(title), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + intro, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + for (final editor in editors) ...[ + const SizedBox(height: 16), + editor, + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton(onPressed: onSave, child: const Text('Save')), + ], + ); + } +} diff --git a/apps/phone_flutter/lib/providers/claude_account_row.dart b/apps/phone_flutter/lib/providers/claude_account_row.dart new file mode 100644 index 0000000..74ab2aa --- /dev/null +++ b/apps/phone_flutter/lib/providers/claude_account_row.dart @@ -0,0 +1,330 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../sync/claude_account_client.dart'; +import 'claude_account_service.dart'; +import 'connected_account_dialog.dart'; +import 'cursor_plan_row.dart'; +import 'provider_connection.dart'; +import 'provider_credential_store.dart'; +import 'provider_connection_row.dart'; + +/// Claude subscription row: Claude Code OAuth sign-in, reconnect, disconnect. +/// +/// Owns its own connected/connecting state so the Providers screen does not. +class ClaudeAccountRow extends StatefulWidget { + const ClaudeAccountRow({ + super.key, + required this.connection, + required this.accountService, + required this.credentialStore, + required this.onCredentialsChanged, + required this.onPastedSecretCleared, + required this.buildTrailing, + }); + + final ProviderConnection connection; + final ClaudeAccountService accountService; + final ProviderCredentialStore credentialStore; + final VoidCallback onCredentialsChanged; + + /// OAuth supersedes a pasted token; lets the screen drop its cached flag. + final VoidCallback onPastedSecretCleared; + final ConnectionTrailingBuilder buildTrailing; + + @override + State createState() => _ClaudeAccountRowState(); +} + +class _ClaudeAccountRowState extends State { + bool? _hasAccount; + bool _isConnecting = false; + + @override + void initState() { + super.initState(); + _loadState(); + } + + Future _loadState() async { + try { + final value = await widget.accountService.isConnected(); + if (mounted) { + setState(() { + _hasAccount = value; + }); + } + } catch (_) { + if (mounted) { + setState(() { + _hasAccount = false; + }); + } + } + } + + Future _editAccount() async { + if (_hasAccount == true) { + final action = await showDialog( + context: context, + builder: + (context) => const ConnectedAccountDialog( + title: 'Claude account', + message: + 'WardPulse reads Claude subscription windows directly on ' + 'this phone using Claude Code OAuth.', + ), + ); + if (action == AccountAction.disconnect) { + try { + await widget.accountService.disconnect(); + } on ClaudeAccountException catch (error) { + if (mounted) { + _showMessage(error.details ?? 'Could not disconnect Claude'); + } + return; + } catch (_) { + if (mounted) { + _showMessage('Could not disconnect Claude'); + } + return; + } + if (mounted) { + setState(() { + _hasAccount = false; + }); + widget.onCredentialsChanged(); + } + return; + } + if (action != AccountAction.reconnect) { + return; + } + } + + setState(() { + _isConnecting = true; + }); + try { + final attempt = await widget.accountService.startLogin(); + if (!mounted) { + attempt.cancel(); + return; + } + final connected = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => _ClaudeLoginDialog(attempt: attempt), + ); + if (connected == true && mounted) { + // Drop any pre-OAuth pasted access token for this connection. + try { + await widget.credentialStore.deleteSecret( + ProviderConnections.claudePlan, + ); + } catch (_) { + // Session is already saved; orphaned paste is non-fatal. + } + if (!mounted) { + return; + } + setState(() { + _hasAccount = true; + }); + widget.onPastedSecretCleared(); + widget.onCredentialsChanged(); + } + } on ClaudeAccountException catch (error) { + if (mounted && error.failure != ClaudeAccountFailure.cancelled) { + _showMessage(error.details ?? 'Could not start Claude sign-in'); + } + } catch (_) { + if (mounted) { + _showMessage('Could not start Claude sign-in'); + } + } finally { + if (mounted) { + setState(() { + _isConnecting = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return ProviderConnectionRow( + icon: Icons.account_circle_outlined, + title: widget.connection.listTitle, + subtitle: widget.connection.listSubtitle, + trailing: widget.buildTrailing( + status: switch ((_hasAccount, _isConnecting)) { + (_, true) || (null, _) => const RowProgress(), + (true, _) => const Text('Connected'), + (false, _) => const Text('Not connected'), + }, + ), + onTap: _hasAccount == null || _isConnecting ? null : _editAccount, + ); + } + + void _showMessage(String message) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } +} + +class _ClaudeLoginDialog extends StatefulWidget { + const _ClaudeLoginDialog({required this.attempt}); + + final ClaudeLoginAttempt attempt; + + @override + State<_ClaudeLoginDialog> createState() => _ClaudeLoginDialogState(); +} + +class _ClaudeLoginDialogState extends State<_ClaudeLoginDialog> { + final _codeController = TextEditingController(); + final _formKey = GlobalKey(); + String? _browserError; + String? _submitError; + bool _submitting = false; + bool _completed = false; + + @override + void dispose() { + _codeController.dispose(); + if (!_completed) { + widget.attempt.cancel(); + } + super.dispose(); + } + + Future _openBrowser() async { + if (_browserError != null) { + setState(() { + _browserError = null; + }); + } + try { + final opened = await launchUrl( + widget.attempt.authorization.authorizationUri, + mode: LaunchMode.externalApplication, + ); + if (opened || !mounted) { + return; + } + } catch (_) { + if (!mounted) { + return; + } + } + if (mounted) { + setState(() { + _browserError = 'Could not open the browser'; + }); + } + } + + Future _submit() async { + if (_submitting || !(_formKey.currentState?.validate() ?? false)) { + return; + } + setState(() { + _submitting = true; + _submitError = null; + }); + try { + await widget.attempt.completeWithCode(_codeController.text); + _completed = true; + if (mounted) { + Navigator.of(context).pop(true); + } + } on ClaudeAccountException catch (error) { + if (mounted && error.failure != ClaudeAccountFailure.cancelled) { + setState(() { + _submitError = error.details ?? 'Claude sign-in failed'; + _submitting = false; + }); + } + } catch (_) { + if (mounted) { + setState(() { + _submitError = 'Claude sign-in failed'; + _submitting = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Connect Claude'), + scrollable: true, + content: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Open the secure Claude sign-in page, approve access, then paste ' + 'the authorization code from the callback page (CODE#STATE).', + ), + const SizedBox(height: 16), + TextFormField( + controller: _codeController, + enabled: !_submitting, + autocorrect: false, + enableSuggestions: false, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Authorization code', + hintText: 'CODE#STATE', + ), + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + validator: (value) { + return value == null || value.trim().isEmpty + ? 'Paste the authorization code' + : null; + }, + ), + const SizedBox(height: 12), + if (_submitting) const LinearProgressIndicator(), + if (_submitError != null) + Text( + _submitError!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + if (_browserError != null) ...[ + const SizedBox(height: 8), + Text( + _browserError!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: + _submitting ? null : () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton.icon( + onPressed: _submitting ? null : _openBrowser, + icon: const Icon(Icons.open_in_browser), + label: const Text('Open browser'), + ), + FilledButton( + onPressed: _submitting ? null : _submit, + child: const Text('Continue'), + ), + ], + ); + } +} diff --git a/apps/phone_flutter/lib/providers/claude_account_service.dart b/apps/phone_flutter/lib/providers/claude_account_service.dart index c78f561..2b4b11a 100644 --- a/apps/phone_flutter/lib/providers/claude_account_service.dart +++ b/apps/phone_flutter/lib/providers/claude_account_service.dart @@ -66,19 +66,24 @@ final class MobileClaudeAccountService implements ClaudeAccountService { return ClaudeLoginAttempt( authorization: authorization, - completeWithCode: (pastedCode) => _serialized(() async { - if (cancellation.isCompleted) { - throw const ClaudeAccountException(ClaudeAccountFailure.cancelled); - } - final session = await _client.exchangeAuthorizationCode( - request: authorization, - pastedCode: pastedCode, - ); - if (cancellation.isCompleted) { - throw const ClaudeAccountException(ClaudeAccountFailure.cancelled); - } - await _writeSession(session); - }), + completeWithCode: + (pastedCode) => _serialized(() async { + if (cancellation.isCompleted) { + throw const ClaudeAccountException( + ClaudeAccountFailure.cancelled, + ); + } + final session = await _client.exchangeAuthorizationCode( + request: authorization, + pastedCode: pastedCode, + ); + if (cancellation.isCompleted) { + throw const ClaudeAccountException( + ClaudeAccountFailure.cancelled, + ); + } + await _writeSession(session); + }), cancel: () { if (!cancellation.isCompleted) { cancellation.complete(); diff --git a/apps/phone_flutter/lib/providers/codex_account_row.dart b/apps/phone_flutter/lib/providers/codex_account_row.dart new file mode 100644 index 0000000..7b156e6 --- /dev/null +++ b/apps/phone_flutter/lib/providers/codex_account_row.dart @@ -0,0 +1,309 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../sync/codex_account_client.dart'; +import 'codex_account_service.dart'; +import 'connected_account_dialog.dart'; +import 'cursor_plan_row.dart'; +import 'provider_connection.dart'; +import 'provider_connection_row.dart'; + +/// Codex subscription row: OAuth sign-in, reconnect, and disconnect. +/// +/// Owns its own connected/connecting state so the Providers screen does not. +class CodexAccountRow extends StatefulWidget { + const CodexAccountRow({ + super.key, + required this.connection, + required this.accountService, + required this.onCredentialsChanged, + required this.buildTrailing, + }); + + final ProviderConnection connection; + final CodexAccountService accountService; + final VoidCallback onCredentialsChanged; + final ConnectionTrailingBuilder buildTrailing; + + @override + State createState() => _CodexAccountRowState(); +} + +class _CodexAccountRowState extends State { + bool? _hasAccount; + bool _isConnecting = false; + + @override + void initState() { + super.initState(); + _loadState(); + } + + Future _loadState() async { + try { + final value = await widget.accountService.isConnected(); + if (mounted) { + setState(() { + _hasAccount = value; + }); + } + } catch (_) { + if (mounted) { + setState(() { + _hasAccount = false; + }); + } + } + } + + Future _editAccount() async { + if (_hasAccount == true) { + final action = await showDialog( + context: context, + builder: + (context) => const ConnectedAccountDialog( + title: 'Codex account', + message: + 'WardPulse reads subscription limits and token activity ' + 'directly on this phone.', + ), + ); + if (action == AccountAction.disconnect) { + try { + await widget.accountService.disconnect(); + } on CodexAccountException catch (error) { + if (mounted) { + _showMessage(error.details ?? 'Could not disconnect Codex'); + } + return; + } catch (_) { + if (mounted) { + _showMessage('Could not disconnect Codex'); + } + return; + } + if (mounted) { + setState(() { + _hasAccount = false; + }); + widget.onCredentialsChanged(); + } + return; + } + if (action != AccountAction.reconnect) { + return; + } + } + + setState(() { + _isConnecting = true; + }); + try { + final attempt = await widget.accountService.startLogin(); + if (!mounted) { + attempt.cancel(); + return; + } + final connected = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => _CodexLoginDialog(attempt: attempt), + ); + if (connected == true && mounted) { + setState(() { + _hasAccount = true; + }); + widget.onCredentialsChanged(); + } + } on CodexAccountException catch (error) { + if (mounted && error.failure != CodexAccountFailure.cancelled) { + _showMessage(error.details ?? 'Could not start Codex sign-in'); + } + } catch (_) { + if (mounted) { + _showMessage('Could not start Codex sign-in'); + } + } finally { + if (mounted) { + setState(() { + _isConnecting = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return ProviderConnectionRow( + icon: Icons.account_circle_outlined, + title: widget.connection.listTitle, + subtitle: widget.connection.listSubtitle, + trailing: widget.buildTrailing( + status: switch ((_hasAccount, _isConnecting)) { + (_, true) || (null, _) => const RowProgress(), + (true, _) => const Text('Connected'), + (false, _) => const Text('Not connected'), + }, + ), + onTap: _hasAccount == null || _isConnecting ? null : _editAccount, + ); + } + + void _showMessage(String message) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } +} + +class _CodexLoginDialog extends StatefulWidget { + const _CodexLoginDialog({required this.attempt}); + + final CodexLoginAttempt attempt; + + @override + State<_CodexLoginDialog> createState() => _CodexLoginDialogState(); +} + +class _CodexLoginDialogState extends State<_CodexLoginDialog> { + String? _error; + String? _browserError; + bool _completed = false; + + @override + void initState() { + super.initState(); + _waitForLogin(); + } + + Future _waitForLogin() async { + try { + await widget.attempt.completion; + _completed = true; + if (mounted) { + Navigator.of(context).pop(true); + } + } on CodexAccountException catch (error) { + if (mounted && error.failure != CodexAccountFailure.cancelled) { + setState(() { + _error = error.details ?? 'Codex sign-in failed'; + }); + } + } catch (_) { + if (mounted) { + setState(() { + _error = 'Codex sign-in failed'; + }); + } + } + } + + @override + void dispose() { + if (!_completed) { + widget.attempt.cancel(); + } + super.dispose(); + } + + Future _openBrowser() async { + if (_browserError != null) { + setState(() { + _browserError = null; + }); + } + try { + final opened = await launchUrl( + widget.attempt.deviceCode.verificationUri, + mode: LaunchMode.externalApplication, + ); + if (opened || !mounted) { + return; + } + } catch (_) { + if (!mounted) { + return; + } + } + if (mounted) { + setState(() { + _browserError = 'Could not open the browser'; + }); + } + } + + Future _copyCode() async { + await Clipboard.setData( + ClipboardData(text: widget.attempt.deviceCode.userCode), + ); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Code copied'))); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Connect Codex'), + scrollable: true, + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Open the secure OpenAI sign-in page, then enter this one-time code:', + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: SelectableText( + widget.attempt.deviceCode.userCode, + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + IconButton( + tooltip: 'Copy code', + onPressed: _copyCode, + icon: const Icon(Icons.copy_outlined), + ), + ], + ), + SelectableText( + widget.attempt.deviceCode.verificationUri.toString(), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + if (_error == null) + const LinearProgressIndicator() + else + Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + if (_browserError != null) ...[ + const SizedBox(height: 12), + Text( + _browserError!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton.icon( + onPressed: _openBrowser, + icon: const Icon(Icons.open_in_browser), + label: const Text('Open browser'), + ), + ], + ); + } +} diff --git a/apps/phone_flutter/lib/providers/connected_account_dialog.dart b/apps/phone_flutter/lib/providers/connected_account_dialog.dart new file mode 100644 index 0000000..8728104 --- /dev/null +++ b/apps/phone_flutter/lib/providers/connected_account_dialog.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +/// Confirms what to do with an already connected provider account. +class ConnectedAccountDialog extends StatelessWidget { + const ConnectedAccountDialog({ + super.key, + required this.title, + required this.message, + this.confirmLabel = 'Reconnect', + }); + + final String title; + final String message; + final String confirmLabel; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(AccountAction.disconnect), + child: const Text('Disconnect'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(AccountAction.reconnect), + child: Text(confirmLabel), + ), + ], + ); + } +} + +enum AccountAction { reconnect, disconnect } diff --git a/apps/phone_flutter/lib/providers/credential_dialog.dart b/apps/phone_flutter/lib/providers/credential_dialog.dart new file mode 100644 index 0000000..433d738 --- /dev/null +++ b/apps/phone_flutter/lib/providers/credential_dialog.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; + +/// Edits one pasted provider secret, with an optional display label. +/// +/// Pops a [CredentialChange], or null when dismissed. +class CredentialDialog extends StatefulWidget { + const CredentialDialog({ + super.key, + required this.hasCredential, + required this.title, + required this.hint, + required this.allowLabel, + this.initialLabel, + }); + + final bool hasCredential; + final String title; + final String hint; + final bool allowLabel; + final String? initialLabel; + + @override + State createState() => _CredentialDialogState(); +} + +class _CredentialDialogState extends State { + final _formKey = GlobalKey(); + final _controller = TextEditingController(); + late final TextEditingController _labelController; + bool _obscureKey = true; + + @override + void initState() { + super.initState(); + _labelController = TextEditingController(text: widget.initialLabel ?? ''); + } + + @override + void dispose() { + _controller.dispose(); + _labelController.dispose(); + super.dispose(); + } + + void _save() { + final key = _controller.text.trim(); + final label = _labelController.text.trim(); + final resolvedLabel = label.isEmpty ? null : label; + if (key.isEmpty) { + if (!widget.hasCredential) { + _formKey.currentState?.validate(); + return; + } + if (widget.allowLabel) { + Navigator.of(context).pop(CredentialChange.labelOnly(resolvedLabel)); + } else { + // Leave blank to keep the stored token; dismiss without a mutation. + Navigator.of(context).pop(); + } + return; + } + if (_formKey.currentState?.validate() ?? false) { + Navigator.of(context).pop( + CredentialChange.save( + key, + label: widget.allowLabel ? resolvedLabel : null, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title), + content: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.allowLabel + ? 'This key is encrypted on this phone and sent only to the provider.' + : 'This token is encrypted on this phone. Experimental compatibility connection.', + ), + const SizedBox(height: 16), + TextFormField( + controller: _controller, + autofocus: true, + obscureText: _obscureKey, + autocorrect: false, + enableSuggestions: false, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: + widget.hasCredential + ? 'Value (leave blank to keep)' + : 'Value', + hintText: widget.hint, + suffixIcon: IconButton( + tooltip: _obscureKey ? 'Show value' : 'Hide value', + onPressed: () { + setState(() { + _obscureKey = !_obscureKey; + }); + }, + icon: Icon( + _obscureKey + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + ), + ), + textInputAction: + widget.allowLabel + ? TextInputAction.next + : TextInputAction.done, + onFieldSubmitted: widget.allowLabel ? null : (_) => _save(), + validator: (value) { + if (widget.hasCredential) { + return null; + } + return value == null || value.trim().isEmpty + ? 'Enter a value' + : null; + }, + ), + if (widget.allowLabel) ...[ + const SizedBox(height: 12), + TextFormField( + controller: _labelController, + autocorrect: false, + enableSuggestions: false, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Label (optional)', + hintText: 'Work org key', + ), + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _save(), + ), + ], + ], + ), + ), + actions: [ + if (widget.hasCredential) + TextButton( + onPressed: () { + Navigator.of(context).pop(const CredentialChange.remove()); + }, + child: const Text('Remove'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton(onPressed: _save, child: const Text('Save')), + ], + ); + } +} + +/// What [CredentialDialog] was asked to do with the secret. +final class CredentialChange { + const CredentialChange.save(this.value, {this.label}) + : remove = false, + updateLabelOnly = false; + + const CredentialChange.labelOnly(this.label) + : value = null, + remove = false, + updateLabelOnly = true; + + const CredentialChange.remove() + : value = null, + label = null, + remove = true, + updateLabelOnly = false; + + final String? value; + final String? label; + final bool remove; + final bool updateLabelOnly; +} diff --git a/apps/phone_flutter/lib/providers/cursor_plan_row.dart b/apps/phone_flutter/lib/providers/cursor_plan_row.dart new file mode 100644 index 0000000..b7497fa --- /dev/null +++ b/apps/phone_flutter/lib/providers/cursor_plan_row.dart @@ -0,0 +1,281 @@ +import 'package:flutter/material.dart'; + +import 'connected_account_dialog.dart'; +import 'credential_dialog.dart'; +import 'cursor_plan_sign_in_screen.dart'; +import 'cursor_session_token.dart'; +import 'cursor_webview_cookies.dart'; +import 'provider_connection.dart'; +import 'provider_connection_row.dart'; +import 'provider_credential_store.dart'; + +/// Opens Cursor dashboard sign-in and returns a session token, or null. +typedef CursorPlanSignIn = Future Function(BuildContext context); + +/// Screen-owned trailing so every catalog row keeps the same chrome. +typedef ConnectionTrailingBuilder = + Widget Function({List leading, required Widget status}); + +/// Cursor plan row: WebView sign-in, token paste, and disconnect. +/// +/// Owns its own connected/connecting state so the Providers screen does not. +class CursorPlanRow extends StatefulWidget { + const CursorPlanRow({ + super.key, + required this.connection, + required this.credentialStore, + required this.onCredentialsChanged, + required this.buildTrailing, + this.signIn, + }); + + final ProviderConnection connection; + final ProviderCredentialStore credentialStore; + final VoidCallback onCredentialsChanged; + final ConnectionTrailingBuilder buildTrailing; + + /// Test seam; defaults to [CursorPlanSignInScreen.open]. + final CursorPlanSignIn? signIn; + + @override + State createState() => _CursorPlanRowState(); +} + +class _CursorPlanRowState extends State { + bool? _hasAccount; + bool _isConnecting = false; + + @override + void initState() { + super.initState(); + _loadState(); + } + + Future _loadState() async { + try { + final value = + await widget.credentialStore.readSecret( + ProviderConnections.cursorPlan, + ) != + null; + if (mounted) { + setState(() { + _hasAccount = value; + }); + } + } catch (_) { + if (mounted) { + setState(() { + _hasAccount = false; + }); + } + } + } + + Future _editAccount() async { + if (_hasAccount == true) { + final action = await showDialog( + context: context, + builder: + (context) => const ConnectedAccountDialog( + title: 'Cursor plan', + message: + 'WardPulse reads personal plan usage from the Cursor ' + 'dashboard session stored on this phone.', + confirmLabel: 'Sign in again', + ), + ); + if (action == AccountAction.disconnect) { + try { + await widget.credentialStore.deleteSecret( + ProviderConnections.cursorPlan, + ); + final wiped = await wipeCursorWebViewSession(); + if (!wiped && mounted) { + _showMessage( + 'Disconnected, but could not clear the in-app Cursor session', + ); + } + } catch (_) { + if (mounted) { + _showMessage('Could not disconnect Cursor plan'); + } + return; + } + if (mounted) { + setState(() { + _hasAccount = false; + }); + widget.onCredentialsChanged(); + } + return; + } + if (action != AccountAction.reconnect) { + return; + } + } + + await _runSignIn(); + } + + Future _runSignIn() async { + setState(() { + _isConnecting = true; + }); + try { + final signIn = widget.signIn ?? CursorPlanSignInScreen.open; + final token = await signIn(context); + if (!mounted) { + return; + } + final normalized = + token == null ? null : normalizeCursorSessionToken(token); + if (normalized == null) { + return; + } + await widget.credentialStore.writeSecret( + ProviderConnections.cursorPlan, + normalized, + ); + if (!mounted) { + return; + } + setState(() { + _hasAccount = true; + }); + widget.onCredentialsChanged(); + } catch (_) { + if (mounted) { + _showMessage('Could not complete Cursor sign-in'); + } + } finally { + if (mounted) { + setState(() { + _isConnecting = false; + }); + } + } + } + + Future _showHelp() async { + final paste = await showDialog( + context: context, + builder: (context) => const _CursorPlanHelpDialog(), + ); + if (paste == true && mounted) { + await _pasteToken(); + } + } + + Future _pasteToken() async { + const id = ProviderConnections.cursorPlan; + final change = await showDialog( + context: context, + builder: + (context) => CredentialDialog( + title: 'Cursor plan · Paste token', + hint: cursorSessionCookieName, + allowLabel: false, + hasCredential: _hasAccount ?? false, + ), + ); + if (change == null) { + return; + } + try { + if (change.remove) { + await widget.credentialStore.deleteSecret(id); + final wiped = await wipeCursorWebViewSession(); + if (!wiped && mounted) { + _showMessage( + 'Removed, but could not clear the in-app Cursor session', + ); + } + } else if (change.value != null) { + final token = normalizeCursorSessionToken(change.value!); + if (token == null) { + if (mounted) { + _showMessage('That does not look like a Cursor session token'); + } + return; + } + await widget.credentialStore.writeSecret(id, token); + } else { + return; + } + if (!mounted) { + return; + } + setState(() { + _hasAccount = !change.remove; + }); + widget.onCredentialsChanged(); + } catch (_) { + if (mounted) { + _showMessage('Could not update Cursor plan token'); + } + } + } + + @override + Widget build(BuildContext context) { + final status = switch ((_hasAccount, _isConnecting)) { + (_, true) || (null, _) => const RowProgress(), + (true, _) => const Text('Connected'), + (false, _) => const Text('Not connected'), + }; + return ProviderConnectionRow( + icon: Icons.account_circle_outlined, + title: widget.connection.listTitle, + subtitle: widget.connection.listSubtitle, + trailing: widget.buildTrailing( + leading: [ + IconButton( + tooltip: 'About Cursor sign-in', + onPressed: _isConnecting ? null : _showHelp, + icon: const Icon(Icons.help_outline), + ), + ], + status: status, + ), + onTap: _hasAccount == null || _isConnecting ? null : _editAccount, + ); + } + + void _showMessage(String message) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } +} + +class _CursorPlanHelpDialog extends StatelessWidget { + const _CursorPlanHelpDialog(); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Cursor sign-in'), + content: const SingleChildScrollView( + child: Text( + 'Tap the Cursor plan row to open Cursor’s dashboard in the app and ' + 'sign in with your Cursor account.\n\n' + 'WardPulse keeps the dashboard session on this phone only and uses it ' + 'to read plan usage. This is an experimental compatibility login, not ' + 'a published Cursor API.\n\n' + 'If you already have a WorkosCursorSessionToken, use Advanced paste.', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Advanced paste'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('OK'), + ), + ], + ); + } +} diff --git a/apps/phone_flutter/lib/providers/cursor_plan_sign_in_screen.dart b/apps/phone_flutter/lib/providers/cursor_plan_sign_in_screen.dart index 62dd894..170cbbf 100644 --- a/apps/phone_flutter/lib/providers/cursor_plan_sign_in_screen.dart +++ b/apps/phone_flutter/lib/providers/cursor_plan_sign_in_screen.dart @@ -143,7 +143,10 @@ class _CursorPlanSignInScreenState extends State { if (!mounted) { return; } - _poll = Timer.periodic(widget.pollInterval, (_) => unawaited(_probeCookies())); + _poll = Timer.periodic( + widget.pollInterval, + (_) => unawaited(_probeCookies()), + ); } @override diff --git a/apps/phone_flutter/lib/providers/cursor_webview_cookies.dart b/apps/phone_flutter/lib/providers/cursor_webview_cookies.dart index 662d0f7..1baab22 100644 --- a/apps/phone_flutter/lib/providers/cursor_webview_cookies.dart +++ b/apps/phone_flutter/lib/providers/cursor_webview_cookies.dart @@ -86,9 +86,7 @@ Future ensureCursorSessionCookieCleared({ } /// Wipes the shared WebView jar and confirms the Cursor session cookie is gone. -Future wipeCursorWebViewSession([ - WebViewCookieManager? cookieManager, -]) { +Future wipeCursorWebViewSession([WebViewCookieManager? cookieManager]) { final manager = cookieManager ?? WebViewCookieManager(); return ensureCursorSessionCookieCleared( clear: manager.clearCookies, diff --git a/apps/phone_flutter/lib/providers/provider_connection.dart b/apps/phone_flutter/lib/providers/provider_connection.dart index 536ae03..e9aad32 100644 --- a/apps/phone_flutter/lib/providers/provider_connection.dart +++ b/apps/phone_flutter/lib/providers/provider_connection.dart @@ -18,6 +18,20 @@ final class ProviderConnectionId { String get storageKey => '${provider.name}.${kind.name}'; + /// Inverse of [storageKey]; null when the key names no known connection. + static ProviderConnectionId? fromStorageKey(String key) { + final parts = key.split('.'); + if (parts.length != 2) { + return null; + } + final provider = ProviderFamily.values.asNameMap()[parts.first]; + final kind = ConnectionKind.values.asNameMap()[parts.last]; + if (provider == null || kind == null) { + return null; + } + return ProviderConnectionId(provider: provider, kind: kind); + } + @override bool operator ==(Object other) => other is ProviderConnectionId && @@ -99,7 +113,7 @@ final class ProviderConnection { } } -/// Canonical connection catalog shown in Settings. +/// Canonical connection catalog shown on the Providers tab. /// /// [platformLabels] holds stored labels keyed by /// [ProviderConnectionId.storageKey]. @@ -150,6 +164,25 @@ List providerConnectionCatalog({ ]; } +/// The family a snapshot's provider belongs to, or `null` for one this build +/// does not know. +/// +/// A subscription and the reporting key beside it are the same family: `codex` +/// and `openai` are both OpenAI, `claude` and `anthropic` both Anthropic. The +/// Providers tab shows one card per family, so this is how account data reaches +/// the card that speaks for it. +/// +/// The pickers read the same strings one step finer, down to the connection +/// (`metric_catalog_groups.dart`). A new provider belongs in both. +ProviderFamily? providerFamilyOf(String provider) { + return switch (provider) { + 'openai' || 'codex' => ProviderFamily.openai, + 'anthropic' || 'claude' => ProviderFamily.anthropic, + 'cursor' => ProviderFamily.cursor, + _ => null, + }; +} + String providerFamilyLabel(ProviderFamily provider) { return switch (provider) { ProviderFamily.openai => 'OpenAI', @@ -157,3 +190,11 @@ String providerFamilyLabel(ProviderFamily provider) { ProviderFamily.cursor => 'Cursor', }; } + +/// Sentence case, to follow a family label (`Anthropic platform`, `Cursor plan`). +String connectionKindLabel(ConnectionKind kind) { + return switch (kind) { + ConnectionKind.plan => 'plan', + ConnectionKind.platform => 'platform', + }; +} diff --git a/apps/phone_flutter/lib/providers/provider_connection_row.dart b/apps/phone_flutter/lib/providers/provider_connection_row.dart index 80500f1..9c26037 100644 --- a/apps/phone_flutter/lib/providers/provider_connection_row.dart +++ b/apps/phone_flutter/lib/providers/provider_connection_row.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -/// Shared Settings row for a plan or platform connection. +/// Shared connection-catalog row for a plan or platform connection. class ProviderConnectionRow extends StatelessWidget { const ProviderConnectionRow({ super.key, @@ -29,3 +29,16 @@ class ProviderConnectionRow extends StatelessWidget { ); } } + +/// Spinner sized for a catalog row trailing. +class RowProgress extends StatelessWidget { + const RowProgress(); + + @override + Widget build(BuildContext context) { + return const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ); + } +} diff --git a/apps/phone_flutter/lib/providers/provider_detail_screen.dart b/apps/phone_flutter/lib/providers/provider_detail_screen.dart deleted file mode 100644 index 3ec935a..0000000 --- a/apps/phone_flutter/lib/providers/provider_detail_screen.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../charts/usage_history_chart.dart'; -import '../dashboard/dashboard_models.dart'; -import '../dashboard/dashboard_screen.dart'; -import '../dashboard/provider_status_color.dart'; -import '../settings/consumption_display_preferences.dart'; - -class ProviderDetailScreen extends StatelessWidget { - const ProviderDetailScreen({ - super.key, - required this.account, - this.displayPreferences = const ConsumptionDisplayPreferences(), - this.syncTooltip, - this.platformLabel, - }); - - final ProviderSnapshot account; - final ConsumptionDisplayPreferences displayPreferences; - final String? syncTooltip; - final String? platformLabel; - - @override - Widget build(BuildContext context) { - final allowances = account.allowances - .where((allowance) => displayPreferences.allows(allowance.source)) - .toList(growable: false); - final title = account.displayTitle(platformLabel: platformLabel); - final accent = providerFamilyColor(account.provider); - - return Scaffold( - appBar: AppBar(title: Text(title)), - body: SafeArea( - child: ListView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), - children: [ - Card( - child: ListTile( - leading: const Icon(Icons.account_circle_outlined), - title: Text(title), - subtitle: switch (account.lastSuccessfulSyncAt) { - null => Text(account.accountId), - final syncedAt => Tooltip( - message: formatUtc(syncedAt), - child: Text( - '${account.accountId} · Synced ${formatLocal(syncedAt)}', - ), - ), - }, - trailing: StatusPill( - status: account.status, - tooltip: syncTooltip, - ), - ), - ), - const SizedBox(height: 16), - if (account.allowances.isEmpty) ...[ - BudgetSummaryCard(title: 'Today', state: account.today), - const SizedBox(height: 12), - BudgetSummaryCard(title: 'Week', state: account.week), - const SizedBox(height: 12), - BudgetSummaryCard(title: 'Month', state: account.month), - ] else if (allowances.isEmpty) - const EmptyAllowanceCard() - else - for (final allowance in allowances) ...[ - AllowanceSummaryCard(allowance: allowance, accent: accent), - if (allowance != allowances.last) const SizedBox(height: 12), - ], - const SizedBox(height: 16), - UsageHistoryChart(buckets: account.buckets, accent: accent), - const SizedBox(height: 16), - Text('Models', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - for (final model in account.modelBreakdown) - Card( - margin: const EdgeInsets.only(bottom: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - child: ListTile( - leading: const Icon(Icons.memory), - title: Text(model.model), - subtitle: Text('${formatCount(model.requests)} requests'), - trailing: model.cost == null ? null : Text(model.cost!.label), - ), - ), - ], - ), - ), - ); - } -} diff --git a/apps/phone_flutter/lib/providers/providers_screen.dart b/apps/phone_flutter/lib/providers/providers_screen.dart index d30d964..5e13940 100644 --- a/apps/phone_flutter/lib/providers/providers_screen.dart +++ b/apps/phone_flutter/lib/providers/providers_screen.dart @@ -1,73 +1,409 @@ import 'package:flutter/material.dart'; +import '../app/pull_to_refresh_list.dart'; +import '../app/surface_order.dart'; import '../dashboard/dashboard_models.dart'; -import '../dashboard/dashboard_screen.dart'; -import '../settings/consumption_display_preferences.dart'; -import 'provider_detail_screen.dart'; +import '../settings/alert_threshold_preferences.dart'; +import 'alert_threshold_dialogs.dart'; +import 'claude_account_row.dart'; +import 'claude_account_service.dart'; +import 'codex_account_row.dart'; +import 'codex_account_service.dart'; +import 'credential_dialog.dart'; +import 'cursor_plan_row.dart'; +import 'provider_connection.dart'; +import 'provider_connection_row.dart'; +import 'provider_credential_store.dart'; -class ProvidersScreen extends StatelessWidget { +/// Connection hub: plan/platform catalog, credentials, and auth flows. +class ProvidersScreen extends StatefulWidget { const ProvidersScreen({ super.key, - required this.snapshot, - this.displayPreferences = const ConsumptionDisplayPreferences(), - this.platformLabels = const {}, + required this.credentialStore, + required this.codexAccountService, + required this.claudeAccountService, + required this.onCredentialsChanged, + required this.alertThresholds, + required this.onAlertThresholdsChanged, + this.cursorPlanSignIn, + this.statuses = const {}, + required this.onRefresh, }); - final DashboardSnapshot snapshot; - final ConsumptionDisplayPreferences displayPreferences; - final Map platformLabels; + final ProviderCredentialStore credentialStore; + final CodexAccountService codexAccountService; + final ClaudeAccountService claudeAccountService; + final VoidCallback onCredentialsChanged; + final AlertThresholdPreferences alertThresholds; + final Future Function( + AlertThresholdPreferences Function(AlertThresholdPreferences current) + update, + ) + onAlertThresholdsChanged; + + /// Test seam; defaults to [CursorPlanSignInScreen.open]. + final CursorPlanSignIn? cursorPlanSignIn; + + /// What each family is reporting, worst account first. Only the order reads + /// it: a tab for managing connections leads with the one asking to be + /// managed. Empty until a snapshot lands. + final Map statuses; + + /// Pull-to-refresh reload, shared with the app-bar action. + final Future Function() onRefresh; @override - Widget build(BuildContext context) { - if (snapshot.accounts.isEmpty) { - return ListView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), - children: [ - Card( - child: const Padding( - padding: EdgeInsets.all(16), - child: Text('No providers'), + State createState() => _ProvidersScreenState(); +} + +class _ProvidersScreenState extends State { + final Map _hasSecret = {}; + final Map _labels = {}; + + /// Connections authorized by a pasted secret, in catalog order. + static final _secretConnections = providerConnectionCatalog() + .where((connection) => connection.secretHint != null) + .map((connection) => connection.id) + .toList(growable: false); + + @override + void initState() { + super.initState(); + _loadCredentialState(); + } + + Future _loadCredentialState() async { + try { + final secrets = {}; + final labels = {}; + for (final id in _secretConnections) { + secrets[id.storageKey] = + await widget.credentialStore.readSecret(id) != null; + labels[id.storageKey] = await widget.credentialStore.readLabel(id); + } + if (mounted) { + setState(() { + _hasSecret + ..clear() + ..addAll(secrets); + _labels + ..clear() + ..addAll(labels); + }); + } + } catch (_) { + if (mounted) { + setState(() { + for (final id in _secretConnections) { + _hasSecret[id.storageKey] = false; + _labels[id.storageKey] = null; + } + }); + } + } + } + + Future _editSecret( + ProviderConnectionId id, { + required bool allowLabel, + required String title, + required String hint, + }) async { + final key = id.storageKey; + final change = await showDialog( + context: context, + builder: + (context) => CredentialDialog( + title: title, + hint: hint, + allowLabel: allowLabel, + hasCredential: _hasSecret[key] ?? false, + initialLabel: _labels[key], + ), + ); + if (change == null) { + return; + } + + try { + if (change.remove) { + await widget.credentialStore.deleteSecret(id); + } else if (change.updateLabelOnly) { + await widget.credentialStore.writeLabel(id, change.label); + } else { + await widget.credentialStore.writeSecret(id, change.value!); + if (allowLabel) { + await widget.credentialStore.writeLabel(id, change.label); + } + } + if (!mounted) { + return; + } + setState(() { + _hasSecret[key] = !change.remove; + _labels[key] = change.remove ? null : change.label; + }); + widget.onCredentialsChanged(); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not update credential')), + ); + } + } + } + + void _showMessage(String message) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + Future _editConnectionAlerts(ProviderConnection connection) async { + final saved = await showDialog( + context: context, + builder: + (context) => ConnectionAlertsDialog( + connectionTitle: connection.listTitle, + kind: connection.id.kind, + thresholds: widget.alertThresholds.forConnection(connection.id), + ), + ); + if (saved == null || !mounted) { + return; + } + await _saveAlertThresholds( + (current) => current.withConnection(connection.id, saved), + ); + } + + Future _editConnectionBudget(ProviderConnection connection) async { + final saved = await showDialog( + context: context, + builder: + (context) => ConnectionBudgetDialog( + connectionTitle: connection.listTitle, + budget: widget.alertThresholds.forConnection(connection.id).budget, + ), + ); + if (saved == null || !mounted) { + return; + } + await _saveAlertThresholds( + (current) => current.withConnection( + connection.id, + current.forConnection(connection.id).copyWith(budget: saved), + ), + ); + } + + Future _saveAlertThresholds( + AlertThresholdPreferences Function(AlertThresholdPreferences current) edit, + ) async { + try { + await widget.onAlertThresholdsChanged(edit); + } catch (_) { + if (mounted) { + _showMessage('Could not update alert thresholds'); + } + } + } + + /// Binds [_statusTrailing] to one connection so extracted rows can reuse it. + ConnectionTrailingBuilder _trailingBuilderFor(ProviderConnection connection) { + return ({List leading = const [], required Widget status}) => + _statusTrailing( + connection: connection, + leading: leading, + status: status, + ); + } + + Widget _statusTrailing({ + required ProviderConnection connection, + required Widget status, + List leading = const [], + }) { + final settings = widget.alertThresholds.forConnection(connection.id); + // Only a connection that reports spend can be held to a limit. + final offersBudget = connection.id.kind == ConnectionKind.platform; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + ...leading, + if (offersBudget) + IconButton( + tooltip: 'Budget limits', + onPressed: () => _editConnectionBudget(connection), + icon: Icon( + settings.budget.isEmpty ? Icons.savings_outlined : Icons.savings, ), ), - ], + IconButton( + tooltip: 'Alert thresholds', + onPressed: () => _editConnectionAlerts(connection), + icon: Icon( + settings.hasAlertRules + ? Icons.notifications_active_outlined + : Icons.notifications_none_outlined, + ), + ), + status, + ], + ); + } + + Widget _connectionRow(ProviderConnection connection) { + if (connection.id == ProviderConnections.codexPlan) { + return CodexAccountRow( + connection: connection, + accountService: widget.codexAccountService, + onCredentialsChanged: widget.onCredentialsChanged, + buildTrailing: _trailingBuilderFor(connection), + ); + } + if (connection.id == ProviderConnections.claudePlan) { + return ClaudeAccountRow( + connection: connection, + accountService: widget.claudeAccountService, + credentialStore: widget.credentialStore, + onCredentialsChanged: widget.onCredentialsChanged, + onPastedSecretCleared: + () => setState(() { + _hasSecret[ProviderConnections.claudePlan.storageKey] = false; + }), + buildTrailing: _trailingBuilderFor(connection), ); } + if (connection.id == ProviderConnections.cursorPlan) { + return CursorPlanRow( + connection: connection, + credentialStore: widget.credentialStore, + onCredentialsChanged: widget.onCredentialsChanged, + signIn: widget.cursorPlanSignIn, + buildTrailing: _trailingBuilderFor(connection), + ); + } + + final hint = connection.secretHint; + if (hint == null) { + return const SizedBox.shrink(); + } + + final hasSecret = _hasSecret[connection.id.storageKey]; + return ProviderConnectionRow( + icon: + connection.id.kind == ConnectionKind.plan + ? Icons.account_circle_outlined + : Icons.key_outlined, + title: connection.listTitle, + subtitle: connection.listSubtitle, + trailing: _statusTrailing( + connection: connection, + status: switch (hasSecret) { + null => const RowProgress(), + // Match OAuth row copy: connection state, not "field empty/filled". + true => const Text('Connected'), + false => const Text('Not connected'), + }, + ), + onTap: + hasSecret == null + ? null + : () => _editSecret( + connection.id, + allowLabel: connection.id.kind == ConnectionKind.platform, + title: + '${providerFamilyLabel(connection.id.provider)} · ' + '${connection.title}', + hint: hint, + ), + ); + } + + /// Families in the order this tab declares: the ones asking for attention + /// first, the ones with no credential last, ties broken by a stable name. + /// + /// Rows inside a card keep their plan-then-platform layout — that pairing is + /// how the card reads, not an order to sort. + int _compareFamilies(ProviderFamily left, ProviderFamily right) { + final connectedCmp = (_isConnected(right) ? 1 : 0).compareTo( + _isConnected(left) ? 1 : 0, + ); + if (connectedCmp != 0) { + return connectedCmp; + } + final statusCmp = compareByStatus(_statusOf(left), _statusOf(right)); + if (statusCmp != 0) { + return statusCmp; + } + // The enum name, not the label: renaming OpenAI must not move its card. + return left.name.compareTo(right.name); + } - return ListView.separated( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), - itemCount: snapshot.accounts.length, - separatorBuilder: (context, index) => const SizedBox(height: 12), - itemBuilder: (context, index) { - final account = snapshot.accounts[index]; - final title = account.displayTitle( - platformLabel: platformLabels[account.accountId], + /// Reporting accounts, or a stored secret no poll has used yet. + /// + /// A subscription signed in through its account service never reaches + /// [_hasSecret] — only pasted secrets do — but it does reach the snapshot, + /// and a family with accounts in the snapshot is a family that is connected. + bool _isConnected(ProviderFamily family) { + return widget.statuses.containsKey(family) || + _hasSecret.entries.any( + (entry) => + entry.value && + ProviderConnectionId.fromStorageKey(entry.key)?.provider == + family, ); + } + + ProviderStatus _statusOf(ProviderFamily family) { + return widget.statuses[family] ?? ProviderStatus.unknown; + } - return Card( - child: ListTile( - leading: const Icon(Icons.hub), - title: Text(title), - subtitle: Text(account.accountId), - trailing: StatusPill( - status: account.status, - tooltip: snapshot.syncTooltip, + @override + Widget build(BuildContext context) { + final catalog = providerConnectionCatalog(platformLabels: _labels); + final families = ProviderFamily.values.toList()..sort(_compareFamilies); + + return PullToRefreshList( + onRefresh: widget.onRefresh, + children: [ + for (final provider in families) ...[ + Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ProviderSectionHeader(title: providerFamilyLabel(provider)), + for (final connection in catalog.where( + (entry) => entry.id.provider == provider, + )) ...[ + if (connection.id.kind == ConnectionKind.platform) + const Divider(height: 1), + _connectionRow(connection), + ], + ], ), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: - (context) => ProviderDetailScreen( - account: account, - displayPreferences: displayPreferences, - syncTooltip: snapshot.syncTooltip, - platformLabel: platformLabels[account.accountId], - ), - ), - ); - }, ), - ); - }, + const SizedBox(height: 16), + ], + ], + ); + } +} + +class _ProviderSectionHeader extends StatelessWidget { + const _ProviderSectionHeader({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 6), + child: Text(title, style: Theme.of(context).textTheme.titleMedium), ); } } + +/// Compact preview of what the next watch payload would carry as rings. diff --git a/apps/phone_flutter/lib/settings/alert_percent_threshold_editor.dart b/apps/phone_flutter/lib/settings/alert_percent_threshold_editor.dart new file mode 100644 index 0000000..85b7982 --- /dev/null +++ b/apps/phone_flutter/lib/settings/alert_percent_threshold_editor.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +import 'alert_threshold_preferences.dart'; + +/// Compact editor for an opt-in threshold in **remaining %** language. +/// +/// Persists used% via [AlertPercentThreshold] for Rust evaluation. +class AlertPercentThresholdEditor extends StatelessWidget { + const AlertPercentThresholdEditor({ + super.key, + this.title, + required this.value, + required this.onChanged, + this.disabledReason, + }); + + /// Heading above the control; omit when the caller already labels the group. + final String? title; + final AlertPercentThreshold value; + final ValueChanged onChanged; + + /// Non-null greys out the control and says why the rule cannot fire. + final String? disabledReason; + + @override + Widget build(BuildContext context) { + final remaining = alertUsedToRemaining(value.at); + final enabled = disabledReason == null; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (title != null) ...[ + Text(title!, style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + ], + InputDecorator( + decoration: InputDecoration( + labelText: 'Alert when', + border: const OutlineInputBorder(), + isDense: true, + enabled: enabled, + helperText: disabledReason, + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: remaining, + items: [ + for (final stop in alertRemainingStops) + DropdownMenuItem( + value: stop, + child: Text(stop == null ? 'Off' : '$stop% left'), + ), + ], + onChanged: + enabled + ? (next) => onChanged( + AlertPercentThreshold(at: alertRemainingToUsed(next)), + ) + : null, + ), + ), + ), + ], + ); + } +} diff --git a/apps/phone_flutter/lib/settings/alert_threshold_preferences.dart b/apps/phone_flutter/lib/settings/alert_threshold_preferences.dart new file mode 100644 index 0000000..d391458 --- /dev/null +++ b/apps/phone_flutter/lib/settings/alert_threshold_preferences.dart @@ -0,0 +1,292 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '../providers/provider_connection.dart'; + +/// Discrete remaining-% stops shown in the UI (null = off). +/// +/// Maps to used% for storage/Rust: `100 - remaining` → 50/70/80/90/100. +const alertRemainingStops = [null, 50, 30, 20, 10, 0]; + +/// UI remaining → stored used percent. +int? alertRemainingToUsed(int? remainingLeft) { + if (remainingLeft == null) { + return null; + } + return 100 - remainingLeft; +} + +/// Stored used percent → UI remaining (null if not a known stop). +int? alertUsedToRemaining(int? used) { + if (used == null) { + return null; + } + final remaining = 100 - used; + return alertRemainingStops.contains(remaining) ? remaining : null; +} + +/// Opt-in used-% threshold for Rust. Null = off. +final class AlertPercentThreshold { + const AlertPercentThreshold({this.at}); + + final int? at; + + bool get isEnabled => at != null; + + Map toJson() => {if (at != null) 'at': at}; + + static AlertPercentThreshold fromJson(Object? json) { + if (json is! Map) { + return const AlertPercentThreshold(); + } + // Prefer `at`; accept legacy warnAt / criticalAt from older installs. + return AlertPercentThreshold( + at: _clampPercent(json['at'] ?? json['warnAt'] ?? json['criticalAt']), + ); + } +} + +/// User-entered spend limits in minor units, one per budget period. +/// +/// Organization keys report spend but no budget, so a percentage is only +/// computable once the user sets a limit. +final class ConnectionBudget { + const ConnectionBudget({this.today, this.week, this.month}); + + final int? today; + final int? week; + final int? month; + + bool get isEmpty => today == null && week == null && month == null; + + Map toJson() => { + if (today != null) 'today': today, + if (week != null) 'week': week, + if (month != null) 'month': month, + }; + + static ConnectionBudget fromJson(Object? json) { + if (json is! Map) { + return const ConnectionBudget(); + } + int? read(Object? value) => value is int && value > 0 ? value : null; + return ConnectionBudget( + today: read(json['today']), + week: read(json['week']), + month: read(json['month']), + ); + } +} + +/// Connection-scoped rules: plan windows, purchased meters, spend budgets. +/// +/// Budgets are per connection so a threshold on one organization key never +/// fires because a different provider spent money. +final class ConnectionAlertThresholds { + const ConnectionAlertThresholds({ + this.plan = const AlertPercentThreshold(), + this.purchased = const AlertPercentThreshold(), + this.today = const AlertPercentThreshold(), + this.week = const AlertPercentThreshold(), + this.month = const AlertPercentThreshold(), + this.budget = const ConnectionBudget(), + }); + + final AlertPercentThreshold plan; + final AlertPercentThreshold purchased; + final AlertPercentThreshold today; + final AlertPercentThreshold week; + final AlertPercentThreshold month; + final ConnectionBudget budget; + + /// True when a percent rule can fire. A budget alone is a ceiling, not a rule. + bool get hasAlertRules => + plan.isEnabled || + purchased.isEnabled || + today.isEnabled || + week.isEnabled || + month.isEnabled; + + /// Worth storing: a bare budget still reaches Rust and shapes the dashboard. + bool get isEnabled => hasAlertRules || !budget.isEmpty; + + ConnectionAlertThresholds copyWith({ + AlertPercentThreshold? plan, + AlertPercentThreshold? purchased, + AlertPercentThreshold? today, + AlertPercentThreshold? week, + AlertPercentThreshold? month, + ConnectionBudget? budget, + }) { + return ConnectionAlertThresholds( + plan: plan ?? this.plan, + purchased: purchased ?? this.purchased, + today: today ?? this.today, + week: week ?? this.week, + month: month ?? this.month, + budget: budget ?? this.budget, + ); + } + + Map toJson() => { + 'plan': plan.toJson(), + 'purchased': purchased.toJson(), + 'today': today.toJson(), + 'week': week.toJson(), + 'month': month.toJson(), + 'budget': budget.toJson(), + }; + + static ConnectionAlertThresholds fromJson(Object? json) { + if (json is! Map) { + return const ConnectionAlertThresholds(); + } + return ConnectionAlertThresholds( + plan: AlertPercentThreshold.fromJson(json['plan']), + purchased: AlertPercentThreshold.fromJson(json['purchased']), + today: AlertPercentThreshold.fromJson(json['today']), + week: AlertPercentThreshold.fromJson(json['week']), + month: AlertPercentThreshold.fromJson(json['month']), + budget: ConnectionBudget.fromJson(json['budget']), + ); + } +} + +/// Phone-local alert rules, one set per connection. +/// +/// Defaults are all off (opt-in). Snapshot evaluation stays in Rust. +final class AlertThresholdPreferences { + const AlertThresholdPreferences({this.connections = const {}}); + + /// Keyed by [ProviderConnectionId.storageKey]. + final Map connections; + + bool get hasEnabledRules => + connections.values.any((value) => value.isEnabled); + + ConnectionAlertThresholds forConnection(ProviderConnectionId id) { + return connections[id.storageKey] ?? const ConnectionAlertThresholds(); + } + + AlertThresholdPreferences withConnection( + ProviderConnectionId id, + ConnectionAlertThresholds value, + ) { + final next = Map.of(connections); + if (value.isEnabled) { + next[id.storageKey] = value; + } else { + next.remove(id.storageKey); + } + return copyWith(connections: next); + } + + AlertThresholdPreferences copyWith({ + Map? connections, + }) { + return AlertThresholdPreferences( + connections: connections ?? this.connections, + ); + } + + Map toJson() => { + 'connections': { + for (final entry in connections.entries) + if (entry.value.isEnabled) entry.key: entry.value.toJson(), + }, + }; + + static AlertThresholdPreferences fromJson(Object? json) { + if (json is! Map) { + return const AlertThresholdPreferences(); + } + final rawConnections = json['connections']; + final connections = {}; + if (rawConnections is Map) { + for (final entry in rawConnections.entries) { + final key = entry.key; + if (key is! String) { + continue; + } + final value = ConnectionAlertThresholds.fromJson(entry.value); + if (value.isEnabled) { + connections[key] = value; + } + } + } + // Budget rules used to be global (top-level today/week/month). They now + // belong to a connection, and an all-providers rule has no equivalent, so + // older installs lose them rather than get them attached to a guess. + return AlertThresholdPreferences( + connections: Map.unmodifiable(connections), + ); + } + + String encode() => jsonEncode(toJson()); + + static AlertThresholdPreferences decode(String? raw) { + if (raw == null || raw.trim().isEmpty) { + return const AlertThresholdPreferences(); + } + try { + return AlertThresholdPreferences.fromJson(jsonDecode(raw)); + } catch (_) { + return const AlertThresholdPreferences(); + } + } +} + +abstract interface class AlertThresholdPreferenceStore { + Future read(); + + Future write(AlertThresholdPreferences value); +} + +final class SecureAlertThresholdPreferenceStore + implements AlertThresholdPreferenceStore { + SecureAlertThresholdPreferenceStore({FlutterSecureStorage? storage}) + : _storage = storage ?? const FlutterSecureStorage(); + + static const _key = 'wardpulse.alerts.thresholds'; + + final FlutterSecureStorage _storage; + + @override + Future read() async { + return AlertThresholdPreferences.decode(await _storage.read(key: _key)); + } + + @override + Future write(AlertThresholdPreferences value) { + return _storage.write(key: _key, value: value.encode()); + } +} + +final class DefaultAlertThresholdPreferenceStore + implements AlertThresholdPreferenceStore { + const DefaultAlertThresholdPreferenceStore(); + + @override + Future read() async => + const AlertThresholdPreferences(); + + @override + Future write(AlertThresholdPreferences value) async {} +} + +int? _clampPercent(Object? value) { + final parsed = switch (value) { + int n => n, + num n => n.round(), + String s => int.tryParse(s), + _ => null, + }; + if (parsed == null) { + return null; + } + if (parsed < 1 || parsed > 100) { + return null; + } + return parsed; +} diff --git a/apps/phone_flutter/lib/settings/consumption_display_preferences.dart b/apps/phone_flutter/lib/settings/consumption_display_preferences.dart deleted file mode 100644 index 4a7b9d5..0000000 --- a/apps/phone_flutter/lib/settings/consumption_display_preferences.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; - -import '../dashboard/dashboard_models.dart'; - -class ConsumptionDisplayPreferences { - const ConsumptionDisplayPreferences({ - this.plan = true, - this.purchased = true, - this.platform = true, - }); - - final bool plan; - final bool purchased; - final bool platform; - - bool get hasVisibleSurface => plan || purchased || platform; - - bool allows(AllowanceSource source) { - return switch (source) { - AllowanceSource.plan => plan, - AllowanceSource.purchased => purchased, - }; - } - - ConsumptionDisplayPreferences copyWith({ - bool? plan, - bool? purchased, - bool? platform, - }) { - return ConsumptionDisplayPreferences( - plan: plan ?? this.plan, - purchased: purchased ?? this.purchased, - platform: platform ?? this.platform, - ); - } -} - -abstract interface class ConsumptionDisplayPreferenceStore { - Future read(); - - Future write(ConsumptionDisplayPreferences value); -} - -final class SecureConsumptionDisplayPreferenceStore - implements ConsumptionDisplayPreferenceStore { - SecureConsumptionDisplayPreferenceStore({FlutterSecureStorage? storage}) - : _storage = storage ?? const FlutterSecureStorage(); - - static const _planKey = 'wardpulse.display.plan'; - static const _purchasedKey = 'wardpulse.display.purchased'; - static const _platformKey = 'wardpulse.display.platform'; - - final FlutterSecureStorage _storage; - - @override - Future read() async { - final values = await Future.wait([ - _storage.read(key: _planKey), - _storage.read(key: _purchasedKey), - _storage.read(key: _platformKey), - ]); - final preferences = ConsumptionDisplayPreferences( - plan: values[0] == null || values[0] == 'true', - purchased: values[1] == null || values[1] == 'true', - platform: values[2] == null || values[2] == 'true', - ); - return preferences.hasVisibleSurface - ? preferences - : const ConsumptionDisplayPreferences(); - } - - @override - Future write(ConsumptionDisplayPreferences value) { - return Future.wait([ - _storage.write(key: _planKey, value: value.plan.toString()), - _storage.write(key: _purchasedKey, value: value.purchased.toString()), - _storage.write(key: _platformKey, value: value.platform.toString()), - ]); - } -} - -final class DefaultConsumptionDisplayPreferenceStore - implements ConsumptionDisplayPreferenceStore { - const DefaultConsumptionDisplayPreferenceStore(); - - @override - Future read() async => - const ConsumptionDisplayPreferences(); - - @override - Future write(ConsumptionDisplayPreferences value) async {} -} diff --git a/apps/phone_flutter/lib/settings/metric_catalog_groups.dart b/apps/phone_flutter/lib/settings/metric_catalog_groups.dart new file mode 100644 index 0000000..e791597 --- /dev/null +++ b/apps/phone_flutter/lib/settings/metric_catalog_groups.dart @@ -0,0 +1,161 @@ +import '../dashboard/dashboard_models.dart'; +import '../providers/provider_connection.dart'; +import 'watch_ring_preferences.dart'; + +/// One connection's rows under a heading: `Codex`, `Anthropic`. +/// +/// `id` is what the group sorts on — the connection's storage key, so a heading +/// can be reworded without moving it. `title` is only what the reader sees. +typedef MetricConnectionGroup = + ({String id, String title, List metrics}); + +/// The picker read as an outline: a kind of connection, its connections, and +/// their metrics in catalog order. +typedef MetricGroup = ({String title, List connections}); + +/// What kind of connection a row belongs to, in the order the pickers show — +/// declared here, not inherited from whatever order the catalog was built in. A +/// subscription is what a reader came to check, a billing key is what they came +/// to watch, and an unknown provider is neither. +enum MetricKind { + plan('Plan'), + platform('Platform'), + other('Other'); + + const MetricKind(this.title); + + /// Heading text for this kind. + final String title; +} + +/// Groups a metric catalog for the pickers. +/// +/// A flat list of `Family · Metric` rows reads as noise once a few providers +/// are connected: the family repeats on every line and the eye has to find the +/// boundaries itself. The rows already carry them — an allowance belongs to a +/// plan connection, a budget names its own — so the pickers just show them. +List groupMetricCatalog(List catalog) { + final order = >{}; + for (final metric in catalog) { + final connection = metricConnectionOf(metric.id); + order + .putIfAbsent(connection.kind, () => {}) + .putIfAbsent( + connection.id, + () => (id: connection.id, title: connection.title, metrics: []), + ) + .metrics + .add(metric); + } + return [ + for (final kind in MetricKind.values) + if (order[kind] case final connections?) + ( + title: kind.title, + connections: connections.values.toList()..sort(_compareConnections), + ), + ]; +} + +/// What the reader can put on a surface comes first, then a stable id. +/// +/// The id, not the heading: sorting on the label would let a copy edit reorder +/// the picker, and the ids sort by provider (`anthropic.plan` before +/// `openai.plan`), which is not the alphabet the headings read in. +int _compareConnections( + MetricConnectionGroup left, + MetricConnectionGroup right, +) { + final selectableCmp = (_isSelectable(right) ? 1 : 0).compareTo( + _isSelectable(left) ? 1 : 0, + ); + if (selectableCmp != 0) { + return selectableCmp; + } + return left.id.compareTo(right.id); +} + +/// Whether anything under this connection can go on a ring at all. Rows that +/// cannot stay listed and disabled, so the connection sinks rather than hides. +bool _isSelectable(MetricConnectionGroup group) { + return group.metrics.any((metric) => metric.isAvailable); +} + +/// Which heading and sub-heading a metric belongs under. +/// +/// Both kinds of row resolve to the same connection: an allowance names its +/// provider (`allowance.codex.…`), a budget names the connection itself +/// (`budget.openai.plan.…`), and those are one and the same subscription. They +/// are listed together, or a reader sees one connection twice under two names. +({MetricKind kind, String id, String title}) metricConnectionOf( + String metricId, +) { + final provider = providerFromRingId(metricId); + final connection = + connectionFromBudgetRingId(metricId) ?? + _connectionOfAllowanceProvider(provider); + if (connection == null) { + // A provider this build does not know yet: name it, and do not file it + // under a kind of connection nobody established it has. There is no + // connection id to sort on either, so the provider itself is the id. + return ( + kind: MetricKind.other, + id: provider ?? '', + title: provider == null ? 'Other' : providerDisplayLabel(provider), + ); + } + return ( + kind: switch (connection.kind) { + ConnectionKind.plan => MetricKind.plan, + ConnectionKind.platform => MetricKind.platform, + }, + id: connection.storageKey, + title: _connectionHeading(connection), + ); +} + +/// The connection an allowance belongs to, read off the provider its account +/// speaks for: `codex` is the OpenAI subscription, `openai` the reporting key +/// beside it. Today only subscriptions report allowances, but the id is built +/// from whatever provider the account carries, so both are mapped. +/// +/// Reads the same provider strings as `providerFamilyOf`, one step finer — a +/// connection rather than a family. A new provider belongs in both. +ProviderConnectionId? _connectionOfAllowanceProvider(String? provider) { + return switch (provider) { + 'codex' => ProviderConnections.codexPlan, + 'openai' => ProviderConnections.openAiPlatform, + 'claude' => ProviderConnections.claudePlan, + 'anthropic' => ProviderConnections.anthropicPlatform, + 'cursor' => ProviderConnections.cursorPlan, + _ => null, + }; +} + +/// The heading above already says plan or platform, so the connection keeps the +/// name the product uses for it: the subscription by its product name (`Codex`, +/// not `OpenAI plan`), the reporting key by its family. +String _connectionHeading(ProviderConnectionId connection) { + return switch (connection.kind) { + ConnectionKind.platform => providerFamilyLabel(connection.provider), + ConnectionKind.plan => switch (connection.provider) { + ProviderFamily.openai => 'Codex', + ProviderFamily.anthropic => 'Claude', + ProviderFamily.cursor => 'Cursor', + }, + }; +} + +/// What a row is called under its connection heading. +/// +/// The heading names the connection, so the row keeps only what tells it from +/// its siblings: `Anthropic platform · Today` becomes `Today`, `Codex · Weekly +/// plan` becomes `Weekly plan`. A title with no separator stays whole — the +/// collapsed Claude slot reads `Claude plan`, and shortening it to its current +/// window would name a window the slot does not promise to keep. +String metricRowTitle(WatchRingMetric metric) { + const separator = ' · '; + final title = metric.catalogTitle; + final cut = title.lastIndexOf(separator); + return cut < 0 ? title : title.substring(cut + separator.length); +} diff --git a/apps/phone_flutter/lib/settings/recovery_notification_preferences.dart b/apps/phone_flutter/lib/settings/recovery_notification_preferences.dart new file mode 100644 index 0000000..cabc871 --- /dev/null +++ b/apps/phone_flutter/lib/settings/recovery_notification_preferences.dart @@ -0,0 +1,50 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Whether WardPulse may say a plan window is usable again. +/// +/// One switch for the whole product, not a rule per connection: a recovery has +/// no threshold to set — it either happened or it did not. +abstract interface class RecoveryNotificationPreferenceStore { + Future read(); + + Future write(bool enabled); +} + +final class SecureRecoveryNotificationPreferenceStore + implements RecoveryNotificationPreferenceStore { + SecureRecoveryNotificationPreferenceStore({FlutterSecureStorage? storage}) + : _storage = storage ?? const FlutterSecureStorage(); + + static const _key = 'wardpulse.recovery.notify'; + + final FlutterSecureStorage _storage; + + @override + Future read() async => + recoveryNotificationsFrom(await _storage.read(key: _key)); + + @override + Future write(bool enabled) { + return _storage.write(key: _key, value: enabled.toString()); + } +} + +/// Reads on and remembers nothing, for hosts with no storage: tests, and builds +/// where a recovery has nowhere to arrive anyway. +final class DefaultRecoveryNotificationPreferenceStore + implements RecoveryNotificationPreferenceStore { + const DefaultRecoveryNotificationPreferenceStore(); + + @override + Future read() async => true; + + @override + Future write(bool enabled) async {} +} + +/// Reads the stored switch, defaulting to on. +/// +/// On until told otherwise: interrupting is the point of the feature, an unset +/// store means the reader has not opinionated yet, and Android still gates the +/// post behind its own permission. +bool recoveryNotificationsFrom(String? stored) => stored != 'false'; diff --git a/apps/phone_flutter/lib/settings/settings_screen.dart b/apps/phone_flutter/lib/settings/settings_screen.dart index 0b4090f..2077c5f 100644 --- a/apps/phone_flutter/lib/settings/settings_screen.dart +++ b/apps/phone_flutter/lib/settings/settings_screen.dart @@ -1,536 +1,63 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:url_launcher/url_launcher.dart'; +import '../app/pull_to_refresh_list.dart'; import '../dashboard/dashboard_models.dart'; -import '../dashboard/dashboard_screen.dart'; -import '../providers/claude_account_service.dart'; -import '../providers/codex_account_service.dart'; -import '../providers/cursor_plan_sign_in_screen.dart'; -import '../providers/cursor_session_token.dart'; -import '../providers/cursor_webview_cookies.dart'; -import '../providers/provider_connection.dart'; -import '../providers/provider_connection_row.dart'; -import '../providers/provider_credential_store.dart'; -import '../sync/claude_account_client.dart'; -import '../sync/codex_account_client.dart'; +import '../dashboard/status_pill.dart'; import '../sync/poll_cadence.dart'; -import 'consumption_display_preferences.dart'; import 'refresh_interval_preferences.dart'; import 'watch_ring_preferences.dart'; -/// Opens Cursor dashboard sign-in and returns a session token, or null. -typedef CursorPlanSignIn = Future Function(BuildContext context); - +/// Systemic phone settings: poll cadence, diagnostics, and debug toggles. +/// Not connections, credentials, Watchface/Widget layout, or alert thresholds +/// (those live on Providers). class SettingsScreen extends StatefulWidget { const SettingsScreen({ super.key, required this.snapshot, - required this.credentialStore, - required this.codexAccountService, - required this.claudeAccountService, - required this.displayPreferences, - required this.onDisplayPreferencesChanged, required this.refreshInterval, required this.onRefreshIntervalChanged, required this.ringPreferences, - required this.onRingPreferencesChanged, required this.onSyncWatch, + required this.recoveryNotificationsEnabled, + required this.onRecoveryNotificationsChanged, required this.debugDataAvailable, required this.mockDataEnabled, required this.onMockDataEnabledChanged, - required this.onCredentialsChanged, - this.cursorPlanSignIn, + required this.onRefresh, }); final DashboardSnapshot? snapshot; - final ProviderCredentialStore credentialStore; - final CodexAccountService codexAccountService; - final ClaudeAccountService claudeAccountService; - final ConsumptionDisplayPreferences displayPreferences; - final Future Function(ConsumptionDisplayPreferences value) - onDisplayPreferencesChanged; + + /// Whether a plan window coming back may interrupt the reader. + final bool recoveryNotificationsEnabled; + + /// Turning it on is also where Android is asked for the permission: only the + /// foreground can ask, and the poll that posts runs without an Activity. + /// Answers whether posting is allowed now, which the switch alone cannot say. + final Future Function(bool enabled) onRecoveryNotificationsChanged; final RefreshIntervalPreference refreshInterval; final Future Function(RefreshIntervalPreference value) onRefreshIntervalChanged; final WatchRingPreferences ringPreferences; - final Future Function(WatchRingPreferences value) - onRingPreferencesChanged; final Future Function() onSyncWatch; final bool debugDataAvailable; final bool mockDataEnabled; final Future Function(bool value) onMockDataEnabledChanged; - final VoidCallback onCredentialsChanged; - /// Test seam; defaults to [CursorPlanSignInScreen.open]. - final CursorPlanSignIn? cursorPlanSignIn; + /// Pull-to-refresh reload, shared with the app-bar action. + final Future Function() onRefresh; @override State createState() => _SettingsScreenState(); } class _SettingsScreenState extends State { - final Map _hasSecret = {}; - final Map _labels = {}; - bool? _hasCodexAccount; - bool? _hasClaudeAccount; - bool? _hasCursorPlan; - bool _isConnectingCodex = false; - bool _isConnectingClaude = false; - bool _isConnectingCursorPlan = false; bool _isSyncing = false; String? _syncResult; int? _dragRefreshMinutes; - /// Connections authorized by a pasted secret, in catalog order. - static final _secretConnections = providerConnectionCatalog() - .where((connection) => connection.secretHint != null) - .map((connection) => connection.id) - .toList(growable: false); - - @override - void initState() { - super.initState(); - _loadCredentialState(); - _loadCodexAccountState(); - _loadClaudeAccountState(); - _loadCursorPlanState(); - } - - Future _loadCodexAccountState() async { - try { - final value = await widget.codexAccountService.isConnected(); - if (mounted) { - setState(() { - _hasCodexAccount = value; - }); - } - } catch (_) { - if (mounted) { - setState(() { - _hasCodexAccount = false; - }); - } - } - } - - Future _loadClaudeAccountState() async { - try { - final value = await widget.claudeAccountService.isConnected(); - if (mounted) { - setState(() { - _hasClaudeAccount = value; - }); - } - } catch (_) { - if (mounted) { - setState(() { - _hasClaudeAccount = false; - }); - } - } - } - - Future _loadCursorPlanState() async { - try { - final value = - await widget.credentialStore.readSecret( - ProviderConnections.cursorPlan, - ) != - null; - if (mounted) { - setState(() { - _hasCursorPlan = value; - }); - } - } catch (_) { - if (mounted) { - setState(() { - _hasCursorPlan = false; - }); - } - } - } - - Future _loadCredentialState() async { - try { - final secrets = {}; - final labels = {}; - for (final id in _secretConnections) { - secrets[id.storageKey] = - await widget.credentialStore.readSecret(id) != null; - labels[id.storageKey] = await widget.credentialStore.readLabel(id); - } - if (mounted) { - setState(() { - _hasSecret - ..clear() - ..addAll(secrets); - _labels - ..clear() - ..addAll(labels); - }); - } - } catch (_) { - if (mounted) { - setState(() { - for (final id in _secretConnections) { - _hasSecret[id.storageKey] = false; - _labels[id.storageKey] = null; - } - }); - } - } - } - - Future _editSecret( - ProviderConnectionId id, { - required bool allowLabel, - required String title, - required String hint, - }) async { - final key = id.storageKey; - final change = await showDialog<_CredentialChange>( - context: context, - builder: - (context) => _CredentialDialog( - title: title, - hint: hint, - allowLabel: allowLabel, - hasCredential: _hasSecret[key] ?? false, - initialLabel: _labels[key], - ), - ); - if (change == null) { - return; - } - - try { - if (change.remove) { - await widget.credentialStore.deleteSecret(id); - } else if (change.updateLabelOnly) { - await widget.credentialStore.writeLabel(id, change.label); - } else { - await widget.credentialStore.writeSecret(id, change.value!); - if (allowLabel) { - await widget.credentialStore.writeLabel(id, change.label); - } - } - if (!mounted) { - return; - } - setState(() { - _hasSecret[key] = !change.remove; - _labels[key] = change.remove ? null : change.label; - }); - widget.onCredentialsChanged(); - } catch (_) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Could not update credential')), - ); - } - } - } - - Future _editCodexAccount() async { - if (_hasCodexAccount == true) { - final action = await showDialog<_CodexAccountAction>( - context: context, - builder: (context) => const _ConnectedCodexAccountDialog(), - ); - if (action == _CodexAccountAction.disconnect) { - try { - await widget.codexAccountService.disconnect(); - } on CodexAccountException catch (error) { - if (mounted) { - _showMessage(error.details ?? 'Could not disconnect Codex'); - } - return; - } catch (_) { - if (mounted) { - _showMessage('Could not disconnect Codex'); - } - return; - } - if (mounted) { - setState(() { - _hasCodexAccount = false; - }); - widget.onCredentialsChanged(); - } - return; - } - if (action != _CodexAccountAction.reconnect) { - return; - } - } - - setState(() { - _isConnectingCodex = true; - }); - try { - final attempt = await widget.codexAccountService.startLogin(); - if (!mounted) { - attempt.cancel(); - return; - } - final connected = await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => _CodexLoginDialog(attempt: attempt), - ); - if (connected == true && mounted) { - setState(() { - _hasCodexAccount = true; - }); - widget.onCredentialsChanged(); - } - } on CodexAccountException catch (error) { - if (mounted && error.failure != CodexAccountFailure.cancelled) { - _showMessage(error.details ?? 'Could not start Codex sign-in'); - } - } catch (_) { - if (mounted) { - _showMessage('Could not start Codex sign-in'); - } - } finally { - if (mounted) { - setState(() { - _isConnectingCodex = false; - }); - } - } - } - - Future _editClaudeAccount() async { - if (_hasClaudeAccount == true) { - final action = await showDialog<_ClaudeAccountAction>( - context: context, - builder: (context) => const _ConnectedClaudeAccountDialog(), - ); - if (action == _ClaudeAccountAction.disconnect) { - try { - await widget.claudeAccountService.disconnect(); - } on ClaudeAccountException catch (error) { - if (mounted) { - _showMessage(error.details ?? 'Could not disconnect Claude'); - } - return; - } catch (_) { - if (mounted) { - _showMessage('Could not disconnect Claude'); - } - return; - } - if (mounted) { - setState(() { - _hasClaudeAccount = false; - }); - widget.onCredentialsChanged(); - } - return; - } - if (action != _ClaudeAccountAction.reconnect) { - return; - } - } - - setState(() { - _isConnectingClaude = true; - }); - try { - final attempt = await widget.claudeAccountService.startLogin(); - if (!mounted) { - attempt.cancel(); - return; - } - final connected = await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => _ClaudeLoginDialog(attempt: attempt), - ); - if (connected == true && mounted) { - // Drop any pre-OAuth pasted access token for this connection. - try { - await widget.credentialStore.deleteSecret( - ProviderConnections.claudePlan, - ); - } catch (_) { - // Session is already saved; orphaned paste is non-fatal. - } - if (!mounted) { - return; - } - setState(() { - _hasClaudeAccount = true; - _hasSecret[ProviderConnections.claudePlan.storageKey] = false; - }); - widget.onCredentialsChanged(); - } - } on ClaudeAccountException catch (error) { - if (mounted && error.failure != ClaudeAccountFailure.cancelled) { - _showMessage(error.details ?? 'Could not start Claude sign-in'); - } - } catch (_) { - if (mounted) { - _showMessage('Could not start Claude sign-in'); - } - } finally { - if (mounted) { - setState(() { - _isConnectingClaude = false; - }); - } - } - } - - Future _editCursorPlanAccount() async { - if (_hasCursorPlan == true) { - final action = await showDialog<_CursorPlanAccountAction>( - context: context, - builder: (context) => const _ConnectedCursorPlanDialog(), - ); - if (action == _CursorPlanAccountAction.disconnect) { - try { - await widget.credentialStore.deleteSecret( - ProviderConnections.cursorPlan, - ); - final wiped = await wipeCursorWebViewSession(); - if (!wiped && mounted) { - _showMessage( - 'Disconnected, but could not clear the in-app Cursor session', - ); - } - } catch (_) { - if (mounted) { - _showMessage('Could not disconnect Cursor plan'); - } - return; - } - if (mounted) { - setState(() { - _hasCursorPlan = false; - }); - widget.onCredentialsChanged(); - } - return; - } - if (action != _CursorPlanAccountAction.reconnect) { - return; - } - } - - await _runCursorPlanSignIn(); - } - - Future _runCursorPlanSignIn() async { - setState(() { - _isConnectingCursorPlan = true; - }); - try { - final signIn = widget.cursorPlanSignIn ?? CursorPlanSignInScreen.open; - final token = await signIn(context); - if (!mounted) { - return; - } - final normalized = token == null - ? null - : normalizeCursorSessionToken(token); - if (normalized == null) { - return; - } - await widget.credentialStore.writeSecret( - ProviderConnections.cursorPlan, - normalized, - ); - if (!mounted) { - return; - } - setState(() { - _hasCursorPlan = true; - }); - widget.onCredentialsChanged(); - } catch (_) { - if (mounted) { - _showMessage('Could not complete Cursor sign-in'); - } - } finally { - if (mounted) { - setState(() { - _isConnectingCursorPlan = false; - }); - } - } - } - - Future _showCursorPlanHelp() async { - final paste = await showDialog( - context: context, - builder: (context) => const _CursorPlanHelpDialog(), - ); - if (paste == true && mounted) { - await _pasteCursorPlanToken(); - } - } - - Future _pasteCursorPlanToken() async { - const id = ProviderConnections.cursorPlan; - final change = await showDialog<_CredentialChange>( - context: context, - builder: - (context) => _CredentialDialog( - title: 'Cursor plan · Paste token', - hint: cursorSessionCookieName, - allowLabel: false, - hasCredential: _hasCursorPlan ?? false, - ), - ); - if (change == null) { - return; - } - try { - if (change.remove) { - await widget.credentialStore.deleteSecret(id); - final wiped = await wipeCursorWebViewSession(); - if (!wiped && mounted) { - _showMessage( - 'Removed, but could not clear the in-app Cursor session', - ); - } - } else if (change.value != null) { - final token = normalizeCursorSessionToken(change.value!); - if (token == null) { - if (mounted) { - _showMessage('That does not look like a Cursor session token'); - } - return; - } - await widget.credentialStore.writeSecret(id, token); - } else { - return; - } - if (!mounted) { - return; - } - setState(() { - _hasCursorPlan = !change.remove; - }); - widget.onCredentialsChanged(); - } catch (_) { - if (mounted) { - _showMessage('Could not update Cursor plan token'); - } - } - } - - void _showMessage(String message) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); - } - Future _syncWatch() async { if (widget.snapshot == null) { return; @@ -572,24 +99,24 @@ class _SettingsScreenState extends State { } } - Future _setDisplayPreferences( - ConsumptionDisplayPreferences value, - ) async { - if (!value.hasVisibleSurface) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Keep at least one dashboard surface visible'), - ), - ); - return; - } - + Future _setRecoveryNotifications(bool value) async { try { - await widget.onDisplayPreferencesChanged(value); + final granted = await widget.onRecoveryNotificationsChanged(value); + if (value && !granted && mounted) { + // The reader said yes and Android said no; leaving the switch on + // without a word would promise an interruption that cannot arrive. + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Android is blocking WardPulse notifications'), + ), + ); + } } catch (_) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Could not update display settings')), + const SnackBar( + content: Text('Could not update notification setting'), + ), ); } } @@ -622,160 +149,9 @@ class _SettingsScreenState extends State { } } - Future _toggleRing(WatchRingMetric metric, bool selected) async { - if (!metric.isAvailable) { - return; - } - final snapshot = widget.snapshot; - final ids = [ - if (snapshot != null) - for (final ring in resolveWatchRings(snapshot, widget.ringPreferences)) - ring.id - else - ...widget.ringPreferences.migratedIds, - ]; - if (selected) { - if (ids.contains(metric.id) || ids.length >= watchRingSlotCount) { - return; - } - ids.add(metric.id); - } else { - ids.remove(metric.id); - } - try { - await widget.onRingPreferencesChanged( - WatchRingPreferences(selectedIds: ids), - ); - } catch (_) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Could not update watch display')), - ); - } - } - } - - List get _effectiveRingIds { - final snapshot = widget.snapshot; - if (snapshot == null) { - return widget.ringPreferences.migratedIds; - } - return [ - for (final ring in resolveWatchRings(snapshot, widget.ringPreferences)) - ring.id, - ]; - } - - Widget _connectionRow(ProviderConnection connection) { - if (connection.id == ProviderConnections.codexPlan) { - return _codexAccountRow(connection); - } - if (connection.id == ProviderConnections.claudePlan) { - return _claudeAccountRow(connection); - } - if (connection.id == ProviderConnections.cursorPlan) { - return _cursorPlanRow(connection); - } - - final hint = connection.secretHint; - if (hint == null) { - return const SizedBox.shrink(); - } - - final hasSecret = _hasSecret[connection.id.storageKey]; - return ProviderConnectionRow( - icon: - connection.id.kind == ConnectionKind.plan - ? Icons.account_circle_outlined - : Icons.key_outlined, - title: connection.listTitle, - subtitle: connection.listSubtitle, - trailing: switch (hasSecret) { - null => const _RowProgress(), - // Match OAuth row copy: connection state, not "field empty/filled". - true => const Text('Connected'), - false => const Text('Not connected'), - }, - onTap: - hasSecret == null - ? null - : () => _editSecret( - connection.id, - allowLabel: connection.id.kind == ConnectionKind.platform, - title: - '${providerFamilyLabel(connection.id.provider)} · ' - '${connection.title}', - hint: hint, - ), - ); - } - - Widget _codexAccountRow(ProviderConnection connection) { - return ProviderConnectionRow( - icon: Icons.account_circle_outlined, - title: connection.listTitle, - subtitle: connection.listSubtitle, - trailing: switch ((_hasCodexAccount, _isConnectingCodex)) { - (_, true) || (null, _) => const _RowProgress(), - (true, _) => const Text('Connected'), - (false, _) => const Text('Not connected'), - }, - onTap: - _hasCodexAccount == null || _isConnectingCodex - ? null - : _editCodexAccount, - ); - } - - Widget _claudeAccountRow(ProviderConnection connection) { - return ProviderConnectionRow( - icon: Icons.account_circle_outlined, - title: connection.listTitle, - subtitle: connection.listSubtitle, - trailing: switch ((_hasClaudeAccount, _isConnectingClaude)) { - (_, true) || (null, _) => const _RowProgress(), - (true, _) => const Text('Connected'), - (false, _) => const Text('Not connected'), - }, - onTap: - _hasClaudeAccount == null || _isConnectingClaude - ? null - : _editClaudeAccount, - ); - } - - Widget _cursorPlanRow(ProviderConnection connection) { - final status = switch ((_hasCursorPlan, _isConnectingCursorPlan)) { - (_, true) || (null, _) => const _RowProgress(), - (true, _) => const Text('Connected'), - (false, _) => const Text('Not connected'), - }; - return ProviderConnectionRow( - icon: Icons.account_circle_outlined, - title: connection.listTitle, - subtitle: connection.listSubtitle, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - tooltip: 'About Cursor sign-in', - onPressed: _isConnectingCursorPlan ? null : _showCursorPlanHelp, - icon: const Icon(Icons.help_outline), - ), - status, - ], - ), - onTap: - _hasCursorPlan == null || _isConnectingCursorPlan - ? null - : _editCursorPlanAccount, - ); - } - @override Widget build(BuildContext context) { final snapshot = widget.snapshot; - final catalog = providerConnectionCatalog(platformLabels: _labels); final shownRefreshMinutes = PollCadence.clampMinutes( _dragRefreshMinutes ?? widget.refreshInterval.minutes, ); @@ -783,52 +159,10 @@ class _SettingsScreenState extends State { shownRefreshMinutes, ); - return ListView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + return PullToRefreshList( + onRefresh: widget.onRefresh, children: [ - Card( - child: Column( - children: [ - SwitchListTile( - secondary: const Icon(Icons.speed_outlined), - title: const Text('Plan usage'), - subtitle: const Text('Subscription rate limits'), - value: widget.displayPreferences.plan, - onChanged: - (value) => _setDisplayPreferences( - widget.displayPreferences.copyWith(plan: value), - ), - ), - const Divider(height: 1), - SwitchListTile( - secondary: const Icon(Icons.toll_outlined), - title: const Text('Purchased usage'), - subtitle: const Text( - 'Purchased tokens or credits, when the provider reports them', - ), - value: widget.displayPreferences.purchased, - onChanged: - (value) => _setDisplayPreferences( - widget.displayPreferences.copyWith(purchased: value), - ), - ), - const Divider(height: 1), - SwitchListTile( - secondary: const Icon(Icons.payments_outlined), - title: const Text('Platform spend'), - subtitle: const Text( - 'Monetary budgets for today, week, and month when reported', - ), - value: widget.displayPreferences.platform, - onChanged: - (value) => _setDisplayPreferences( - widget.displayPreferences.copyWith(platform: value), - ), - ), - ], - ), - ), - const SizedBox(height: 16), + const _SettingsSectionHeader(title: 'Refresh'), Card( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), @@ -880,62 +214,37 @@ class _SettingsScreenState extends State { ), ), const SizedBox(height: 16), + const _SettingsSectionHeader(title: 'Notifications'), Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const ListTile( - leading: Icon(Icons.watch_outlined), - title: Text('Watch display'), - subtitle: Text( - 'Pick up to $watchRingSlotCount metrics for the watch at ' - 'once · unavailable ones stay off the watch', - ), - ), - for (final metric in watchRingCatalog(widget.snapshot)) ...[ - const Divider(height: 1), - _WatchRingTile( - metric: metric, - selected: _effectiveRingIds.contains(metric.id), - atCapacity: _effectiveRingIds.length >= watchRingSlotCount, - onChanged: (value) => _toggleRing(metric, value), - ), - ], - ], + child: SwitchListTile( + secondary: const Icon(Icons.notifications_active_outlined), + title: const Text('Plan recovery'), + subtitle: const Text( + 'Tells you when a plan window you had run out of is usable again ' + '· the only thing WardPulse interrupts for', + ), + value: widget.recoveryNotificationsEnabled, + onChanged: _setRecoveryNotifications, ), ), const SizedBox(height: 16), if (widget.debugDataAvailable) ...[ + const _SettingsSectionHeader(title: 'Debug'), Card( child: SwitchListTile( secondary: const Icon(Icons.science_outlined), title: const Text('Mock data'), - subtitle: const Text('Debug builds only'), + subtitle: const Text( + 'Debug only · full multi-provider demo; refresh draws a new scenario', + ), value: widget.mockDataEnabled, onChanged: _setMockDataEnabled, ), ), const SizedBox(height: 16), ], - for (final provider in ProviderFamily.values) ...[ - Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _ProviderSectionHeader(title: providerFamilyLabel(provider)), - for (final connection in catalog.where( - (entry) => entry.id.provider == provider, - )) ...[ - if (connection.id.kind == ConnectionKind.platform) - const Divider(height: 1), - _connectionRow(connection), - ], - ], - ), - ), - const SizedBox(height: 16), - ], - if (snapshot != null) + if (snapshot != null) ...[ + const _SettingsSectionHeader(title: 'Diagnostics'), Card( child: Column( children: [ @@ -956,7 +265,7 @@ class _SettingsScreenState extends State { leading: const Icon(Icons.watch_outlined), title: const Text('Watch summary'), subtitle: Text( - _watchSummarySubtitle(snapshot, widget.ringPreferences), + watchRingPayloadSubtitle(snapshot, widget.ringPreferences), ), trailing: StatusPill( status: snapshot.watchSummary.status, @@ -984,698 +293,22 @@ class _SettingsScreenState extends State { ], ), ), + ], ], ); } } -class _WatchRingTile extends StatelessWidget { - const _WatchRingTile({ - required this.metric, - required this.selected, - required this.atCapacity, - required this.onChanged, - }); - - final WatchRingMetric metric; - final bool selected; - final bool atCapacity; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - final canToggle = metric.isAvailable && (selected || !atCapacity); - final reason = metric.unavailableReason; - return CheckboxListTile( - secondary: - reason == null - ? const Icon(Icons.data_usage_outlined) - : Tooltip(message: reason, child: const Icon(Icons.help_outline)), - title: Text(metric.settingsTitle), - subtitle: Text(metric.settingsSubtitle), - value: selected, - onChanged: canToggle ? (value) => onChanged(value ?? false) : null, - ); - } -} - -class _RowProgress extends StatelessWidget { - const _RowProgress(); - - @override - Widget build(BuildContext context) { - return const SizedBox.square( - dimension: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ); - } -} - -class _ProviderSectionHeader extends StatelessWidget { - const _ProviderSectionHeader({required this.title}); +class _SettingsSectionHeader extends StatelessWidget { + const _SettingsSectionHeader({required this.title}); final String title; @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 6), + padding: const EdgeInsets.only(bottom: 8), child: Text(title, style: Theme.of(context).textTheme.titleMedium), ); } } - -class _ConnectedCodexAccountDialog extends StatelessWidget { - const _ConnectedCodexAccountDialog(); - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Codex account'), - content: const Text( - 'WardPulse reads subscription limits and token activity directly on this phone.', - ), - actions: [ - TextButton( - onPressed: - () => Navigator.of(context).pop(_CodexAccountAction.disconnect), - child: const Text('Disconnect'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: - () => Navigator.of(context).pop(_CodexAccountAction.reconnect), - child: const Text('Reconnect'), - ), - ], - ); - } -} - -class _CodexLoginDialog extends StatefulWidget { - const _CodexLoginDialog({required this.attempt}); - - final CodexLoginAttempt attempt; - - @override - State<_CodexLoginDialog> createState() => _CodexLoginDialogState(); -} - -class _CodexLoginDialogState extends State<_CodexLoginDialog> { - String? _error; - String? _browserError; - bool _completed = false; - - @override - void initState() { - super.initState(); - _waitForLogin(); - } - - Future _waitForLogin() async { - try { - await widget.attempt.completion; - _completed = true; - if (mounted) { - Navigator.of(context).pop(true); - } - } on CodexAccountException catch (error) { - if (mounted && error.failure != CodexAccountFailure.cancelled) { - setState(() { - _error = error.details ?? 'Codex sign-in failed'; - }); - } - } catch (_) { - if (mounted) { - setState(() { - _error = 'Codex sign-in failed'; - }); - } - } - } - - @override - void dispose() { - if (!_completed) { - widget.attempt.cancel(); - } - super.dispose(); - } - - Future _openBrowser() async { - if (_browserError != null) { - setState(() { - _browserError = null; - }); - } - try { - final opened = await launchUrl( - widget.attempt.deviceCode.verificationUri, - mode: LaunchMode.externalApplication, - ); - if (opened || !mounted) { - return; - } - } catch (_) { - if (!mounted) { - return; - } - } - if (mounted) { - setState(() { - _browserError = 'Could not open the browser'; - }); - } - } - - Future _copyCode() async { - await Clipboard.setData( - ClipboardData(text: widget.attempt.deviceCode.userCode), - ); - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Code copied'))); - } - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Connect Codex'), - scrollable: true, - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Open the secure OpenAI sign-in page, then enter this one-time code:', - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: SelectableText( - widget.attempt.deviceCode.userCode, - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - IconButton( - tooltip: 'Copy code', - onPressed: _copyCode, - icon: const Icon(Icons.copy_outlined), - ), - ], - ), - SelectableText( - widget.attempt.deviceCode.verificationUri.toString(), - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 12), - if (_error == null) - const LinearProgressIndicator() - else - Text( - _error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - if (_browserError != null) ...[ - const SizedBox(height: 12), - Text( - _browserError!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ], - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - FilledButton.icon( - onPressed: _openBrowser, - icon: const Icon(Icons.open_in_browser), - label: const Text('Open browser'), - ), - ], - ); - } -} - -class _ConnectedClaudeAccountDialog extends StatelessWidget { - const _ConnectedClaudeAccountDialog(); - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Claude account'), - content: const Text( - 'WardPulse reads Claude subscription windows directly on this phone ' - 'using Claude Code OAuth.', - ), - actions: [ - TextButton( - onPressed: - () => Navigator.of(context).pop(_ClaudeAccountAction.disconnect), - child: const Text('Disconnect'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: - () => Navigator.of(context).pop(_ClaudeAccountAction.reconnect), - child: const Text('Reconnect'), - ), - ], - ); - } -} - -class _ClaudeLoginDialog extends StatefulWidget { - const _ClaudeLoginDialog({required this.attempt}); - - final ClaudeLoginAttempt attempt; - - @override - State<_ClaudeLoginDialog> createState() => _ClaudeLoginDialogState(); -} - -class _ClaudeLoginDialogState extends State<_ClaudeLoginDialog> { - final _codeController = TextEditingController(); - final _formKey = GlobalKey(); - String? _browserError; - String? _submitError; - bool _submitting = false; - bool _completed = false; - - @override - void dispose() { - _codeController.dispose(); - if (!_completed) { - widget.attempt.cancel(); - } - super.dispose(); - } - - Future _openBrowser() async { - if (_browserError != null) { - setState(() { - _browserError = null; - }); - } - try { - final opened = await launchUrl( - widget.attempt.authorization.authorizationUri, - mode: LaunchMode.externalApplication, - ); - if (opened || !mounted) { - return; - } - } catch (_) { - if (!mounted) { - return; - } - } - if (mounted) { - setState(() { - _browserError = 'Could not open the browser'; - }); - } - } - - Future _submit() async { - if (_submitting || !(_formKey.currentState?.validate() ?? false)) { - return; - } - setState(() { - _submitting = true; - _submitError = null; - }); - try { - await widget.attempt.completeWithCode(_codeController.text); - _completed = true; - if (mounted) { - Navigator.of(context).pop(true); - } - } on ClaudeAccountException catch (error) { - if (mounted && error.failure != ClaudeAccountFailure.cancelled) { - setState(() { - _submitError = error.details ?? 'Claude sign-in failed'; - _submitting = false; - }); - } - } catch (_) { - if (mounted) { - setState(() { - _submitError = 'Claude sign-in failed'; - _submitting = false; - }); - } - } - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Connect Claude'), - scrollable: true, - content: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Open the secure Claude sign-in page, approve access, then paste ' - 'the authorization code from the callback page (CODE#STATE).', - ), - const SizedBox(height: 16), - TextFormField( - controller: _codeController, - enabled: !_submitting, - autocorrect: false, - enableSuggestions: false, - decoration: const InputDecoration( - border: OutlineInputBorder(), - labelText: 'Authorization code', - hintText: 'CODE#STATE', - ), - textInputAction: TextInputAction.done, - onFieldSubmitted: (_) => _submit(), - validator: (value) { - return value == null || value.trim().isEmpty - ? 'Paste the authorization code' - : null; - }, - ), - const SizedBox(height: 12), - if (_submitting) const LinearProgressIndicator(), - if (_submitError != null) - Text( - _submitError!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - if (_browserError != null) ...[ - const SizedBox(height: 8), - Text( - _browserError!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ], - ], - ), - ), - actions: [ - TextButton( - onPressed: - _submitting ? null : () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - FilledButton.icon( - onPressed: _submitting ? null : _openBrowser, - icon: const Icon(Icons.open_in_browser), - label: const Text('Open browser'), - ), - FilledButton( - onPressed: _submitting ? null : _submit, - child: const Text('Continue'), - ), - ], - ); - } -} - -class _CredentialDialog extends StatefulWidget { - const _CredentialDialog({ - required this.hasCredential, - required this.title, - required this.hint, - required this.allowLabel, - this.initialLabel, - }); - - final bool hasCredential; - final String title; - final String hint; - final bool allowLabel; - final String? initialLabel; - - @override - State<_CredentialDialog> createState() => _CredentialDialogState(); -} - -class _CredentialDialogState extends State<_CredentialDialog> { - final _formKey = GlobalKey(); - final _controller = TextEditingController(); - late final TextEditingController _labelController; - bool _obscureKey = true; - - @override - void initState() { - super.initState(); - _labelController = TextEditingController(text: widget.initialLabel ?? ''); - } - - @override - void dispose() { - _controller.dispose(); - _labelController.dispose(); - super.dispose(); - } - - void _save() { - final key = _controller.text.trim(); - final label = _labelController.text.trim(); - final resolvedLabel = label.isEmpty ? null : label; - if (key.isEmpty) { - if (!widget.hasCredential) { - _formKey.currentState?.validate(); - return; - } - if (widget.allowLabel) { - Navigator.of(context).pop(_CredentialChange.labelOnly(resolvedLabel)); - } else { - // Leave blank to keep the stored token; dismiss without a mutation. - Navigator.of(context).pop(); - } - return; - } - if (_formKey.currentState?.validate() ?? false) { - Navigator.of(context).pop( - _CredentialChange.save( - key, - label: widget.allowLabel ? resolvedLabel : null, - ), - ); - } - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: Text(widget.title), - content: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.allowLabel - ? 'This key is encrypted on this phone and sent only to the provider.' - : 'This token is encrypted on this phone. Experimental compatibility connection.', - ), - const SizedBox(height: 16), - TextFormField( - controller: _controller, - autofocus: true, - obscureText: _obscureKey, - autocorrect: false, - enableSuggestions: false, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: - widget.hasCredential - ? 'Value (leave blank to keep)' - : 'Value', - hintText: widget.hint, - suffixIcon: IconButton( - tooltip: _obscureKey ? 'Show value' : 'Hide value', - onPressed: () { - setState(() { - _obscureKey = !_obscureKey; - }); - }, - icon: Icon( - _obscureKey - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, - ), - ), - ), - textInputAction: - widget.allowLabel - ? TextInputAction.next - : TextInputAction.done, - onFieldSubmitted: widget.allowLabel ? null : (_) => _save(), - validator: (value) { - if (widget.hasCredential) { - return null; - } - return value == null || value.trim().isEmpty - ? 'Enter a value' - : null; - }, - ), - if (widget.allowLabel) ...[ - const SizedBox(height: 12), - TextFormField( - controller: _labelController, - autocorrect: false, - enableSuggestions: false, - decoration: const InputDecoration( - border: OutlineInputBorder(), - labelText: 'Label (optional)', - hintText: 'Work org key', - ), - textInputAction: TextInputAction.done, - onFieldSubmitted: (_) => _save(), - ), - ], - ], - ), - ), - actions: [ - if (widget.hasCredential) - TextButton( - onPressed: () { - Navigator.of(context).pop(const _CredentialChange.remove()); - }, - child: const Text('Remove'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton(onPressed: _save, child: const Text('Save')), - ], - ); - } -} - -/// Compact preview of what the next watch payload would carry as rings. -String _watchSummarySubtitle( - DashboardSnapshot snapshot, - WatchRingPreferences ringPreferences, -) { - final rings = orderWatchRingsForSurface( - resolveWatchRings(snapshot, ringPreferences), - snapshot: snapshot, - ); - if (rings.isEmpty) { - return 'No rings selected'; - } - if (rings.length == 1) { - final ring = rings.single; - return '${ring.label} ${ring.remainingPercent!.round()}% left'; - } - return '${rings.length} rings · ${rings.map((ring) => ring.label).join(', ')}'; -} - -final class _CredentialChange { - const _CredentialChange.save(this.value, {this.label}) - : remove = false, - updateLabelOnly = false; - - const _CredentialChange.labelOnly(this.label) - : value = null, - remove = false, - updateLabelOnly = true; - - const _CredentialChange.remove() - : value = null, - label = null, - remove = true, - updateLabelOnly = false; - - final String? value; - final String? label; - final bool remove; - final bool updateLabelOnly; -} - -enum _CodexAccountAction { reconnect, disconnect } - -enum _ClaudeAccountAction { reconnect, disconnect } - -enum _CursorPlanAccountAction { reconnect, disconnect } - -class _ConnectedCursorPlanDialog extends StatelessWidget { - const _ConnectedCursorPlanDialog(); - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Cursor plan'), - content: const Text( - 'WardPulse reads personal plan usage from the Cursor dashboard ' - 'session stored on this phone.', - ), - actions: [ - TextButton( - onPressed: - () => - Navigator.of(context).pop(_CursorPlanAccountAction.disconnect), - child: const Text('Disconnect'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: - () => - Navigator.of(context).pop(_CursorPlanAccountAction.reconnect), - child: const Text('Sign in again'), - ), - ], - ); - } -} - -class _CursorPlanHelpDialog extends StatelessWidget { - const _CursorPlanHelpDialog(); - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Cursor sign-in'), - content: const SingleChildScrollView( - child: Text( - 'Tap the Cursor plan row to open Cursor’s dashboard in the app and ' - 'sign in with your Cursor account.\n\n' - 'WardPulse keeps the dashboard session on this phone only and uses it ' - 'to read plan usage. This is an experimental compatibility login, not ' - 'a published Cursor API.\n\n' - 'If you already have a WorkosCursorSessionToken, use Advanced paste.', - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Advanced paste'), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('OK'), - ), - ], - ); - } -} diff --git a/apps/phone_flutter/lib/settings/watch_ring_preferences.dart b/apps/phone_flutter/lib/settings/watch_ring_preferences.dart index 26e8d32..d21728a 100644 --- a/apps/phone_flutter/lib/settings/watch_ring_preferences.dart +++ b/apps/phone_flutter/lib/settings/watch_ring_preferences.dart @@ -3,14 +3,24 @@ import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../dashboard/dashboard_models.dart'; +import '../providers/provider_connection.dart'; import '../sync/credit_request_runway.dart'; /// Maximum simultaneous rings on watch and watch-face surfaces. -const watchRingSlotCount = 4; +const watchRingSlotCount = 3; /// Synthetic watch-ring slot: Claude plan windows collapse to the tightest one. const claudePlanRingId = 'allowance.claude.plan'; +/// Retired: for one build the two Cursor pools were a single catalog row. They +/// are picked separately again — a band is shared only when **both** are picked +/// (`WATCH_RING_DESIGN.md`, Split band) — so this id survives only to migrate a +/// selection stored under it. +const cursorPlanRingId = 'allowance.cursor.plan'; + +/// What a shared band is called where a pair counts as one: the payload preview. +const cursorPlanRingLabel = 'Cursor plan'; + /// Claude plan window allowance ids, tie-break order (shorter / primary first). const _claudePlanWindowIds = [ 'claude-five-hour', @@ -19,6 +29,20 @@ const _claudePlanWindowIds = [ 'claude-seven-day-sonnet', ]; +/// Former ring-catalog ids for purchased meters (no longer selectable). +const _retiredPurchasedRingIds = { + 'allowance.claude.claude-extra-usage', + 'allowance.cursor.cursor-on-demand', + 'allowance.codex.codex-purchased-credits', +}; + +/// Former ring-catalog ids for the summed budgets, now keyed by connection. +const retiredBudgetRingIds = { + 'budget.today', + 'budget.week', + 'budget.month', +}; + /// One selectable percent metric for a watch ring. final class WatchRingMetric { const WatchRingMetric({ @@ -26,6 +50,8 @@ final class WatchRingMetric { required this.label, required this.usedPercent, required this.status, + this.spent, + this.limit, this.unavailableReason, }); @@ -38,6 +64,11 @@ final class WatchRingMetric { final double? usedPercent; final ProviderStatus status; + /// Money behind a budget ring — the face strip reads it instead of a percent. + /// Allowance rings leave both null: a plan window has no price. + final Money? spent; + final Money? limit; + /// When set, the Settings row is disabled and the reason is shown as help. final String? unavailableReason; @@ -45,11 +76,31 @@ final class WatchRingMetric { bool get _isClaudePlanSlot => id == claudePlanRingId; - /// Settings checkbox title (Claude plan slot keeps a stable name). - String get settingsTitle => _isClaudePlanSlot ? 'Claude plan' : label; + /// Catalog checkbox title, qualified by family so that a `Weekly plan` from + /// two providers stays apart. The Claude plan slot is synthetic — its label + /// follows the tightest window, so it keeps a stable name instead. + String get catalogTitle { + if (_isClaudePlanSlot) { + return 'Claude plan'; + } + final connection = connectionFromBudgetRingId(id); + if (connection != null) { + // The family alone cannot tell two connections of one provider apart, + // and the label is only the period (`Anthropic platform · Month`). + return '${providerFamilyLabel(connection.provider)} ' + '${connectionKindLabel(connection.kind)} · $label'; + } + final provider = providerFromRingId(id); + if (provider == null) { + return label; + } + final family = providerDisplayLabel(provider); + // A few pool names already open with the family (`Cursor Models`). + return label.split(' ').first == family ? label : '$family · $label'; + } - /// Settings checkbox subtitle. - String get settingsSubtitle { + /// Catalog checkbox subtitle. + String get catalogSubtitle { final reason = unavailableReason; if (reason != null) { return reason; @@ -73,7 +124,6 @@ final class WatchRingMetric { } /// Ordered ring metric ids for Watchface (at most [watchRingSlotCount]). -/// Transitional UI may still edit these under Settings “Watch display”. /// /// `selectedIds == null` means unset → use the default available metrics. /// An empty list means the user chose zero rings. @@ -84,14 +134,31 @@ final class WatchRingPreferences { bool get usesDefaults => selectedIds == null; - List get clampedIds => (selectedIds ?? const []) - .take(watchRingSlotCount) - .toList(growable: false); + /// Clamped by **slot cost**, not by count: both Cursor pools share a band, so + /// a four-id selection can still be three rings. Counting ids here dropped the + /// fourth tick before it ever reached the watch. + List get clampedIds => _withinSlotBudget( + selectedIds ?? const [], + watchRingSlotCount, + ).toList(growable: false); - /// Clamped selection with legacy Claude window ids coalesced. + /// Clamped selection with legacy Claude / purchased ring ids migrated. List get migratedIds => migrateWatchRingSelectedIds(clampedIds); } +/// Storage → prefs, reading an all-retired selection as unset. +/// +/// An empty *stored* list is a real choice ("no rings"); a list that migration +/// empties is not — those ids no longer exist, so defaults apply again rather +/// than leaving the watch blank until the user re-picks. +WatchRingPreferences watchRingPreferencesFromStoredIds(List ids) { + final migrated = migrateWatchRingSelectedIds(ids); + if (ids.isNotEmpty && migrated.isEmpty) { + return const WatchRingPreferences(); + } + return WatchRingPreferences(selectedIds: migrated); +} + String? _claudePlanWindowGlanceLabel(String allowanceId) { return switch (allowanceId) { 'claude-five-hour' => '5h', @@ -114,11 +181,30 @@ bool _isClaudePlanWindowRingId(String ringId) { return _isClaudePlanWindowAllowanceId(ringId.substring(prefix.length)); } -/// Coalesce legacy per-window Claude plan ring ids into [claudePlanRingId]. -List migrateWatchRingSelectedIds(List ids) { +/// Expanded Claude plan window ring ids for phone-widget prefs migration. +final claudePlanWindowRingIds = [ + for (final id in _claudePlanWindowIds) 'allowance.claude.$id', +]; + +/// Coalesce legacy Claude window ids into [claudePlanRingId], drop the retired +/// summed budget ids, and optionally drop retired purchased-meter ring ids +/// (Watchface only). +List migrateWatchRingSelectedIds( + List ids, { + bool dropPurchased = true, + int maxSlots = watchRingSlotCount, +}) { final out = []; var sawClaudePlan = false; + var sawCursorPlan = false; for (final id in ids) { + // No successor to pick: which connection the sum stood for is unknown. + if (retiredBudgetRingIds.contains(id)) { + continue; + } + if (dropPurchased && _retiredPurchasedRingIds.contains(id)) { + continue; + } if (id == claudePlanRingId || _isClaudePlanWindowRingId(id)) { if (!sawClaudePlan) { out.add(claudePlanRingId); @@ -126,16 +212,27 @@ List migrateWatchRingSelectedIds(List ids) { } continue; } + // One build stored the pair under a single id. Pools are picked separately + // again, so that id unfolds into the two it stood for. + if (id == cursorPlanRingId) { + if (!sawCursorPlan) { + out.addAll([cursorOwnPoolId, cursorOtherPoolId]); + sawCursorPlan = true; + } + continue; + } + if (out.contains(id)) { + continue; + } out.add(id); } - return out.take(watchRingSlotCount).toList(growable: false); + return _withinSlotBudget(out, maxSlots).toList(growable: false); } /// Collapses Claude plan windows into one ring (tightest remaining). /// /// Prefers non-exhausted windows so surface omit-exhausted does not hide a -/// usable shorter window behind a fully used weekly. Purchased extra usage is -/// not included. +/// usable shorter window behind a fully used weekly. WatchRingMetric? collapseClaudePlanRing(Iterable allowances) { final windows = [ for (final allowance in allowances) @@ -157,8 +254,7 @@ WatchRingMetric? collapseClaudePlanRing(Iterable allowances) { label: 'Claude plan', usedPercent: null, status: windows.first.status, - unavailableReason: - 'This allowance has no percentage to show as a ring.', + unavailableReason: 'This allowance has no percentage to show.', ); } @@ -189,65 +285,150 @@ int _compareClaudePlanWindows(AllowanceState a, AllowanceState b) { } /// Builds the catalog of ring metrics from the current dashboard snapshot. -List watchRingCatalog(DashboardSnapshot? snapshot) { - final metrics = [ - _budgetMetric('budget.today', 'Today', snapshot?.todayTotal), - _budgetMetric('budget.week', 'Week', snapshot?.weekTotal), - _budgetMetric('budget.month', 'Month', snapshot?.monthTotal), - ]; - +/// +/// Purchased meters (Extra usage, on-demand, Codex credits) are excluded by +/// default — they stay on phone cards / the Widget tab, not Wear rings. +/// +/// [collapseClaudePlan] keeps Watch/WFF on one Claude slot. The phone widget +/// passes `false` so every Claude plan window is selectable on its own. +List watchRingCatalog( + DashboardSnapshot? snapshot, { + bool includePurchased = false, + bool collapseClaudePlan = true, +}) { if (snapshot == null) { - return metrics; + return const []; } + final metrics = []; for (final account in snapshot.accounts) { if (account.provider == 'mock') { continue; } - if (account.provider == 'claude') { + if (account.provider == 'claude' && collapseClaudePlan) { final collapsed = collapseClaudePlanRing(account.allowances); if (collapsed != null) { metrics.add(collapsed); } - for (final allowance in account.allowances) { - if (_isClaudePlanWindowAllowanceId(allowance.id)) { - continue; + if (includePurchased) { + for (final allowance in account.allowances) { + if (allowance.source == AllowanceSource.purchased) { + metrics.add(_allowanceMetric(account.provider, allowance)); + } } - metrics.add(_allowanceMetric(account.provider, allowance)); } continue; } for (final allowance in account.allowances) { + if (allowance.source == AllowanceSource.purchased && !includePurchased) { + continue; + } metrics.add(_allowanceMetric(account.provider, allowance)); } } + // Budgets last: a plan window always carries a percentage, while a budget + // ring waits for a limit, and unset Watchface defaults take the first slots. + for (final account in snapshot.accounts) { + if (account.provider == 'mock') { + continue; + } + metrics.addAll(_connectionBudgetMetrics(account)); + } + return metrics; } +/// What a selection costs in ring slots: **bands**, not pools. Both Cursor pools +/// picked together share one band, so they cost one slot; either alone is an +/// ordinary ring (`WATCH_RING_DESIGN.md`, Split band). +int watchRingSlotCost(Iterable ids) { + final selected = ids.toList(growable: false); + final shared = + selected.contains(cursorOwnPoolId) && + selected.contains(cursorOtherPoolId); + return selected.length - (shared ? 1 : 0); +} + +/// Keeps ids in order while they fit the slot budget, counting by band. +/// +/// Skips what does not fit rather than stopping at it: the second pool of a pair +/// costs nothing once the first is in, and it may sit behind a ring that no +/// longer fits — stopping there would split the pair and hand the watch a half. +List _withinSlotBudget(Iterable ids, int maxSlots) { + final out = []; + for (final id in ids) { + if (watchRingSlotCost([...out, id]) <= maxSlots) { + out.add(id); + } + } + return out; +} + +/// Catalog rows the watch is set to show: the stored choice, or the defaults the +/// catalog offers when there is none. +/// +/// These are **catalog** ids — a Cursor pair is one id here, not its two pools. +/// [resolveWatchRings] expands them; the Watchface tab must not, or its +/// checkboxes would look for a pair by an id the catalog never shows. +List watchRingSelectionIds( + DashboardSnapshot? snapshot, + WatchRingPreferences preferences, +) { + if (!preferences.usesDefaults || snapshot == null) { + return preferences.migratedIds; + } + return _withinSlotBudget([ + for (final metric in watchRingCatalog(snapshot)) + if (metric.isAvailable) metric.id, + ], watchRingSlotCount).toList(growable: false); +} + /// Resolves rings for the watch payload: only available percent metrics. /// -/// Order follows Settings selection (or catalog defaults). Use +/// Order follows Watchface selection (or catalog defaults). Use /// [orderWatchRingsForSurface] before sending to Wear/WFF. List resolveWatchRings( DashboardSnapshot snapshot, WatchRingPreferences preferences, ) { - if (preferences.usesDefaults) { - return watchRingCatalog( - snapshot, - ).where((metric) => metric.isAvailable).take(watchRingSlotCount).toList(); - } - final catalog = { for (final metric in watchRingCatalog(snapshot)) metric.id: metric, }; return [ - for (final id in preferences.migratedIds) + for (final id in watchRingSelectionIds(snapshot, preferences)) if (catalog[id] case final metric? when metric.isAvailable) metric, ]; } +/// The Cursor plan's two pools share one band, so they travel as one entry: the +/// own-models pool with the external one folded into its `split` +/// (`WATCH_RING_DESIGN.md`, Split band). +/// +/// The pair keeps the place of its **tighter** half, which is where the surface +/// order already put it, while inside the entry the order is by pool — own +/// first — because that side is a name, not a rank. +const cursorOwnPoolId = 'allowance.cursor.cursor-plan-models'; +const cursorOtherPoolId = 'allowance.cursor.cursor-plan-other'; + +List<(WatchRingMetric, WatchRingMetric?)> pairCursorPools( + List rings, +) { + final own = rings.where((ring) => ring.id == cursorOwnPoolId).firstOrNull; + final other = rings.where((ring) => ring.id == cursorOtherPoolId).firstOrNull; + if (own == null || other == null) { + return [for (final ring in rings) (ring, null)]; + } + final tighter = rings.indexOf(own) < rings.indexOf(other) ? own : other; + return [ + for (final ring in rings) + if (ring.id == tighter.id) + (own, other) + else if (ring.id != cursorOwnPoolId && ring.id != cursorOtherPoolId) + (ring, null), + ]; +} + /// Display order for Wear/WFF/Glance: omit exhausted layers, tightest remaining first. /// /// Payload index 0 = critical limit. Face maps that to the **innermost** ring and the @@ -258,6 +439,7 @@ List resolveWatchRings( List orderWatchRingsForSurface( List rings, { DashboardSnapshot? snapshot, + int maxSlots = watchRingSlotCount, }) { final active = [ for (final ring in rings) @@ -266,7 +448,7 @@ List orderWatchRingsForSurface( final runways = {}; if (snapshot != null) { for (final ring in active) { - final provider = _providerFromRingId(ring.id); + final provider = providerFromRingId(ring.id); if (provider == null) { continue; } @@ -281,8 +463,8 @@ List orderWatchRingsForSurface( if (usedCmp != 0) { return usedCmp; } - final providerA = _providerFromRingId(a.id); - final providerB = _providerFromRingId(b.id); + final providerA = providerFromRingId(a.id); + final providerB = providerFromRingId(b.id); final runwayA = providerA == null ? null : runways[providerA]; final runwayB = providerB == null ? null : runways[providerB]; // Missing / unlimited → +∞ (no secondary tightness pressure). @@ -296,11 +478,52 @@ List orderWatchRingsForSurface( } return a.id.compareTo(b.id); }); - return active.take(watchRingSlotCount).toList(growable: false); + // By band, not by ring: a pair is two rings and one slot, so counting rings + // here dropped one of its pools and the band arrived undivided. + final kept = + _withinSlotBudget([for (final ring in active) ring.id], maxSlots).toSet(); + return [ + for (final ring in active) + if (kept.contains(ring.id)) ring, + ]; +} + +/// Short subtitle for Watchface preview and Settings diagnostics. +/// +/// Counts **bands**, not pools: a Cursor pair travels as one payload entry and +/// costs one slot, so it is named once, by the row the user checked. +String watchRingPayloadSubtitle( + DashboardSnapshot snapshot, + WatchRingPreferences ringPreferences, +) { + final bands = pairCursorPools( + orderWatchRingsForSurface( + resolveWatchRings(snapshot, ringPreferences), + snapshot: snapshot, + ), + ); + if (bands.isEmpty) { + return 'No rings selected'; + } + if (bands.length == 1) { + final (ring, split) = bands.single; + // A band speaks with its tighter half, the way the face ranks it. + final tightest = + split == null || ring.remainingPercent! <= split.remainingPercent! + ? ring + : split; + final title = split == null ? ring.catalogTitle : cursorPlanRingLabel; + return '$title ${tightest.remainingPercent!.round()}% left'; + } + final titles = [ + for (final (ring, split) in bands) + if (split == null) ring.catalogTitle else cursorPlanRingLabel, + ]; + return '${bands.length} rings · ${titles.join(', ')}'; } /// Owning provider for `allowance..…`, or null for budgets / unknown. -String? _providerFromRingId(String ringId) { +String? providerFromRingId(String ringId) { if (!ringId.startsWith('allowance.')) { return null; } @@ -319,26 +542,55 @@ WatchRingMetric _allowanceMetric(String provider, AllowanceState allowance) { usedPercent: percent, status: allowance.status, unavailableReason: - percent == null - ? 'This allowance has no percentage to show as a ring.' - : null, + percent == null ? 'This allowance has no percentage to show.' : null, ); } -WatchRingMetric _budgetMetric(String id, String label, BudgetState? budget) { - final percent = budget?.usedPercent; +/// Local budget rings of one connection: one per period it reports spend for. +/// +/// Reported spend is what makes a ceiling meaningful — subscription plans +/// report none, and a connection may report only some periods. +List _connectionBudgetMetrics(ProviderSnapshot account) { + final connection = account.connection; + if (connection == null) { + return const []; + } + return [ + for (final budget in [account.today, account.week, account.month]) + if (budget.spent != null) _budgetMetric(connection, budget), + ]; +} + +WatchRingMetric _budgetMetric(String connection, BudgetState budget) { + final percent = budget.usedPercent; return WatchRingMetric( - id: id, - label: label, + id: 'budget.$connection.${budget.period}', + label: budget.periodLabel, usedPercent: percent, - status: budget?.status ?? ProviderStatus.unknown, + status: budget.status, + spent: budget.spent, + limit: budget.limit, unavailableReason: percent == null - ? 'Connect a spend-reporting provider to fill this budget ring.' + ? 'Set this period’s limit under Providers · Budget limits.' : null, ); } +/// Connection of a `budget..` ring, or null for other ids. +ProviderConnectionId? connectionFromBudgetRingId(String ringId) { + const prefix = 'budget.'; + if (!ringId.startsWith(prefix)) { + return null; + } + final key = ringId.substring(prefix.length); + final period = key.lastIndexOf('.'); + if (period < 0) { + return null; + } + return ProviderConnectionId.fromStorageKey(key.substring(0, period)); +} + abstract interface class WatchRingPreferenceStore { Future read(); @@ -368,11 +620,20 @@ final class SecureWatchRingPreferenceStore implements WatchRingPreferenceStore { for (final entry in decoded) if (entry is String && entry.isNotEmpty) entry, ]; - final migrated = migrateWatchRingSelectedIds(ids); - if (!_sameIds(ids.take(watchRingSlotCount).toList(), migrated)) { + final preferences = watchRingPreferencesFromStoredIds(ids); + final migrated = preferences.selectedIds; + // The comparison is against what the budget keeps, not the first three + // ids: a pair is four ids and three bands, and counting ids rewrote the + // stored selection on every read. + if (migrated == null) { + await _storage.delete(key: _key); + } else if (!_sameIds( + _withinSlotBudget(ids, watchRingSlotCount), + migrated, + )) { await _storage.write(key: _key, value: jsonEncode(migrated)); } - return WatchRingPreferences(selectedIds: migrated); + return preferences; } on FormatException { return const WatchRingPreferences(); } @@ -383,10 +644,7 @@ final class SecureWatchRingPreferenceStore implements WatchRingPreferenceStore { if (value.selectedIds == null) { return _storage.delete(key: _key); } - return _storage.write( - key: _key, - value: jsonEncode(value.migratedIds), - ); + return _storage.write(key: _key, value: jsonEncode(value.migratedIds)); } } diff --git a/apps/phone_flutter/lib/sync/claude_account_client.dart b/apps/phone_flutter/lib/sync/claude_account_client.dart index 5768f71..63465dc 100644 --- a/apps/phone_flutter/lib/sync/claude_account_client.dart +++ b/apps/phone_flutter/lib/sync/claude_account_client.dart @@ -16,7 +16,8 @@ enum ClaudeAccountFailure { cancelled, } -typedef ClaudeSessionChanged = Future Function(ClaudeAccountSession value); +typedef ClaudeSessionChanged = + Future Function(ClaudeAccountSession value); final class ClaudeAccountException implements Exception { const ClaudeAccountException(this.failure, [this.details]); @@ -144,8 +145,7 @@ final class ClaudeAccountClient { _random = random ?? Random.secure(); static const clientId = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; - static const redirectUri = - 'https://platform.claude.com/oauth/code/callback'; + static const redirectUri = 'https://platform.claude.com/oauth/code/callback'; static const _scope = 'user:inference user:profile user:sessions:claude_code user:mcp_servers'; static const _tokenTimeout = Duration(seconds: 120); @@ -188,16 +188,17 @@ final class ClaudeAccountClient { required String pastedCode, }) async { final parsed = _parsePastedCode(pastedCode, expectedState: request.state); - final body = Uri( - queryParameters: { - 'grant_type': 'authorization_code', - 'code': parsed.code, - 'redirect_uri': redirectUri, - 'client_id': clientId, - 'code_verifier': request.codeVerifier, - 'state': parsed.state, - }, - ).query; + final body = + Uri( + queryParameters: { + 'grant_type': 'authorization_code', + 'code': parsed.code, + 'redirect_uri': redirectUri, + 'client_id': clientId, + 'code_verifier': request.codeVerifier, + 'state': parsed.state, + }, + ).query; final response = await _transport.send( 'POST', _tokenUri, @@ -235,13 +236,14 @@ final class ClaudeAccountClient { } Future refresh(ClaudeAccountSession session) async { - final body = Uri( - queryParameters: { - 'grant_type': 'refresh_token', - 'refresh_token': session.refreshToken, - 'client_id': clientId, - }, - ).query; + final body = + Uri( + queryParameters: { + 'grant_type': 'refresh_token', + 'refresh_token': session.refreshToken, + 'client_id': clientId, + }, + ).query; final response = await _transport.send( 'POST', _tokenUri, diff --git a/apps/phone_flutter/lib/sync/credit_request_runway.dart b/apps/phone_flutter/lib/sync/credit_request_runway.dart index c59d27b..4738f3e 100644 --- a/apps/phone_flutter/lib/sync/credit_request_runway.dart +++ b/apps/phone_flutter/lib/sync/credit_request_runway.dart @@ -24,7 +24,7 @@ double? creditRequestRunwayForProvider( return null; } - final remaining = _purchasedCreditsRemaining(snapshot, provider); + final remaining = purchasedCreditsRemainingForProvider(snapshot, provider); if (remaining == null) { return null; } @@ -32,7 +32,10 @@ double? creditRequestRunwayForProvider( } /// Finite purchased `credits` remaining for one provider, or null. -double? _purchasedCreditsRemaining( +/// +/// Shared by watch-ring sort and phone home-widget Glance-style suffixes. +/// Unlimited purchased balances return null (no numeric credits line). +double? purchasedCreditsRemainingForProvider( DashboardSnapshot snapshot, String provider, ) { diff --git a/apps/phone_flutter/lib/sync/headless_provider_sync.dart b/apps/phone_flutter/lib/sync/headless_provider_sync.dart index 9c7cced..385014e 100644 --- a/apps/phone_flutter/lib/sync/headless_provider_sync.dart +++ b/apps/phone_flutter/lib/sync/headless_provider_sync.dart @@ -5,6 +5,7 @@ import 'package:workmanager/workmanager.dart'; import 'poll_cadence.dart'; import 'provider_sync_once.dart'; +import 'recovery_wake.dart'; /// WorkManager-backed sync after Android reclaims the UI isolate. /// @@ -16,7 +17,7 @@ abstract final class HeadlessProviderSync { static const taskName = 'providerSync'; static Future ensureInitialized() async { - if (!_isAndroidHost) { + if (!isAndroidHost) { return; } await Workmanager().initialize(callbackDispatcher); @@ -24,7 +25,7 @@ abstract final class HeadlessProviderSync { /// Enqueue or replace the periodic headless sync for [preferred] slider value. static Future schedule(Duration preferred) async { - if (!_isAndroidHost) { + if (!isAndroidHost) { return; } await Workmanager().registerPeriodicTask( @@ -35,15 +36,18 @@ abstract final class HeadlessProviderSync { constraints: Constraints(networkType: NetworkType.connected), ); } +} - /// Real Android OS host — not Flutter's simulated [defaultTargetPlatform] - /// (tests on Linux report Android as the target but are not Android hosts). - static bool get _isAndroidHost { - try { - return Platform.isAndroid; - } catch (_) { - return false; - } +/// Real Android OS host — not Flutter's simulated `defaultTargetPlatform` +/// (tests on Linux report Android as the target but are not Android hosts). +/// +/// Anything that schedules work asks this first: on a host with no WorkManager, +/// booking is not a failure to report, it is a thing that does not apply. +bool get isAndroidHost { + try { + return Platform.isAndroid; + } catch (_) { + return false; } } @@ -52,7 +56,11 @@ void callbackDispatcher() { Workmanager().executeTask((task, inputData) async { // Background isolate — register plugins before secure storage / channels. DartPluginRegistrant.ensureInitialized(); - if (task == HeadlessProviderSync.taskName) { + // Both names run the same poll: the periodic tick, and the wake booked for + // a spent window's reset. A name without a branch here would "succeed" + // without doing anything at all. + if (task == HeadlessProviderSync.taskName || + task == WorkmanagerRecoveryWakeScheduler.taskName) { await providerSyncOnce(); } return true; diff --git a/apps/phone_flutter/lib/sync/poll_cadence.dart b/apps/phone_flutter/lib/sync/poll_cadence.dart index 3fcb4b1..e9f4452 100644 --- a/apps/phone_flutter/lib/sync/poll_cadence.dart +++ b/apps/phone_flutter/lib/sync/poll_cadence.dart @@ -33,9 +33,10 @@ abstract final class PollCadence { /// Headless / WorkManager cadence: never below the Android 15-minute floor. static Duration headlessInterval(Duration preferred) { final minutes = preferred.inMinutes; - final clamped = minutes < headlessMinRefreshMinutes - ? headlessMinRefreshMinutes - : clampMinutes(minutes); + final clamped = + minutes < headlessMinRefreshMinutes + ? headlessMinRefreshMinutes + : clampMinutes(minutes); return Duration(minutes: clamped); } diff --git a/apps/phone_flutter/lib/sync/provider_reporting_clients.dart b/apps/phone_flutter/lib/sync/provider_reporting_clients.dart index b99782b..5c99c54 100644 --- a/apps/phone_flutter/lib/sync/provider_reporting_clients.dart +++ b/apps/phone_flutter/lib/sync/provider_reporting_clients.dart @@ -89,9 +89,7 @@ final class CursorPlanClient { Future fetchUsageSummary({required String sessionToken}) async { final response = await _http.get( _baseUri.replace(path: '/api/usage-summary'), - headers: { - 'Cookie': '$cursorSessionCookieName=$sessionToken', - }, + headers: {'Cookie': '$cursorSessionCookieName=$sessionToken'}, label: 'Cursor usage', ); return response.body; diff --git a/apps/phone_flutter/lib/sync/provider_sync_once.dart b/apps/phone_flutter/lib/sync/provider_sync_once.dart index 02f669f..8d90d30 100644 --- a/apps/phone_flutter/lib/sync/provider_sync_once.dart +++ b/apps/phone_flutter/lib/sync/provider_sync_once.dart @@ -1,8 +1,17 @@ +import 'dart:developer' as developer; + import 'package:flutter/widgets.dart'; +import '../dashboard/apply_alert_settings.dart'; import '../dashboard/phone_live_bindings.dart'; -import '../settings/consumption_display_preferences.dart'; +import '../settings/alert_threshold_preferences.dart'; +import '../settings/recovery_notification_preferences.dart'; import '../settings/watch_ring_preferences.dart'; +import '../widget/phone_widget_preferences.dart'; +import '../widget/phone_widget_sync.dart'; +import 'recovery_notifications.dart'; +import 'recovery_wake.dart'; +import 'recovery_watchlist.dart'; import 'watch_sync_service.dart'; /// One provider sync + watch push with no UI (headless WorkManager tick). @@ -12,14 +21,36 @@ Future providerSyncOnce() async { WidgetsFlutterBinding.ensureInitialized(); try { final live = PhoneLiveBindings.create(); - final displayPreferences = - await SecureConsumptionDisplayPreferenceStore().read(); final ringPreferences = await SecureWatchRingPreferenceStore().read(); - final snapshot = await live.repository.load(); - await const MethodChannelWatchSyncService().sync( + final widgetPreferences = await SecurePhoneWidgetPreferenceStore().read(); + final alertThresholds = await SecureAlertThresholdPreferenceStore().read(); + final notifyOnRecovery = + await SecureRecoveryNotificationPreferenceStore().read(); + final snapshot = applyUserAlertSettings( + await live.repository.load(), + alertThresholds, + ); + await const MethodChannelWatchSyncService().sync(snapshot, ringPreferences); + try { + await HomeWidgetPhoneWidgetSyncService().sync( + snapshot, + widgetPreferences, + ); + } catch (error) { + // The outer catch would swallow this without a word; name the cause. + developer.log( + 'headless sync could not update the widget: $error', + name: phoneWidgetLogName, + ); + } + // Bookkeeping last: what the reader can see is pushed first, and a store + // that stalls must not hold up the watch. + await syncPlanRecoveries( snapshot, - displayPreferences, - ringPreferences, + SecureRecoveryWatchlistStore(), + LocalRecoveryNotifier(), + const WorkmanagerRecoveryWakeScheduler(), + notifications: notifyOnRecovery, ); } catch (_) { // Automatic / headless sync keeps the last successful snapshot visible. diff --git a/apps/phone_flutter/lib/sync/recovery_notifications.dart b/apps/phone_flutter/lib/sync/recovery_notifications.dart new file mode 100644 index 0000000..89bf862 --- /dev/null +++ b/apps/phone_flutter/lib/sync/recovery_notifications.dart @@ -0,0 +1,134 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +import '../dashboard/dashboard_models.dart'; +import '../dashboard/plan_recoveries.dart'; + +/// Tells the reader a plan window is usable again. +/// +/// An interface, not a channel call, for the same reason `WatchSyncService` is +/// one: host tests carry no Android to post to. +abstract interface class RecoveryNotifier { + Future notify(PlanRecovery recovery); + + /// Asks Android for permission to post at all (13+), and answers whether it + /// may now. + /// + /// Only a foreground Activity may ask, so this is called from the Settings + /// switch and never from the poll that posts. + Future requestPermission(); +} + +/// Says nothing, for hosts with nowhere to say it: tests, and any build without +/// the Android side wired up. +final class SilentRecoveryNotifier implements RecoveryNotifier { + const SilentRecoveryNotifier(); + + @override + Future notify(PlanRecovery recovery) async {} + + @override + Future requestPermission() async => true; +} + +/// Posts through the local notification plugin. +/// +/// A plugin rather than a channel of our own: the wake that matters fires with +/// the app closed, in the WorkManager background isolate, and only pub plugins +/// are registered there. An app-level channel lives in the Activity's engine +/// alone. +final class LocalRecoveryNotifier implements RecoveryNotifier { + LocalRecoveryNotifier({FlutterLocalNotificationsPlugin? plugin}) + : _plugin = plugin ?? FlutterLocalNotificationsPlugin(); + + /// One channel for the one thing this product interrupts a reader for. + static const _channelId = 'wardpulse.plan_recovery'; + static const _channelName = 'Plan recovery'; + static const _channelDescription = + 'A plan window you had run out of is usable again.'; + + final FlutterLocalNotificationsPlugin _plugin; + bool _ready = false; + + @override + Future notify(PlanRecovery recovery) async { + await _ensureReady(); + await _plugin.show( + id: recoveryNotificationId(recovery), + title: recoveryNotificationTitle(recovery), + body: recoveryNotificationBody, + notificationDetails: const NotificationDetails( + android: AndroidNotificationDetails( + _channelId, + _channelName, + channelDescription: _channelDescription, + category: AndroidNotificationCategory.status, + // High, because this is the one thing the product interrupts for: a + // window the reader has been waiting on is usable again. At the + // default the notification would wait quietly in the shade, which is + // the opposite of what the phase decided it is worth. + importance: Importance.high, + priority: Priority.high, + ), + ), + ); + } + + @override + Future requestPermission() async { + await _ensureReady(); + final granted = + await _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.requestNotificationsPermission(); + // Null means no Android implementation answered — nothing was refused, so + // the switch has no bad news to report. + return granted ?? true; + } + + Future _ensureReady() async { + if (_ready) { + return; + } + await _plugin.initialize( + settings: const InitializationSettings( + // The launcher mark, because the product has no separate status icon. + android: AndroidInitializationSettings('@mipmap/ic_launcher'), + ), + ); + _ready = true; + } +} + +/// What a recovery says on a locked screen: the family and the window's own +/// name, nothing else. +/// +/// Family first, because two subscriptions can both call a window "Weekly +/// plan" and the reader is being interrupted to learn which one is back. Kept +/// out of the plugin call so the rule in `SECURITY_MODEL.md` — no credentials, +/// no account ids, no spend, no raw provider fields — is something a test can +/// read rather than something a reviewer has to. +String recoveryNotificationTitle(PlanRecovery recovery) { + return '${providerDisplayLabel(recovery.provider)} · ${recovery.label}'; +} + +/// The whole message. A recovery has one thing to say and says it. +const recoveryNotificationBody = 'Usable again.'; + +/// The notification a window owns, so a second telling replaces the first +/// rather than stacking beside it — which is what makes a repeat harmless, and +/// a repeat is what the reporting order deliberately risks. +/// +/// Computed here rather than taken from `hashCode`: Dart does not promise a +/// string keeps its hash between runs, and an id that drifted across launches +/// would leave the reader with two notifications for one window. +int recoveryNotificationId(PlanRecovery recovery) { + // FNV-1a, folded into the positive 31 bits an Android notification id uses. + var hash = 0x811c9dc5; + final key = '${recovery.accountId}|${recovery.allowanceId}'; + for (final unit in key.codeUnits) { + hash = ((hash ^ unit) * 0x01000193) & 0xffffffff; + } + return hash & 0x7fffffff; +} diff --git a/apps/phone_flutter/lib/sync/recovery_wake.dart b/apps/phone_flutter/lib/sync/recovery_wake.dart new file mode 100644 index 0000000..ec3cf5c --- /dev/null +++ b/apps/phone_flutter/lib/sync/recovery_wake.dart @@ -0,0 +1,64 @@ +import 'package:workmanager/workmanager.dart'; + +import 'headless_provider_sync.dart'; + +/// Wakes the phone when a spent plan window is due to roll. +/// +/// One wake, not one per window: whatever poll runs on waking checks every +/// window at once, so the earliest reset is the only instant worth booking — +/// the rest book themselves on that same pass. +abstract interface class RecoveryWakeScheduler { + Future scheduleAt(DateTime resetsAt); + + Future cancel(); +} + +/// Books the wake with WorkManager, which the headless poll already runs on. +/// +/// An exact alarm would be the obvious tool and is not available to this app: +/// `SCHEDULE_EXACT_ALARM` is granted to alarms and calendars. A delayed task +/// fires inside the system's own window instead, which is what "scheduled from +/// the reset instant rather than found by polling" means here. +final class WorkmanagerRecoveryWakeScheduler implements RecoveryWakeScheduler { + const WorkmanagerRecoveryWakeScheduler(); + + static const uniqueName = 'wardpulse.recoveryWake'; + static const taskName = 'recoveryWake'; + + @override + Future scheduleAt(DateTime resetsAt) async { + if (!isAndroidHost) { + return; + } + final delay = resetsAt.difference(DateTime.now()); + await Workmanager().registerOneOffTask( + uniqueName, + taskName, + initialDelay: delay.isNegative ? Duration.zero : delay, + // Replace, because the default for a one-off task is KEEP: a window whose + // reset moved would otherwise keep the wake booked for the old instant. + existingWorkPolicy: ExistingWorkPolicy.replace, + constraints: Constraints(networkType: NetworkType.connected), + ); + } + + @override + Future cancel() async { + if (!isAndroidHost) { + return; + } + await Workmanager().cancelByUniqueName(uniqueName); + } +} + +/// Books nothing, for hosts with no scheduler: tests, and any build where the +/// poll is driven by hand. +final class DisabledRecoveryWakeScheduler implements RecoveryWakeScheduler { + const DisabledRecoveryWakeScheduler(); + + @override + Future scheduleAt(DateTime resetsAt) async {} + + @override + Future cancel() async {} +} diff --git a/apps/phone_flutter/lib/sync/recovery_watchlist.dart b/apps/phone_flutter/lib/sync/recovery_watchlist.dart new file mode 100644 index 0000000..e8eaa7a --- /dev/null +++ b/apps/phone_flutter/lib/sync/recovery_watchlist.dart @@ -0,0 +1,132 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '../dashboard/dashboard_models.dart'; +import '../dashboard/plan_recoveries.dart'; +import 'poll_cadence.dart'; +import 'recovery_notifications.dart'; +import 'recovery_wake.dart'; + +/// The plan windows that were spent when the phone last looked. +/// +/// A recovery is an edge, and an edge needs a past. The process does not +/// survive between polls, so the past lives here — as the core's own window +/// keys, never as a second idea of what "exhausted" means. +abstract interface class RecoveryWatchlistStore { + Future> read(); + + Future write(List keys); +} + +final class SecureRecoveryWatchlistStore implements RecoveryWatchlistStore { + SecureRecoveryWatchlistStore({FlutterSecureStorage? storage}) + : _storage = storage ?? const FlutterSecureStorage(); + + static const _key = 'wardpulse.recovery.exhaustedWindows'; + + final FlutterSecureStorage _storage; + + @override + Future> read() async { + final stored = await _storage.read(key: _key); + if (stored == null) { + return const []; + } + return decodeWindowKeys(stored); + } + + @override + Future write(List keys) { + return _storage.write(key: _key, value: encodeWindowKeys(keys)); + } +} + +/// Remembers nothing, for hosts with no storage to speak of: tests and any +/// build where a recovery would have nowhere to arrive. +final class DisabledRecoveryWatchlistStore implements RecoveryWatchlistStore { + const DisabledRecoveryWatchlistStore(); + + @override + Future> read() async => const []; + + @override + Future write(List keys) async {} +} + +/// Tells the reader about windows that came back, then remembers what is spent +/// now. +/// +/// Called from both places a fresh snapshot appears — the headless tick and the +/// foreground load — because they are separate paths, exactly as the watch and +/// widget pushes are. +/// +/// **Notify first, remember second.** Remembering first would lose a recovery +/// for good if the process died in between: the key is gone and no later poll +/// can see the edge. Notifying first risks a repeat instead, and a repeat costs +/// nothing — the notification is keyed by the window and replaces its own +/// earlier copy. The same property is why the two paths need no lock when they +/// overlap: they read the same list, report the same windows, and write the +/// same answer. +/// +/// [notifications] off silences the telling and nothing else: the windows are +/// still tracked and the wake is still booked, because the switch turns off an +/// interruption, not an eye. +/// +/// [mockData] empties the list instead of filling it, and reports nothing. Demo +/// windows are invented, so remembering them would make the first live poll +/// after leaving demo mode look like a recovery. +Future syncPlanRecoveries( + DashboardSnapshot snapshot, + RecoveryWatchlistStore store, + RecoveryNotifier notifier, + RecoveryWakeScheduler wake, { + bool mockData = false, + bool notifications = true, + ReadPlanRecoveries readRecoveries = planRecoveries, + ReadExhaustedWindows readWindows = exhaustedWindows, + DateTime Function() now = DateTime.now, +}) async { + try { + if (mockData) { + await store.write(const []); + await wake.cancel(); + return; + } + // An edge needs a past. With nothing remembered there is nothing to have + // left, and asking the core would serialize the whole snapshot to be told + // so — on most polls, since most of the time nothing is spent. + final remembered = await store.read(); + if (notifications && remembered.isNotEmpty) { + for (final recovery in readRecoveries(snapshot, remembered)) { + try { + await notifier.notify(recovery); + } catch (_) { + // Each window is reported on its own: a notifier that refuses one + // must not silence the rest, and must not stop the list below from + // moving on. A stuck list would go on missing every later window. + } + } + } + final spent = readWindows(snapshot); + await store.write(spent); + // Booked from the reset instant rather than left to the next poll, which + // the cadence would make up to an interval late. `null` covers every case + // with nothing to wake for: nothing spent, nothing saying when it rolls, + // and a reset already behind because the provider has not caught up. + final soonest = nextResetAmong(snapshot, spent, after: now()); + if (soonest == null) { + await wake.cancel(); + } else { + // Never sooner than the poll floor after this one. A window resetting a + // minute from now would otherwise send the woken poll straight back to a + // provider, and `PollCadence` calls its lower bound the strictest hard + // floor across connections — a wake is not an exemption from it. + final floor = now().add( + const Duration(minutes: PollCadence.minRefreshMinutes), + ); + await wake.scheduleAt(soonest.isBefore(floor) ? floor : soonest); + } + } catch (_) { + // A poll must never crash over bookkeeping; the next one writes the list + // again. + } +} diff --git a/apps/phone_flutter/lib/sync/watch_credits_glance.dart b/apps/phone_flutter/lib/sync/watch_credits_glance.dart index 5f6ddea..adbe06b 100644 --- a/apps/phone_flutter/lib/sync/watch_credits_glance.dart +++ b/apps/phone_flutter/lib/sync/watch_credits_glance.dart @@ -1,5 +1,4 @@ import '../dashboard/dashboard_models.dart'; -import '../settings/consumption_display_preferences.dart'; /// Compact remaining purchased credits for the watch-face strip. final class WatchCreditsGlance { @@ -21,15 +20,8 @@ final class WatchCreditsGlance { }; } -/// Remaining purchased credits when Settings shows purchased usage. -WatchCreditsGlance? resolveWatchCreditsGlance( - DashboardSnapshot snapshot, - ConsumptionDisplayPreferences displayPreferences, -) { - if (!displayPreferences.purchased) { - return null; - } - +/// Remaining purchased credits for the watch-face strip. +WatchCreditsGlance? resolveWatchCreditsGlance(DashboardSnapshot snapshot) { String? unit; final providers = {}; double total = 0; diff --git a/apps/phone_flutter/lib/sync/watch_sync_service.dart b/apps/phone_flutter/lib/sync/watch_sync_service.dart index e484f6d..cf9d0d6 100644 --- a/apps/phone_flutter/lib/sync/watch_sync_service.dart +++ b/apps/phone_flutter/lib/sync/watch_sync_service.dart @@ -4,7 +4,6 @@ import 'dart:convert'; import 'package:flutter/services.dart'; import '../dashboard/dashboard_models.dart'; -import '../settings/consumption_display_preferences.dart'; import '../settings/watch_ring_preferences.dart'; import 'manual_refresh_window.dart'; import 'watch_credits_glance.dart'; @@ -12,9 +11,9 @@ import 'watch_credits_glance.dart'; abstract interface class WatchSyncService { Future sync( DashboardSnapshot snapshot, - ConsumptionDisplayPreferences displayPreferences, WatchRingPreferences ringPreferences, { DateTime? manualRefreshAnchorAt, + bool mockDataMode = false, }); /// Native → Dart: Wear Glance asked the phone to sync providers. @@ -33,17 +32,17 @@ class MethodChannelWatchSyncService implements WatchSyncService { @override Future sync( DashboardSnapshot snapshot, - ConsumptionDisplayPreferences displayPreferences, WatchRingPreferences ringPreferences, { DateTime? manualRefreshAnchorAt, + bool mockDataMode = false, }) { return _channel.invokeMethod( 'syncWatchSummary', WatchDashboardSummaryPayload.fromSnapshot( snapshot, - displayPreferences, ringPreferences, manualRefreshAnchorAt: manualRefreshAnchorAt, + mockDataMode: mockDataMode, ).encode(), ); } @@ -74,47 +73,45 @@ class WatchDashboardSummaryPayload { factory WatchDashboardSummaryPayload.fromSnapshot( DashboardSnapshot snapshot, - ConsumptionDisplayPreferences displayPreferences, WatchRingPreferences ringPreferences, { DateTime? manualRefreshAnchorAt, DateTime? clock, + bool mockDataMode = false, }) { final rings = orderWatchRingsForSurface( resolveWatchRings(snapshot, ringPreferences), snapshot: snapshot, ); - final creditsGlance = resolveWatchCreditsGlance( - snapshot, - displayPreferences, - ); + final creditsGlance = resolveWatchCreditsGlance(snapshot); final window = ManualRefreshWindow.fromLastSync( lastSyncAt: manualRefreshAnchorAt ?? snapshot.generatedAt, now: clock, ); + final isLegacyMockProvider = + snapshot.accounts.isNotEmpty && + snapshot.accounts.every((account) => account.provider == 'mock'); return WatchDashboardSummaryPayload._({ - 'schemaVersion': 7, - 'dataMode': - snapshot.accounts.isNotEmpty && - snapshot.accounts.every( - (account) => account.provider == 'mock', - ) - ? 'mock' - : 'live', + 'schemaVersion': 9, + 'dataMode': mockDataMode || isLegacyMockProvider ? 'mock' : 'live', 'generatedAt': snapshot.generatedAt.toUtc().toIso8601String(), 'overallStatus': snapshot.overallStatus.wireName, 'manualRefreshAllowed': window.allowed, - 'manualRefreshAvailableAt': - window.availableAt?.toUtc().toIso8601String(), + 'manualRefreshAvailableAt': window.availableAt?.toUtc().toIso8601String(), 'rings': [ - for (final ring in rings) + for (final (ring, split) in pairCursorPools(rings)) { 'id': ring.id, - 'label': ring.label, + 'label': _ringLabel(ring), // Round for glanceable surfaces — avoid float noise like 24.800000000000004. 'usedPercent': double.parse( (ring.usedPercent ?? 0).toStringAsFixed(1), ), 'status': ring.status.wireName, + // Budget strips read money, so it travels as structure — the + // currency code included, or Wear would have to assume one. + 'spent': _moneyToJson(ring.spent), + 'limit': _moneyToJson(ring.limit), + 'split': split == null ? null : _splitToJson(split), }, ], 'creditsGlance': creditsGlance?.toJson(), @@ -123,17 +120,16 @@ class WatchDashboardSummaryPayload { 'allowances': [ for (final account in snapshot.accounts) for (final allowance in account.allowances) - if (displayPreferences.allows(allowance.source)) - { - 'source': allowance.source.name, - // Disambiguate multi-provider Usage rows on Wear (no schema bump). - 'label': '${account.providerLabel} · ${allowance.label}', - 'usedPercent': allowance.usedPercent, - 'remaining': _quantityToJson(allowance.remaining), - if (allowance.unlimited) 'unlimited': true, - 'resetsAt': allowance.resetsAt?.toUtc().toIso8601String(), - 'status': allowance.status.wireName, - }, + { + 'source': allowance.source.name, + // Disambiguate multi-provider Usage rows on Wear (no schema bump). + 'label': '${account.providerLabel} · ${allowance.label}', + 'usedPercent': allowance.usedPercent, + 'remaining': _quantityToJson(allowance.remaining), + if (allowance.unlimited) 'unlimited': true, + 'resetsAt': allowance.resetsAt?.toUtc().toIso8601String(), + 'status': allowance.status.wireName, + }, ], 'providers': [ for (final account in snapshot.accounts) @@ -168,6 +164,22 @@ extension _ProviderStatusWireName on ProviderStatus { } } +/// Budget rings travel named (`Anthropic platform · Month`): the connection is +/// the phone's vocabulary, and three bare `Month` rows read alike on Wear. +/// Plan windows keep the pool name that Glance prefixes with its family. +Map _splitToJson(WatchRingMetric ring) { + return { + 'id': ring.id, + 'label': _ringLabel(ring), + 'usedPercent': double.parse((ring.usedPercent ?? 0).toStringAsFixed(1)), + 'status': ring.status.wireName, + }; +} + +String _ringLabel(WatchRingMetric ring) { + return ring.id.startsWith('budget.') ? ring.catalogTitle : ring.label; +} + Map _budgetToJson(BudgetState budget) { return { 'period': budget.period, diff --git a/apps/phone_flutter/lib/watchface/watchface_screen.dart b/apps/phone_flutter/lib/watchface/watchface_screen.dart new file mode 100644 index 0000000..718edd8 --- /dev/null +++ b/apps/phone_flutter/lib/watchface/watchface_screen.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; + +import '../app/catalog_outline.dart'; +import '../app/pull_to_refresh_list.dart'; +import '../dashboard/dashboard_models.dart'; +import '../settings/watch_ring_preferences.dart'; + +/// Wear / WFF ring slots and a compact preview of the next watch payload. +class WatchfaceScreen extends StatefulWidget { + const WatchfaceScreen({ + super.key, + required this.snapshot, + required this.ringPreferences, + required this.onRingPreferencesChanged, + required this.onRefresh, + }); + + final DashboardSnapshot? snapshot; + final WatchRingPreferences ringPreferences; + final Future Function(WatchRingPreferences value) + onRingPreferencesChanged; + + /// Pull-to-refresh reload, shared with the app-bar action. + final Future Function() onRefresh; + + @override + State createState() => _WatchfaceScreenState(); +} + +class _WatchfaceScreenState extends State { + /// Catalog ids, never resolved ones: a pair is one row here, and a checkbox + /// that looked for its pools would never find itself. + List get _effectiveRingIds => + watchRingSelectionIds(widget.snapshot, widget.ringPreferences); + + Future _toggleRing(WatchRingMetric metric, bool selected) async { + if (!metric.isAvailable) { + return; + } + final ids = [..._effectiveRingIds]; + if (selected) { + if (ids.contains(metric.id) || + watchRingSlotCost([...ids, metric.id]) > watchRingSlotCount) { + return; + } + ids.add(metric.id); + } else { + ids.remove(metric.id); + } + try { + await widget.onRingPreferencesChanged( + WatchRingPreferences(selectedIds: ids), + ); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not update ring slots')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final snapshot = widget.snapshot; + final selectedIds = _effectiveRingIds; + final catalog = watchRingCatalog(snapshot); + // Ticked rows, not stored ids: without a snapshot the card has no rows at + // all, and the stored selection would claim slots nothing on screen shows. + // Counted by band, so both Cursor pools together cost one. + final usedSlots = watchRingSlotCost([ + for (final metric in catalog) + if (selectedIds.contains(metric.id)) metric.id, + ]); + + return PullToRefreshList( + onRefresh: widget.onRefresh, + children: [ + const _SectionHeader(title: 'Ring slots'), + Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + leading: const Icon(Icons.watch_outlined), + title: const Text('Wear & watch face'), + subtitle: Text( + // The count, not the cap: a full stack greys every unpicked + // row, and without it that reads as a fault rather than a + // slot to free. + '$usedSlots of $watchRingSlotCount slots used · ' + 'unavailable ones stay off the watch', + ), + ), + if (catalog.isEmpty) ...[ + const Divider(height: 1), + const ListTile( + title: Text('No metrics yet'), + subtitle: Text( + 'Connect a provider under Providers · plan windows and ' + 'budget limits fill these slots.', + ), + ), + ], + CatalogOutline( + catalog: catalog, + rowBuilder: + (metric, title) => _WatchRingTile( + metric: metric, + title: title, + selected: selectedIds.contains(metric.id), + atCapacity: + watchRingSlotCost([...selectedIds, metric.id]) > + watchRingSlotCount, + onChanged: (value) => _toggleRing(metric, value), + ), + ), + ], + ), + ), + if (snapshot != null) ...[ + const SizedBox(height: 16), + const _SectionHeader(title: 'Preview'), + Card( + child: ListTile( + leading: const Icon(Icons.preview_outlined), + title: const Text('Next watch payload'), + subtitle: Text( + watchRingPayloadSubtitle(snapshot, widget.ringPreferences), + ), + ), + ), + ], + ], + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(title, style: Theme.of(context).textTheme.titleMedium), + ); + } +} + +class _WatchRingTile extends StatelessWidget { + const _WatchRingTile({ + required this.metric, + required this.title, + required this.selected, + required this.atCapacity, + required this.onChanged, + }); + + final WatchRingMetric metric; + final String title; + final bool selected; + final bool atCapacity; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final canToggle = metric.isAvailable && (selected || !atCapacity); + final reason = metric.unavailableReason; + return CheckboxListTile( + secondary: + reason == null + ? const Icon(Icons.data_usage_outlined) + : Tooltip(message: reason, child: const Icon(Icons.help_outline)), + title: Text(title), + subtitle: Text(metric.catalogSubtitle), + value: selected, + onChanged: canToggle ? (value) => onChanged(value ?? false) : null, + ); + } +} diff --git a/apps/phone_flutter/lib/widget/phone_widget_payload.dart b/apps/phone_flutter/lib/widget/phone_widget_payload.dart new file mode 100644 index 0000000..2e7ce08 --- /dev/null +++ b/apps/phone_flutter/lib/widget/phone_widget_payload.dart @@ -0,0 +1,138 @@ +import '../dashboard/dashboard_models.dart'; +import '../dashboard/provider_status_color.dart'; +import '../settings/watch_ring_preferences.dart'; +import '../sync/credit_request_runway.dart'; +import '../sync/watch_credits_glance.dart'; +import 'phone_widget_preferences.dart'; + +/// One rendered home-widget row (remaining language). +final class PhoneWidgetRow { + const PhoneWidgetRow({ + required this.label, + required this.remainingPercent, + required this.accentArgb, + this.creditsSuffix, + }); + + final String label; + final int remainingPercent; + + /// ARGB int for the family accent bar. + final int accentArgb; + + /// Glance-style purchased credits for this row's provider (`320 credits`). + final String? creditsSuffix; + + /// Fixed percent column for the launcher table layout. + String get percentText => '$remainingPercent% left'; + + /// Widget-tab / tests line: `% left` · label · optional credits (matches columns). + String get line { + final credits = creditsSuffix; + if (credits == null) { + return '$percentText · $label'; + } + return '$percentText · $label · $credits'; + } +} + +/// Snapshot + prefs → home-widget rows (unavailable already omitted). +final class PhoneWidgetPayload { + const PhoneWidgetPayload({required this.rows, required this.stale}); + + final List rows; + final bool stale; + + bool get isEmpty => rows.isEmpty; +} + +/// Builds the launcher payload. Tightest-first; exhausted plan pools stay as 0%. +PhoneWidgetPayload buildPhoneWidgetPayload( + DashboardSnapshot snapshot, + PhoneWidgetPreferences preferences, { + int maxSlots = phoneWidgetSlotCount, +}) { + final metrics = orderPhoneWidgetMetrics( + resolvePhoneWidgetMetrics(snapshot, preferences), + maxSlots: maxSlots, + ); + return PhoneWidgetPayload( + stale: snapshot.overallStatus == ProviderStatus.stale, + rows: [ + for (final metric in metrics) + PhoneWidgetRow( + label: metric.catalogTitle, + remainingPercent: metric.remainingPercent!.round(), + accentArgb: phoneWidgetAccentArgb(metric.id), + creditsSuffix: phoneWidgetCreditsSuffix(snapshot, metric.id), + ), + ], + ); +} + +/// Per-provider purchased credits suffix for a **plan** metric row, or null. +/// +/// Matches Wear Glance / face strips: append finite purchased `credits` on plan +/// windows only. Purchased-meter rows (Extra usage, on-demand) already *are* the +/// credit pool — do not repeat. Budgets and missing/unlimited balances omit. +String? phoneWidgetCreditsSuffix(DashboardSnapshot snapshot, String metricId) { + final provider = providerFromRingId(metricId); + if (provider == null) { + return null; + } + if (_isPurchasedMeterMetric(snapshot, provider, metricId)) { + return null; + } + final remaining = purchasedCreditsRemainingForProvider(snapshot, provider); + if (remaining == null || remaining <= 0) { + return null; + } + return '${compactCreditCount(remaining)} credits'; +} + +bool _isPurchasedMeterMetric( + DashboardSnapshot snapshot, + String provider, + String metricId, +) { + const prefix = 'allowance.'; + final stem = '$prefix$provider.'; + if (!metricId.startsWith(stem)) { + return false; + } + final allowanceId = metricId.substring(stem.length); + for (final account in snapshot.accounts) { + if (account.provider != provider) { + continue; + } + for (final allowance in account.allowances) { + if (allowance.id == allowanceId) { + return allowance.source == AllowanceSource.purchased; + } + } + } + return false; +} + +/// Family accent matching [providerFamilyColor]; neutral grey when no family is +/// named, which should not happen for a metric the product ships. +/// +/// A budget row carries the connection it belongs to, so it takes that family +/// (`budget.anthropic.platform.month`) rather than a colour of its own. +int phoneWidgetAccentArgb(String metricId) { + if (metricId.contains('claude') || metricId.contains('anthropic')) { + return providerFamilyColor('claude').toARGB32(); + } + // Before the family branch, or it would never be reached: the pool name sits + // inside a `cursor` id (`WATCH_RING_DESIGN.md`, Split band). + if (metricId.contains('cursor-plan-models')) { + return familyCursorOwnColor.toARGB32(); + } + if (metricId.contains('cursor')) { + return providerFamilyColor('cursor').toARGB32(); + } + if (metricId.contains('codex') || metricId.contains('openai')) { + return providerFamilyColor('codex').toARGB32(); + } + return familyFallbackColor.toARGB32(); +} diff --git a/apps/phone_flutter/lib/widget/phone_widget_preferences.dart b/apps/phone_flutter/lib/widget/phone_widget_preferences.dart new file mode 100644 index 0000000..d49f620 --- /dev/null +++ b/apps/phone_flutter/lib/widget/phone_widget_preferences.dart @@ -0,0 +1,242 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '../dashboard/dashboard_models.dart'; +import '../settings/watch_ring_preferences.dart'; + +/// Prefs / payload cap for the phone home-widget (launcher may show fewer rows). +const phoneWidgetSlotCount = 6; + +/// Ordered metric ids for the phone home-screen widget (independent of Watchface). +/// +/// `selectedIds == null` means unset → use the default available metrics. +/// An empty list means the user chose zero metrics. +final class PhoneWidgetPreferences { + const PhoneWidgetPreferences({this.selectedIds}); + + final List? selectedIds; + + bool get usesDefaults => selectedIds == null; + + List get clampedIds => (selectedIds ?? const []) + .take(phoneWidgetSlotCount) + .toList(growable: false); + + /// Clamped selection; Claude windows stay expanded (no Watchface collapse). + List get migratedIds => migratePhoneWidgetSelectedIds(clampedIds); +} + +/// Storage → prefs, reading an all-retired selection as unset. +/// +/// Mirrors [watchRingPreferencesFromStoredIds]: an empty stored list is the +/// user's "no metrics", while an emptied one only means the ids are gone. +PhoneWidgetPreferences phoneWidgetPreferencesFromStoredIds(List ids) { + final migrated = migratePhoneWidgetSelectedIds(ids); + if (ids.isNotEmpty && migrated.isEmpty) { + return const PhoneWidgetPreferences(); + } + return PhoneWidgetPreferences(selectedIds: migrated); +} + +/// Phone-widget prefs migration: keep Claude windows and purchased meters. +/// +/// Rewrites the retired Watchface-only [claudePlanRingId] into expanded window +/// ids so an older Claude selection is not dropped on upgrade, and drops the +/// [retiredBudgetRingIds] the summed budgets used to occupy. +List migratePhoneWidgetSelectedIds(List ids) { + final out = []; + for (final id in ids) { + if (retiredBudgetRingIds.contains(id)) { + continue; + } + if (id == claudePlanRingId) { + for (final windowId in claudePlanWindowRingIds) { + if (!out.contains(windowId)) { + out.add(windowId); + } + } + continue; + } + if (!out.contains(id)) { + out.add(id); + } + } + return out.take(phoneWidgetSlotCount).toList(growable: false); +} + +/// Phone home-widget catalog: budgets, every Claude plan window, purchased meters. +List phoneWidgetCatalog(DashboardSnapshot? snapshot) { + return watchRingCatalog( + snapshot, + includePurchased: true, + collapseClaudePlan: false, + // The widget has rows, not bands: nothing to split, so both Cursor pools + // stay selectable on their own. + ); +} + +/// Resolves widget metrics: available percent metrics, prefs order. +/// +/// Exhausted meters (`usedPercent >= 100`) stay selectable / visible as 0% left +/// so sibling pools (e.g. Cursor Models + Other Models) remain together. +List resolvePhoneWidgetMetrics( + DashboardSnapshot snapshot, + PhoneWidgetPreferences preferences, +) { + if (preferences.usesDefaults) { + return _defaultPhoneWidgetMetrics(snapshot); + } + + final catalog = { + for (final metric in phoneWidgetCatalog(snapshot)) metric.id: metric, + }; + return [ + for (final id in preferences.migratedIds) + if (catalog[id] case final metric? when metric.isAvailable) metric, + ]; +} + +/// Defaults: plan windows first, then purchased, then budgets. +List _defaultPhoneWidgetMetrics(DashboardSnapshot snapshot) { + final purchasedIds = _purchasedAllowanceRingIds(snapshot); + final plans = []; + final purchased = []; + final budgets = []; + for (final metric in phoneWidgetCatalog(snapshot)) { + if (!metric.isAvailable) { + continue; + } + if (metric.id.startsWith('budget.')) { + budgets.add(metric); + } else if (purchasedIds.contains(metric.id)) { + purchased.add(metric); + } else { + plans.add(metric); + } + } + return [ + ...plans, + ...purchased, + ...budgets, + ].take(phoneWidgetSlotCount).toList(growable: false); +} + +Set _purchasedAllowanceRingIds(DashboardSnapshot snapshot) { + return { + for (final account in snapshot.accounts) + for (final allowance in account.allowances) + if (allowance.source == AllowanceSource.purchased) + 'allowance.${account.provider}.${allowance.id}', + }; +} + +/// Display order for the launcher: tightest remaining first; keep 0% left rows. +List orderPhoneWidgetMetrics( + List metrics, { + int maxSlots = phoneWidgetSlotCount, +}) { + final rows = [...metrics]; + rows.sort((a, b) { + final usedCmp = (b.usedPercent ?? 0).compareTo(a.usedPercent ?? 0); + if (usedCmp != 0) { + return usedCmp; + } + return a.id.compareTo(b.id); + }); + return rows.take(maxSlots).toList(growable: false); +} + +/// Short subtitle for Widget tab preview. +String phoneWidgetPayloadSubtitle( + DashboardSnapshot snapshot, + PhoneWidgetPreferences preferences, +) { + final metrics = orderPhoneWidgetMetrics( + resolvePhoneWidgetMetrics(snapshot, preferences), + ); + if (metrics.isEmpty) { + return 'No metrics selected'; + } + if (metrics.length == 1) { + final metric = metrics.single; + return '${metric.catalogTitle} ${metric.remainingPercent!.round()}% left'; + } + return '${metrics.length} metrics · ' + '${metrics.map((m) => m.catalogTitle).join(', ')}'; +} + +abstract interface class PhoneWidgetPreferenceStore { + Future read(); + + Future write(PhoneWidgetPreferences value); +} + +final class SecurePhoneWidgetPreferenceStore + implements PhoneWidgetPreferenceStore { + SecurePhoneWidgetPreferenceStore({FlutterSecureStorage? storage}) + : _storage = storage ?? const FlutterSecureStorage(); + + static const _key = 'wardpulse.widget.selectedIds'; + + final FlutterSecureStorage _storage; + + @override + Future read() async { + final raw = await _storage.read(key: _key); + if (raw == null || raw.trim().isEmpty) { + return const PhoneWidgetPreferences(); + } + try { + final decoded = jsonDecode(raw); + if (decoded is! List) { + return const PhoneWidgetPreferences(); + } + final ids = [ + for (final entry in decoded) + if (entry is String) entry, + ]; + final preferences = phoneWidgetPreferencesFromStoredIds(ids); + final migrated = preferences.selectedIds; + if (migrated == null) { + await _storage.delete(key: _key); + } else if (!_sameIds(ids.take(phoneWidgetSlotCount).toList(), migrated)) { + await _storage.write(key: _key, value: jsonEncode(migrated)); + } + return preferences; + } on FormatException { + return const PhoneWidgetPreferences(); + } + } + + @override + Future write(PhoneWidgetPreferences value) { + if (value.selectedIds == null) { + return _storage.delete(key: _key); + } + return _storage.write(key: _key, value: jsonEncode(value.migratedIds)); + } +} + +bool _sameIds(List a, List b) { + if (a.length != b.length) { + return false; + } + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) { + return false; + } + } + return true; +} + +final class DefaultPhoneWidgetPreferenceStore + implements PhoneWidgetPreferenceStore { + const DefaultPhoneWidgetPreferenceStore(); + + @override + Future read() async => const PhoneWidgetPreferences(); + + @override + Future write(PhoneWidgetPreferences value) async {} +} diff --git a/apps/phone_flutter/lib/widget/phone_widget_sync.dart b/apps/phone_flutter/lib/widget/phone_widget_sync.dart new file mode 100644 index 0000000..43a5851 --- /dev/null +++ b/apps/phone_flutter/lib/widget/phone_widget_sync.dart @@ -0,0 +1,147 @@ +import 'package:home_widget/home_widget.dart'; + +import '../dashboard/dashboard_models.dart'; +import 'phone_widget_payload.dart'; +import 'phone_widget_preferences.dart'; + +/// Android AppWidgetProvider simple class name (see `WardPulseAppWidget`). +const phoneWidgetProviderName = 'WardPulseAppWidget'; + +/// Fully-qualified Android provider for [HomeWidget.updateWidget]. +const phoneWidgetQualifiedAndroidName = 'app.wardpulse.WardPulseAppWidget'; + +/// `developer.log` channel for launcher-widget failures. +const phoneWidgetLogName = 'WardPulse.PhoneWidget'; + +/// Pushes [snapshot] metrics onto the Android home-screen widget. +/// +/// Throws when the write fails. Callers decide what a stale launcher tile is +/// worth to them: the foreground app keeps rendering, the headless worker keeps +/// its run green. +abstract interface class PhoneWidgetSyncService { + Future sync( + DashboardSnapshot snapshot, + PhoneWidgetPreferences preferences, + ); +} + +/// No-op for widget tests and hosts without a launcher surface. +final class DisabledPhoneWidgetSyncService implements PhoneWidgetSyncService { + const DisabledPhoneWidgetSyncService(); + + @override + Future sync( + DashboardSnapshot snapshot, + PhoneWidgetPreferences preferences, + ) async {} +} + +typedef PhoneWidgetPayloadWriter = + Future Function(PhoneWidgetPayload payload); + +/// Serializes overlapping syncs in one Flutter isolate (latest wins). +/// +/// Foreground UI and headless WorkManager each get their own isolate, so this +/// does not cross-engine-lock SharedPreferences writes — only coalesces callers +/// that share the same engine. +final class PhoneWidgetSyncCoordinator { + PhoneWidgetSyncCoordinator({required PhoneWidgetPayloadWriter write}) + : _write = write; + + final PhoneWidgetPayloadWriter _write; + + int _epoch = 0; + DashboardSnapshot? _latestSnapshot; + PhoneWidgetPreferences? _latestPreferences; + Future _chain = Future.value(); + + /// Queues a write. Overlapping callers coalesce to the newest args. + /// + /// A failure reaches the caller but must not poison the queue, so the chain + /// continues from a settled future while the returned one still throws. + Future sync( + DashboardSnapshot snapshot, + PhoneWidgetPreferences preferences, + ) { + _latestSnapshot = snapshot; + _latestPreferences = preferences; + final epoch = ++_epoch; + final run = _chain.then((_) => _runIfCurrent(epoch)); + _chain = run.catchError((_) {}); + return run; + } + + Future _runIfCurrent(int epoch) async { + if (epoch != _epoch) { + return; + } + final snapshot = _latestSnapshot; + final preferences = _latestPreferences; + if (snapshot == null || preferences == null) { + return; + } + await _write(buildPhoneWidgetPayload(snapshot, preferences)); + } +} + +/// Writes payload keys for [WardPulseAppWidget] via `home_widget`. +final class HomeWidgetPhoneWidgetSyncService implements PhoneWidgetSyncService { + HomeWidgetPhoneWidgetSyncService({PhoneWidgetSyncCoordinator? coordinator}) + : _coordinator = coordinator ?? _sharedCoordinator; + + static final PhoneWidgetSyncCoordinator _sharedCoordinator = + PhoneWidgetSyncCoordinator(write: writeHomeWidgetPayload); + + final PhoneWidgetSyncCoordinator _coordinator; + + static const _maxRows = phoneWidgetSlotCount; + + @override + Future sync( + DashboardSnapshot snapshot, + PhoneWidgetPreferences preferences, + ) { + return _coordinator.sync(snapshot, preferences); + } + + /// Persists [payload] and asks the launcher to redraw. + static Future writeHomeWidgetPayload(PhoneWidgetPayload payload) async { + await HomeWidget.saveWidgetData('stale', payload.stale ? '1' : '0'); + await HomeWidget.saveWidgetData( + 'empty', + payload.isEmpty ? '1' : '0', + ); + final rows = payload.rows.take(_maxRows).toList(growable: false); + await HomeWidget.saveWidgetData('row_count', rows.length); + for (var i = 0; i < _maxRows; i++) { + if (i < rows.length) { + final row = rows[i]; + await HomeWidget.saveWidgetData( + 'row_${i}_percent', + row.percentText, + ); + await HomeWidget.saveWidgetData( + 'row_${i}_credits', + row.creditsSuffix ?? '', + ); + await HomeWidget.saveWidgetData('row_${i}_label', row.label); + // Hex string — ARGB ints with the high bit set arrive as Long on Android + // and ClassCastException SharedPreferences.getInt. + await HomeWidget.saveWidgetData( + 'row_${i}_color', + row.accentArgb.toRadixString(16).padLeft(8, '0'), + ); + } else { + await HomeWidget.saveWidgetData('row_${i}_percent', ''); + await HomeWidget.saveWidgetData('row_${i}_credits', ''); + await HomeWidget.saveWidgetData('row_${i}_label', ''); + await HomeWidget.saveWidgetData('row_${i}_color', ''); + } + } + await HomeWidget.updateWidget( + name: phoneWidgetProviderName, + androidName: phoneWidgetProviderName, + qualifiedAndroidName: phoneWidgetQualifiedAndroidName, + ); + } +} diff --git a/apps/phone_flutter/lib/widget/widget_screen.dart b/apps/phone_flutter/lib/widget/widget_screen.dart new file mode 100644 index 0000000..34dcb4f --- /dev/null +++ b/apps/phone_flutter/lib/widget/widget_screen.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; + +import '../app/catalog_outline.dart'; +import '../app/pull_to_refresh_list.dart'; +import '../dashboard/dashboard_models.dart'; +import '../settings/watch_ring_preferences.dart'; +import 'phone_widget_preferences.dart'; + +/// Phone home-widget metric slots and a compact preview of the next widget payload. +class WidgetScreen extends StatefulWidget { + const WidgetScreen({ + super.key, + required this.snapshot, + required this.preferences, + required this.onPreferencesChanged, + required this.onRefresh, + }); + + final DashboardSnapshot? snapshot; + final PhoneWidgetPreferences preferences; + final Future Function(PhoneWidgetPreferences value) + onPreferencesChanged; + + /// Pull-to-refresh reload, shared with the app-bar action. + final Future Function() onRefresh; + + @override + State createState() => _WidgetScreenState(); +} + +class _WidgetScreenState extends State { + List get _effectiveIds { + final snapshot = widget.snapshot; + if (snapshot == null) { + return widget.preferences.migratedIds; + } + return [ + for (final metric in resolvePhoneWidgetMetrics( + snapshot, + widget.preferences, + )) + metric.id, + ]; + } + + Future _toggleMetric(WatchRingMetric metric, bool selected) async { + if (!metric.isAvailable) { + return; + } + final ids = [..._effectiveIds]; + if (selected) { + if (ids.contains(metric.id) || ids.length >= phoneWidgetSlotCount) { + return; + } + ids.add(metric.id); + } else { + ids.remove(metric.id); + } + try { + await widget.onPreferencesChanged( + PhoneWidgetPreferences(selectedIds: ids), + ); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not update widget metrics')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final snapshot = widget.snapshot; + final selectedIds = _effectiveIds; + final catalog = phoneWidgetCatalog(snapshot); + // Ticked rows, not stored ids — same reason as Watchface. + final usedSlots = + catalog.where((metric) => selectedIds.contains(metric.id)).length; + + return PullToRefreshList( + onRefresh: widget.onRefresh, + children: [ + const _SectionHeader(title: 'Widget metrics'), + Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + leading: const Icon(Icons.widgets_outlined), + title: const Text('Home screen widget'), + subtitle: Text( + // Same reason as Watchface: the count explains a greyed row + // that the cap alone leaves looking broken. + '$usedSlots of $phoneWidgetSlotCount slots used · ' + 'independent of Watchface · Claude windows stay separate · ' + 'credits append like Glance · exhausted pools show as 0% left', + ), + ), + if (catalog.isEmpty) ...[ + const Divider(height: 1), + const ListTile( + title: Text('No metrics yet'), + subtitle: Text( + 'Connect a provider under Providers · plan windows and ' + 'budget limits fill these slots.', + ), + ), + ], + CatalogOutline( + catalog: catalog, + rowBuilder: + (metric, title) => _MetricTile( + metric: metric, + title: title, + selected: selectedIds.contains(metric.id), + atCapacity: selectedIds.length >= phoneWidgetSlotCount, + onChanged: (value) => _toggleMetric(metric, value), + ), + ), + ], + ), + ), + if (snapshot != null) ...[ + const SizedBox(height: 16), + const _SectionHeader(title: 'Preview'), + Card( + child: ListTile( + leading: const Icon(Icons.preview_outlined), + title: const Text('Next widget payload'), + subtitle: Text( + phoneWidgetPayloadSubtitle(snapshot, widget.preferences), + ), + ), + ), + ], + ], + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(title, style: Theme.of(context).textTheme.titleMedium), + ); + } +} + +class _MetricTile extends StatelessWidget { + const _MetricTile({ + required this.metric, + required this.title, + required this.selected, + required this.atCapacity, + required this.onChanged, + }); + + final WatchRingMetric metric; + final String title; + final bool selected; + final bool atCapacity; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final canToggle = metric.isAvailable && (selected || !atCapacity); + final reason = metric.unavailableReason; + return CheckboxListTile( + secondary: + reason == null + ? const Icon(Icons.data_usage_outlined) + : Tooltip(message: reason, child: const Icon(Icons.help_outline)), + title: Text(title), + subtitle: Text(metric.catalogSubtitle), + value: selected, + onChanged: canToggle ? (value) => onChanged(value ?? false) : null, + ); + } +} diff --git a/apps/phone_flutter/pubspec.lock b/apps/phone_flutter/pubspec.lock index f37624a..83da998 100644 --- a/apps/phone_flutter/pubspec.lock +++ b/apps/phone_flutter/pubspec.lock @@ -65,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + dbus: + dependency: transitive + description: + name: dbus + sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91" + url: "https://pub.dev" + source: hosted + version: "0.7.13" fake_async: dependency: transitive description: @@ -94,6 +102,46 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "1447ba911c60f2ba3f25dae1af151ec187162566b0f57e37771bf0b400f013ad" + url: "https://pub.dev" + source: hosted + version: "22.3.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b" + url: "https://pub.dev" + source: hosted + version: "8.0.1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "43c3761d916c9bd3d5c7ebbc44d82f4990329840c0c5d62ad5260cc1b5d399bd" + url: "https://pub.dev" + source: hosted + version: "12.2.0" + flutter_local_notifications_web: + dependency: transitive + description: + name: flutter_local_notifications_web + sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "6f43bdd03b171b7a90f22647506fea33e2bb12294b7c7c7a3d690e960a382945" + url: "https://pub.dev" + source: hosted + version: "3.1.1" flutter_secure_storage: dependency: "direct main" description: @@ -122,10 +170,10 @@ packages: dependency: transitive description: name: flutter_secure_storage_platform_interface - sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" + sha256: "06686df417f34fe9f963ed217b7fdce250e2f637ddd6c374d7eb054549770633" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.2" flutter_secure_storage_web: dependency: transitive description: @@ -152,6 +200,14 @@ packages: description: flutter source: sdk version: "0.0.0" + home_widget: + dependency: "direct main" + description: + name: home_widget + sha256: fece84c7464099873c3ace38394ad7053509a4b0e36e57a1105a6aa31f47f23c + url: "https://pub.dev" + source: hosted + version: "0.9.3" hooks: dependency: transitive description: @@ -160,22 +216,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" jni: dependency: transitive description: name: jni - sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.3" jni_flutter: dependency: transitive description: name: jni_flutter - sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.0" leak_tracker: dependency: transitive description: @@ -236,18 +316,18 @@ packages: dependency: transitive description: name: objective_c - sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e url: "https://pub.dev" source: hosted - version: "9.4.1" + version: "9.5.0" package_config: dependency: transitive description: name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "3.0.0" path: dependency: transitive description: @@ -304,6 +384,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -389,6 +477,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + timezone: + dependency: transitive + description: + name: timezone + sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" + url: "https://pub.dev" + source: hosted + version: "0.11.1" typed_data: dependency: transitive description: @@ -572,6 +668,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" yaml: dependency: transitive description: diff --git a/apps/phone_flutter/pubspec.yaml b/apps/phone_flutter/pubspec.yaml index a133434..0eb05e5 100644 --- a/apps/phone_flutter/pubspec.yaml +++ b/apps/phone_flutter/pubspec.yaml @@ -16,6 +16,8 @@ dependencies: workmanager: ^0.9.0+3 webview_flutter: ^4.14.1 webview_flutter_android: ^4.13.0 + home_widget: ^0.9.3 + flutter_local_notifications: ^22.3.0 dev_dependencies: flutter_test: diff --git a/apps/phone_flutter/test/alert_threshold_preferences_test.dart b/apps/phone_flutter/test/alert_threshold_preferences_test.dart new file mode 100644 index 0000000..4760b6b --- /dev/null +++ b/apps/phone_flutter/test/alert_threshold_preferences_test.dart @@ -0,0 +1,156 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ward_pulse_phone/providers/provider_connection.dart'; +import 'package:ward_pulse_phone/settings/alert_threshold_preferences.dart'; + +void main() { + test('defaults are opt-in off', () { + const prefs = AlertThresholdPreferences(); + expect(prefs.hasEnabledRules, isFalse); + expect( + prefs.forConnection(ProviderConnections.codexPlan).isEnabled, + isFalse, + ); + }); + + test('hasEnabledRules is true when any connection rule is set', () { + expect( + const AlertThresholdPreferences() + .withConnection( + ProviderConnections.openAiPlatform, + const ConnectionAlertThresholds( + today: AlertPercentThreshold(at: 80), + ), + ) + .hasEnabledRules, + isTrue, + ); + expect( + const AlertThresholdPreferences() + .withConnection( + ProviderConnections.codexPlan, + const ConnectionAlertThresholds( + plan: AlertPercentThreshold(at: 100), + ), + ) + .hasEnabledRules, + isTrue, + ); + }); + + test('round-trips JSON and drops empty connection rules', () { + final prefs = AlertThresholdPreferences( + connections: { + ProviderConnections + .codexPlan + .storageKey: const ConnectionAlertThresholds( + plan: AlertPercentThreshold(at: 70), + ), + ProviderConnections.claudePlan.storageKey: + const ConnectionAlertThresholds(), + }, + ); + + final decoded = AlertThresholdPreferences.decode(prefs.encode()); + expect(decoded.forConnection(ProviderConnections.codexPlan).plan.at, 70); + expect( + decoded.connections.containsKey( + ProviderConnections.claudePlan.storageKey, + ), + isFalse, + ); + }); + + test('reads legacy warnAt / criticalAt as a single at threshold', () { + final decoded = AlertThresholdPreferences.decode( + '{"connections":{"openai.plan":{"plan":{"warnAt":80,"criticalAt":100}}}}', + ); + expect(decoded.forConnection(ProviderConnections.codexPlan).plan.at, 80); + + final criticalOnly = AlertPercentThreshold.fromJson({'criticalAt': 100}); + expect(criticalOnly.at, 100); + }); + + test('drops global budget rules saved before they moved to connections', () { + final decoded = AlertThresholdPreferences.decode( + '{"today":{"at":80},"week":{},"month":{},"connections":{}}', + ); + + expect(decoded.hasEnabledRules, isFalse); + }); + + test('encode omits disabled thresholds', () { + final prefs = const AlertThresholdPreferences().withConnection( + ProviderConnections.openAiPlatform, + const ConnectionAlertThresholds(today: AlertPercentThreshold(at: 80)), + ); + expect(prefs.encode(), contains('"today":{"at":80}')); + expect(prefs.encode(), contains('"week":{}')); + expect(prefs.encode(), isNot(contains('warnAt'))); + expect(prefs.encode(), isNot(contains('criticalAt'))); + }); + + test('a budget limit alone survives storage', () { + // A limit without a percentage is still a setting worth keeping: the user + // may add the threshold later, and withConnection drops disabled rules. + final prefs = const AlertThresholdPreferences().withConnection( + ProviderConnections.openAiPlatform, + const ConnectionAlertThresholds(budget: ConnectionBudget(month: 5000)), + ); + + final decoded = AlertThresholdPreferences.decode(prefs.encode()); + final saved = decoded.forConnection(ProviderConnections.openAiPlatform); + + expect(saved.budget.month, 5000); + // Stored, but not an alert: the Providers bell must stay quiet. + expect(saved.isEnabled, isTrue); + expect(saved.hasAlertRules, isFalse); + }); + + test('clearing every limit drops the rule', () { + final prefs = const AlertThresholdPreferences().withConnection( + ProviderConnections.openAiPlatform, + const ConnectionAlertThresholds(), + ); + + expect(prefs.hasEnabledRules, isFalse); + expect(prefs.connections, isEmpty); + }); + + test('remaining stops map to used percent for storage', () { + expect(alertRemainingToUsed(null), isNull); + expect(alertRemainingToUsed(50), 50); + expect(alertRemainingToUsed(30), 70); + expect(alertRemainingToUsed(20), 80); + expect(alertRemainingToUsed(10), 90); + expect(alertRemainingToUsed(0), 100); + + expect(alertUsedToRemaining(null), isNull); + expect(alertUsedToRemaining(70), 30); + expect(alertUsedToRemaining(100), 0); + expect(alertUsedToRemaining(75), isNull); + }); + + test('withConnection removes disabled rules', () { + final enabled = const AlertThresholdPreferences().withConnection( + ProviderConnections.cursorPlan, + const ConnectionAlertThresholds( + purchased: AlertPercentThreshold(at: 100), + ), + ); + expect( + enabled.forConnection(ProviderConnections.cursorPlan).isEnabled, + isTrue, + ); + + final cleared = enabled.withConnection( + ProviderConnections.cursorPlan, + const ConnectionAlertThresholds(), + ); + expect( + cleared.connections.containsKey( + ProviderConnections.cursorPlan.storageKey, + ), + isFalse, + ); + }); +} diff --git a/apps/phone_flutter/test/app_test.dart b/apps/phone_flutter/test/app_test.dart index 47c252f..7598996 100644 --- a/apps/phone_flutter/test/app_test.dart +++ b/apps/phone_flutter/test/app_test.dart @@ -6,19 +6,25 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ward_pulse_phone/app/ward_pulse_app.dart'; import 'package:ward_pulse_phone/dashboard/dashboard_models.dart'; +import 'package:ward_pulse_phone/dashboard/plan_recoveries.dart'; +import 'package:ward_pulse_phone/dashboard/provider_status_color.dart'; import 'package:ward_pulse_phone/dashboard/dashboard_repository.dart'; import 'package:ward_pulse_phone/providers/provider_connection.dart'; import 'package:ward_pulse_phone/providers/provider_credential_store.dart'; -import 'package:ward_pulse_phone/settings/consumption_display_preferences.dart'; +import 'package:ward_pulse_phone/settings/alert_threshold_preferences.dart'; import 'package:ward_pulse_phone/settings/watch_ring_preferences.dart'; import 'package:ward_pulse_phone/settings/debug_data_preferences.dart'; import 'package:ward_pulse_phone/settings/refresh_interval_preferences.dart'; +import 'package:ward_pulse_phone/widget/phone_widget_preferences.dart'; import 'package:ward_pulse_phone/sync/poll_cadence.dart'; import 'package:ward_pulse_phone/sync/provider_sync_scheduler.dart'; +import 'package:ward_pulse_phone/settings/recovery_notification_preferences.dart'; +import 'package:ward_pulse_phone/sync/recovery_notifications.dart'; +import 'package:ward_pulse_phone/sync/recovery_watchlist.dart'; import 'package:ward_pulse_phone/sync/watch_sync_service.dart'; void main() { - testWidgets('renders mock history and opens provider details', ( + testWidgets('renders mock history and opens Providers catalog', ( tester, ) async { final fixture = @@ -67,18 +73,16 @@ void main() { await tester.tap(find.text('Providers')); await tester.pumpAndSettle(); - expect(find.text('Mock'), findsOneWidget); - await tester.tap(find.text('Mock')); - await tester.pumpAndSettle(); - - expect(find.textContaining('mock-local'), findsOneWidget); - expect(find.text('Today'), findsOneWidget); + // Which family sits where is the tab's own business + // (providers_screen_test.dart); this test only asks that the catalog + // opened, so it scrolls to the row instead of assuming a position. await tester.scrollUntilVisible( - find.text('Usage history'), + find.text('Codex subscription'), 300, scrollable: find.byType(Scrollable).first, ); - expect(find.text('4 buckets'), findsOneWidget); + expect(find.text('Codex subscription'), findsOneWidget); + expect(find.text('Not connected'), findsWidgets); }); testWidgets('syncs an empty watch summary when no providers are connected', ( @@ -107,17 +111,17 @@ void main() { final repository = _CountingDashboardRepository(() { loads += 1; return DashboardSnapshot.empty( - generatedAt: DateTime.utc(2026, 7, 26, 12).subtract( - Duration(minutes: loads == 1 ? 10 : 0), - ), + generatedAt: DateTime.utc( + 2026, + 7, + 26, + 12, + ).subtract(Duration(minutes: loads == 1 ? 10 : 0)), ); }); await tester.pumpWidget( - WardPulseApp( - repository: repository, - watchSyncService: watchSyncService, - ), + WardPulseApp(repository: repository, watchSyncService: watchSyncService), ); await tester.pumpAndSettle(); expect(loads, 1); @@ -161,6 +165,386 @@ void main() { expect(find.text('Watch summary queued'), findsOneWidget); }); + testWidgets('Settings shows systemic sections without Watch display', ( + tester, + ) async { + final snapshot = DashboardSnapshot.fromJsonString( + File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), + ); + + await tester.pumpWidget( + WardPulseApp(repository: ValueDashboardRepository(snapshot)), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Settings')); + await tester.pumpAndSettle(); + + expect(find.text('Display'), findsNothing); + expect(find.text('Alerts'), findsNothing); + expect(find.text('Refresh'), findsOneWidget); + expect(find.text('Watch display'), findsNothing); + expect( + find.textContaining('Temporary until the Watchface tab'), + findsNothing, + ); + await tester.scrollUntilVisible( + find.text('Diagnostics'), + 300, + scrollable: find.byType(Scrollable).first, + ); + expect(find.text('Diagnostics'), findsOneWidget); + expect(find.text('Sync'), findsWidgets); + expect(find.text('Watch summary'), findsOneWidget); + }); + + testWidgets('slot cards say where metrics come from when there are none', ( + tester, + ) async { + await tester.pumpWidget( + WardPulseApp(repository: const NoProvidersDashboardRepository()), + ); + await tester.pumpAndSettle(); + + for (final tab in ['Watchface', 'Widget']) { + await tester.tap(find.text(tab)); + await tester.pumpAndSettle(); + // A bare header card would read as a broken screen. + expect(find.text('No metrics yet'), findsOneWidget, reason: tab); + expect(find.byType(CheckboxListTile), findsNothing, reason: tab); + } + }); + + testWidgets('a reload keeps the dashboard on screen', (tester) async { + final snapshot = DashboardSnapshot.fromJsonString( + File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), + ); + + await tester.pumpWidget( + WardPulseApp(repository: ValueDashboardRepository(snapshot)), + ); + await tester.pumpAndSettle(); + expect(find.text('Usage dashboard'), findsOneWidget); + + // The refresh used to blank the tab and draw it again, which read as a + // flicker over the numbers the reader was looking at. + await tester.tap(find.byTooltip('Refresh')); + await tester.pump(); + expect(find.text('Usage dashboard'), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + + await tester.pumpAndSettle(); + expect(find.text('Usage dashboard'), findsOneWidget); + }); + + testWidgets('slot count follows the rows, not a stored selection', ( + tester, + ) async { + final store = + _MemoryWatchRingStore() + ..value = const WatchRingPreferences( + selectedIds: [ + 'allowance.codex.plan', + 'budget.anthropic.platform.today', + 'budget.anthropic.platform.week', + ], + ); + + await tester.pumpWidget( + WardPulseApp( + repository: const _FailingDashboardRepository( + DashboardSyncIssue.providerUnavailable, + ), + watchRingPreferenceStore: store, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Watchface')); + await tester.pumpAndSettle(); + + // A failed load leaves no rows to tick, so counting the stored ids would + // claim three slots the screen does not show. + expect(find.text('No metrics yet'), findsOneWidget); + expect(find.textContaining('0 of 3 slots used'), findsOneWidget); + }); + + testWidgets('Watchface tab owns ring slots and payload preview', ( + tester, + ) async { + final snapshot = _asConnection( + DashboardSnapshot.fromJsonString( + File( + '../../fixtures/snapshots/dashboard_today.json', + ).readAsStringSync(), + ), + ); + final store = _MemoryWatchRingStore(); + + await tester.pumpWidget( + WardPulseApp( + repository: ValueDashboardRepository(snapshot), + watchRingPreferenceStore: store, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Watchface')); + await tester.pumpAndSettle(); + + expect(find.text('Ring slots'), findsOneWidget); + expect(find.text('Wear & watch face'), findsOneWidget); + expect(find.text('Next watch payload'), findsOneWidget); + // Budget rings are per connection, and the connection is a heading now. + expect(find.text('Anthropic'), findsWidgets); + + await tester.scrollUntilVisible( + find.text('Anthropic'), + 200, + scrollable: find.byType(Scrollable).first, + ); + // Defaults select available metrics; toggling Today off persists an explicit list. + final todayTile = + find.widgetWithText(CheckboxListTile, 'Today').hitTestable().first; + // A full stack greys every unpicked row; the count is what tells that + // apart from a fault, so it has to follow the selection. + expect(find.textContaining('3 of 3 slots used'), findsOneWidget); + await tester.tap(todayTile); + await tester.pumpAndSettle(); + + expect(store.value.selectedIds, isNotNull); + expect(find.textContaining('2 of 3 slots used'), findsOneWidget); + }); + + testWidgets('a metric with nothing to show stays listed and refuses a tap', ( + tester, + ) async { + final source = DashboardSnapshot.fromJsonString( + File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), + ); + final account = + Map.from(source.primaryAccount!.toJson()) + ..['accountId'] = 'anthropic-platform' + ..['provider'] = 'claude' + ..['connection'] = 'anthropic.platform' + // No limit set for the month, so the ring has no percentage to draw. + ..['month'] = { + 'period': 'month', + 'spent': {'minorUnits': 2500, 'currency': 'USD'}, + 'limit': null, + 'remaining': null, + 'usedPercent': null, + 'projectedTotal': null, + 'status': 'ok', + }; + final snapshot = DashboardSnapshot.fromJson({ + ...source.toJson(), + 'accounts': [account], + }); + + await tester.pumpWidget( + WardPulseApp( + repository: ValueDashboardRepository(snapshot), + watchRingPreferenceStore: _MemoryWatchRingStore(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Watchface')); + await tester.pumpAndSettle(); + + final month = find.widgetWithText(CheckboxListTile, 'Month'); + await tester.scrollUntilVisible( + month, + 200, + scrollable: find.byType(Scrollable).first, + ); + + // Still under its connection, saying why it cannot be picked, and the box + // refuses the tap — sunk rather than hidden. + expect(month, findsOneWidget); + expect( + find.descendant( + of: month, + matching: find.byTooltip( + 'Set this period’s limit under Providers · Budget limits.', + ), + ), + findsOneWidget, + ); + expect(tester.widget(month).onChanged, isNull); + }); + + testWidgets('Widget tab owns metrics independently of Watchface', ( + tester, + ) async { + final snapshot = _asConnection( + DashboardSnapshot.fromJsonString( + File( + '../../fixtures/snapshots/dashboard_today.json', + ).readAsStringSync(), + ), + ); + final store = _MemoryPhoneWidgetStore(); + + await tester.pumpWidget( + WardPulseApp( + repository: ValueDashboardRepository(snapshot), + phoneWidgetPreferenceStore: store, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Widget')); + await tester.pumpAndSettle(); + + expect(find.text('Widget metrics'), findsOneWidget); + expect(find.text('Home screen widget'), findsOneWidget); + // The picker is an outline now, so the preview sits below its headings. + await tester.scrollUntilVisible( + find.text('Next widget payload'), + 300, + scrollable: find.byType(Scrollable).first, + ); + expect(find.text('Next widget payload'), findsOneWidget); + expect(find.textContaining('independent of Watchface'), findsOneWidget); + // Its own copy of the count, on a screen with six slots instead of three. + expect(find.textContaining('of 6 slots used'), findsOneWidget); + + // The connection is a heading here now, so its rows carry only the period. + expect(find.text('Anthropic'), findsWidgets); + final todayTile = + find.widgetWithText(CheckboxListTile, 'Today').hitTestable().first; + await tester.tap(todayTile); + await tester.pumpAndSettle(); + + expect(store.value.selectedIds, isNotNull); + }); + + testWidgets('Providers Platform row saves a budget limit and its alert', ( + tester, + ) async { + final snapshot = DashboardSnapshot.fromJsonString( + File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), + ); + final store = _MemoryAlertThresholdStore(); + // A connection rule the budget dialog must not clobber on save. + store.value = store.value.withConnection( + ProviderConnections.codexPlan, + const ConnectionAlertThresholds(plan: AlertPercentThreshold(at: 90)), + ); + + await tester.pumpWidget( + WardPulseApp( + repository: ValueDashboardRepository(snapshot), + alertThresholdStore: store, + // Widget tests do not load libward_pulse_ffi.so; prefs UI only. + applyAlertSettings: (current, _) => current, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Providers')); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible( + find.text('Platform reporting'), + 300, + scrollable: find.byType(Scrollable).first, + ); + Finder platformButton(String tooltip) => find.descendant( + of: find.ancestor( + of: find.text('Platform reporting'), + matching: find.byType(ListTile), + ), + matching: find.byTooltip(tooltip), + ); + + // The limit comes first: a percentage is meaningless without one. + await tester.tap(platformButton('Budget limits')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Budget · Platform reporting'), findsOneWidget); + await tester.enterText(find.byType(TextField).first, '25'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + await tester.tap(platformButton('Alert thresholds')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Alerts · Platform reporting'), findsOneWidget); + expect(find.text('Today'), findsOneWidget); + expect(find.text('Week'), findsOneWidget); + expect(find.text('Month'), findsOneWidget); + + await tester.tap(find.text('Off').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('20% left').last); + await tester.pumpAndSettle(); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect( + store.value.forConnection(ProviderConnections.openAiPlatform).today.at, + 80, + ); + expect( + store.value + .forConnection(ProviderConnections.openAiPlatform) + .budget + .today, + 2500, + ); + expect( + store.value.forConnection(ProviderConnections.codexPlan).plan.at, + 90, + ); + }); + + testWidgets('Providers plan row can save connection alert thresholds', ( + tester, + ) async { + final snapshot = DashboardSnapshot.fromJsonString( + File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), + ); + final store = _MemoryAlertThresholdStore(); + + await tester.pumpWidget( + WardPulseApp( + repository: ValueDashboardRepository(snapshot), + alertThresholdStore: store, + applyAlertSettings: (current, _) => current, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Providers')); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible( + find.text('Codex subscription'), + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap( + find.descendant( + of: find.widgetWithText(ListTile, 'Codex subscription'), + matching: find.byTooltip('Alert thresholds'), + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('Alerts ·'), findsOneWidget); + // A plan connection offers window rules, not spend budgets. + expect(find.text('Plan'), findsOneWidget); + expect(find.text('Purchased'), findsOneWidget); + expect(find.text('Today'), findsNothing); + await tester.tap(find.text('Off').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('30% left').last); + await tester.pumpAndSettle(); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect( + store.value.forConnection(ProviderConnections.codexPlan).plan.at, + 70, + ); + }); + testWidgets('watch sync failure does not block the dashboard', ( tester, ) async { @@ -178,6 +562,9 @@ void main() { expect(find.text('WardPulse'), findsOneWidget); expect(find.text('Dashboard unavailable'), findsNothing); + // Nothing deviates, so the app bar says nothing at all + // (PHONE_DASHBOARD_DESIGN.md). + expect(find.byType(Badge), findsNothing); }); testWidgets('labels previous dashboard data as stale', (tester) async { @@ -190,8 +577,12 @@ void main() { ); await tester.pumpAndSettle(); - // App bar overall status plus the provider plaque on the dashboard. - expect(find.text('Stale'), findsWidgets); + // Staleness is an account state, so no card carries a glyph for it: the + // family answers with a count and the app bar with a way in — never the + // same mark twice down the screen (PHONE_DASHBOARD_DESIGN.md). + expect(find.byIcon(Icons.schedule), findsNothing); + expect(find.byTooltip('Stale'), findsWidgets); + expect(find.byTooltip('Go to stale'), findsOneWidget); expect( find.textContaining('Showing previous data · Updated'), findsOneWidget, @@ -204,13 +595,58 @@ void main() { find.byTooltip(DashboardSyncIssue.authentication.message), findsWidgets, ); - final staleIcon = find.byIcon(Icons.schedule).first; + final appBarMark = find.byTooltip('Go to stale'); expect( - tester.widget(staleIcon).color, - Theme.of(tester.element(staleIcon)).colorScheme.tertiary, + tester + .widget( + find.descendant(of: appBarMark, matching: find.byType(Badge)), + ) + .backgroundColor, + providerStatusChipColors( + Theme.of(tester.element(appBarMark)).colorScheme, + ProviderStatus.stale, + ).fill, ); }); + testWidgets('the app-bar mark opens the Dashboard at the first problem', ( + tester, + ) async { + final snapshot = + DashboardSnapshot.fromJsonString( + File( + '../../fixtures/snapshots/dashboard_today.json', + ).readAsStringSync(), + ).withStaleStatus(); + + await tester.pumpWidget( + WardPulseApp(repository: ValueDashboardRepository(snapshot)), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Settings')); + await tester.pumpAndSettle(); + expect(find.text('Refresh interval'), findsOneWidget); + + // A rollup earns its place by leading somewhere, so it works from any tab. + await tester.tap(find.byTooltip('Go to stale')); + await tester.pumpAndSettle(); + + // Settings is gone and the flagged section is the thing on screen — the + // dashboard title has scrolled off above it, which is the point. + expect(find.text('Refresh interval'), findsNothing); + expect(find.byTooltip('Stale'), findsWidgets); + expect(find.text('Usage dashboard'), findsNothing); + + // The request is spent: coming back later is an ordinary visit, not a + // repeat of a tap the reader made once. + await tester.tap(find.text('Providers')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Dashboard')); + await tester.pumpAndSettle(); + expect(find.text('Usage dashboard'), findsOneWidget); + }); + testWidgets('stores and masks an OpenAI Admin API key', (tester) async { final snapshot = DashboardSnapshot.fromJsonString( File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), @@ -224,7 +660,7 @@ void main() { ), ); await tester.pumpAndSettle(); - await tester.tap(find.text('Settings')); + await tester.tap(find.text('Providers')); await tester.pumpAndSettle(); await tester.scrollUntilVisible( @@ -255,6 +691,11 @@ void main() { await tester.pumpAndSettle(); expect(credentialStore.openAiSecret, 'secret-admin-key'); + await tester.scrollUntilVisible( + find.text('Connected'), + -300, + scrollable: find.byType(Scrollable).first, + ); expect(find.text('Connected'), findsOneWidget); expect(find.text('secret-admin-key'), findsNothing); }); @@ -272,7 +713,7 @@ void main() { ), ); await tester.pumpAndSettle(); - await tester.tap(find.text('Settings')); + await tester.tap(find.text('Providers')); await tester.pumpAndSettle(); await tester.scrollUntilVisible( @@ -302,24 +743,29 @@ void main() { expect(credentialStore.openAiSecret, 'secret-admin-key'); expect(credentialStore.openAiLabel, 'Work org key'); + await tester.scrollUntilVisible( + find.text('Work org key'), + -300, + scrollable: find.byType(Scrollable).first, + ); expect(find.text('Work org key'), findsOneWidget); expect(find.text('Platform reporting'), findsNothing); - final settingsList = find.descendant( + final providersList = find.descendant( of: find.byType(ListView), matching: find.byType(Scrollable), ); await tester.scrollUntilVisible( find.text('Anthropic'), 300, - scrollable: settingsList, + scrollable: providersList, ); expect(find.text('Anthropic'), findsOneWidget); await tester.scrollUntilVisible( find.text('Cursor'), 300, - scrollable: settingsList, + scrollable: providersList, ); expect(find.text('Cursor'), findsOneWidget); expect(find.text('Not connected'), findsWidgets); @@ -327,7 +773,7 @@ void main() { await tester.scrollUntilVisible( find.text('Work org key'), 300, - scrollable: settingsList, + scrollable: providersList, ); await tester.tap(find.text('Work org key')); await tester.pumpAndSettle(); @@ -339,7 +785,7 @@ void main() { expect(find.text('Platform reporting'), findsOneWidget); }); - testWidgets('keeps credential settings available after a load failure', ( + testWidgets('keeps Providers catalog available after a load failure', ( tester, ) async { final credentialStore = _MemoryCredentialStore('invalid-admin-key'); @@ -375,7 +821,7 @@ void main() { expect(find.text('Usage · HTTP 401 · invalid_api_key'), findsOneWidget); await tester.tap(find.text('Close')); await tester.pumpAndSettle(); - await tester.tap(find.text('Settings')); + await tester.tap(find.text('Providers')); await tester.pumpAndSettle(); await tester.scrollUntilVisible( @@ -391,71 +837,64 @@ void main() { expect(find.text('Remove'), findsOneWidget); }); - testWidgets('shows plan and purchased by default and can hide purchases', ( - tester, - ) async { - final json = - jsonDecode( - File( - '../../fixtures/snapshots/dashboard_today.json', - ).readAsStringSync(), - ) - as Map; - final account = (json['accounts'] as List).first as Map; - account['provider'] = 'codex'; - account['allowances'] = [ - { - 'id': 'plan', - 'source': 'plan', - 'label': 'Weekly plan', - 'usedPercent': 84.0, - 'used': null, - 'limit': null, - 'remaining': null, - 'windowMinutes': 10080, - 'resetsAt': '2026-07-26T09:55:37Z', - 'status': 'warning', - }, - { - 'id': 'purchased', - 'source': 'purchased', - 'label': 'Purchased credits', - 'usedPercent': null, - 'used': null, - 'limit': null, - 'remaining': {'value': '12.5', 'unit': 'credits'}, - 'windowMinutes': null, - 'resetsAt': null, - 'status': 'ok', - }, - ]; - final preferences = _MemoryDisplayPreferenceStore(); + testWidgets( + 'shows plan and purchased surfaces without Settings display toggles', + (tester) async { + final json = + jsonDecode( + File( + '../../fixtures/snapshots/dashboard_today.json', + ).readAsStringSync(), + ) + as Map; + final account = (json['accounts'] as List).first as Map; + account['provider'] = 'codex'; + account['allowances'] = [ + { + 'id': 'plan', + 'source': 'plan', + 'label': 'Weekly plan', + 'usedPercent': 84.0, + 'used': null, + 'limit': null, + 'remaining': null, + 'windowMinutes': 10080, + 'resetsAt': '2026-07-26T09:55:37Z', + 'status': 'warning', + }, + { + 'id': 'purchased', + 'source': 'purchased', + 'label': 'Purchased credits', + 'usedPercent': null, + 'used': null, + 'limit': null, + 'remaining': {'value': '12.5', 'unit': 'credits'}, + 'windowMinutes': null, + 'resetsAt': null, + 'status': 'ok', + }, + ]; + + await tester.pumpWidget( + WardPulseApp( + repository: ValueDashboardRepository( + DashboardSnapshot.fromJson(json), + ), + ), + ); + await tester.pumpAndSettle(); - await tester.pumpWidget( - WardPulseApp( - repository: ValueDashboardRepository(DashboardSnapshot.fromJson(json)), - displayPreferenceStore: preferences, - ), - ); - await tester.pumpAndSettle(); + expect(find.text('Weekly plan'), findsOneWidget); + expect(find.text('Purchased credits'), findsOneWidget); - expect(find.text('Weekly plan'), findsOneWidget); - expect(find.text('Purchased credits'), findsOneWidget); - - await tester.tap(find.text('Settings')); - await tester.pumpAndSettle(); - expect(find.widgetWithText(SwitchListTile, 'Platform spend'), findsOneWidget); - await tester.tap(find.widgetWithText(SwitchListTile, 'Purchased usage')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Dashboard')); - await tester.pumpAndSettle(); - - expect(preferences.value.purchased, isFalse); - expect(preferences.value.plan, isTrue); - expect(preferences.value.platform, isTrue); - expect(find.text('Weekly plan'), findsOneWidget); - expect(find.text('Purchased credits'), findsNothing); - }); + await tester.tap(find.text('Settings')); + await tester.pumpAndSettle(); + expect(find.text('Display'), findsNothing); + expect(find.text('Platform spend'), findsNothing); + expect(find.text('Purchased usage'), findsNothing); + }, + ); testWidgets('persists the global refresh interval slider', (tester) async { final snapshot = DashboardSnapshot.fromJsonString( @@ -550,7 +989,7 @@ void main() { WardPulseApp(repository: ValueDashboardRepository(snapshot)), ); await tester.pumpAndSettle(); - await tester.tap(find.text('Settings')); + await tester.tap(find.text('Providers')); await tester.pumpAndSettle(); final settingsScrollable = find.descendant( @@ -588,12 +1027,23 @@ void main() { }); testWidgets('enables mock data only from the debug setting', (tester) async { - final mock = DashboardSnapshot.fromJsonString( + final liveFixture = DashboardSnapshot.fromJsonString( File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), ); - final liveJson = mock.toJson(); + final liveJson = liveFixture.toJson(); (liveJson['accounts'] as List).first['provider'] = 'openai'; final live = DashboardSnapshot.fromJson(liveJson); + final mockJson = liveFixture.toJson(); + final accounts = mockJson['accounts'] as List; + accounts.first + ..['provider'] = 'codex' + ..['accountId'] = 'codex-demo'; + accounts.add({ + ...Map.from(accounts.first as Map), + 'provider': 'cursor', + 'accountId': 'cursor-demo', + }); + final mock = DashboardSnapshot.fromJson(mockJson); final preferences = _MemoryDebugDataPreferenceStore(); await tester.pumpWidget( @@ -626,8 +1076,95 @@ void main() { expect(preferences.value, isTrue); await tester.tap(find.text('Providers')); await tester.pumpAndSettle(); - expect(find.text('Mock'), findsOneWidget); - expect(find.text('OpenAI'), findsNothing); + // Which family sits where is the tab's own business + // (providers_screen_test.dart); this test only asks that the catalog + // opened, so it scrolls to the row instead of assuming a position. + await tester.scrollUntilVisible( + find.text('Codex subscription'), + 300, + scrollable: find.byType(Scrollable).first, + ); + expect(find.text('Codex subscription'), findsOneWidget); + }); + + testWidgets( + 'demo data leaves nothing for a recovery to be measured against', + (tester) async { + final source = DashboardSnapshot.fromJsonString( + File( + '../../fixtures/snapshots/dashboard_today.json', + ).readAsStringSync(), + ); + final preferences = _MemoryDebugDataPreferenceStore(); + await preferences.writeMockDataEnabled(true); + final watchlist = + _MemoryRecoveryWatchlistStore() + ..keys = const [(accountId: 'codex-demo', allowanceId: 'weekly')]; + + await tester.pumpWidget( + WardPulseApp( + repository: DebugDashboardRepository( + live: ValueDashboardRepository(source), + mock: ValueDashboardRepository(source), + preferences: preferences, + ), + debugDataAvailable: true, + debugDataPreferenceStore: preferences, + recoveryWatchlistStore: watchlist, + ), + ); + await tester.pumpAndSettle(); + + // Invented windows must not survive the demo: kept, they would make the + // first live poll afterwards look like a recovery. + expect(watchlist.keys, isEmpty); + }, + ); + + testWidgets('the recovery switch is on, and says when Android blocks it', ( + tester, + ) async { + final store = _MemoryRecoveryNotificationStore(); + final notifier = _BlockedRecoveryNotifier(); + + await tester.pumpWidget( + WardPulseApp( + repository: ValueDashboardRepository( + DashboardSnapshot.empty(generatedAt: DateTime.utc(2026, 8, 17)), + ), + recoveryNotificationStore: store, + recoveryNotifier: notifier, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Settings')); + await tester.pumpAndSettle(); + + final toggle = find.widgetWithText(SwitchListTile, 'Plan recovery'); + await tester.scrollUntilVisible( + toggle, + 300, + scrollable: find.byType(Scrollable).first, + ); + + // On without being asked: interrupting is the point of the feature. + expect(tester.widget(toggle).value, isTrue); + + // Off and on again, so the ask runs with Android refusing it. + await tester.tap(toggle); + await tester.pumpAndSettle(); + expect(store.enabled, isFalse); + await tester.tap(toggle); + await tester.pumpAndSettle(); + + expect(store.enabled, isTrue); + expect(notifier.asked, 1); + // The reader said yes and Android said no; leaving the switch on without a + // word would promise an interruption that cannot arrive. + expect( + find.text('Android is blocking WardPulse notifications'), + findsOneWidget, + ); }); testWidgets('hides mock data outside debug builds', (tester) async { @@ -650,6 +1187,54 @@ void main() { }); } +/// Mock fixture as a real connection: mock accounts hold no metric slots. +DashboardSnapshot _asConnection(DashboardSnapshot snapshot) { + final account = + Map.from(snapshot.primaryAccount!.toJson()) + ..['accountId'] = 'anthropic-platform' + ..['provider'] = 'claude' + ..['connection'] = 'anthropic.platform'; + return DashboardSnapshot.fromJson({ + ...snapshot.toJson(), + 'accounts': [account], + }); +} + +class _MemoryRecoveryNotificationStore + implements RecoveryNotificationPreferenceStore { + bool enabled = true; + + @override + Future read() async => enabled; + + @override + Future write(bool value) async => enabled = value; +} + +/// Android refusing the permission, which the switch alone cannot detect. +class _BlockedRecoveryNotifier implements RecoveryNotifier { + var asked = 0; + + @override + Future notify(PlanRecovery recovery) async {} + + @override + Future requestPermission() async { + asked++; + return false; + } +} + +class _MemoryRecoveryWatchlistStore implements RecoveryWatchlistStore { + List keys = const []; + + @override + Future> read() async => keys; + + @override + Future write(List value) async => keys = value; +} + class _FakeWatchSyncService implements WatchSyncService { final syncedSnapshots = []; void Function()? refreshHandler; @@ -657,9 +1242,9 @@ class _FakeWatchSyncService implements WatchSyncService { @override Future sync( DashboardSnapshot snapshot, - ConsumptionDisplayPreferences displayPreferences, WatchRingPreferences ringPreferences, { DateTime? manualRefreshAnchorAt, + bool mockDataMode = false, }) async { syncedSnapshots.add(snapshot); } @@ -681,9 +1266,9 @@ class _FailingWatchSyncService implements WatchSyncService { @override Future sync( DashboardSnapshot snapshot, - ConsumptionDisplayPreferences displayPreferences, WatchRingPreferences ringPreferences, { DateTime? manualRefreshAnchorAt, + bool mockDataMode = false, }) { return Future.error(StateError('Watch unavailable')); } @@ -716,6 +1301,42 @@ final class _CountingDashboardRepository extends DashboardRepository { Future load() async => _load(); } +class _MemoryPhoneWidgetStore implements PhoneWidgetPreferenceStore { + PhoneWidgetPreferences value = const PhoneWidgetPreferences(); + + @override + Future read() async => value; + + @override + Future write(PhoneWidgetPreferences next) async { + value = next; + } +} + +class _MemoryWatchRingStore implements WatchRingPreferenceStore { + WatchRingPreferences value = const WatchRingPreferences(); + + @override + Future read() async => value; + + @override + Future write(WatchRingPreferences next) async { + value = next; + } +} + +class _MemoryAlertThresholdStore implements AlertThresholdPreferenceStore { + AlertThresholdPreferences value = const AlertThresholdPreferences(); + + @override + Future read() async => value; + + @override + Future write(AlertThresholdPreferences next) async { + value = next; + } +} + class _MemoryCredentialStore implements ProviderCredentialStore { _MemoryCredentialStore([String? openAiAdminKey]) { if (openAiAdminKey != null) { @@ -758,19 +1379,6 @@ class _MemoryCredentialStore implements ProviderCredentialStore { } } -class _MemoryDisplayPreferenceStore - implements ConsumptionDisplayPreferenceStore { - ConsumptionDisplayPreferences value = const ConsumptionDisplayPreferences(); - - @override - Future read() async => value; - - @override - Future write(ConsumptionDisplayPreferences value) async { - this.value = value; - } -} - class _MemoryDebugDataPreferenceStore implements DebugDataPreferenceStore { bool value = false; diff --git a/apps/phone_flutter/test/apply_alert_settings_test.dart b/apps/phone_flutter/test/apply_alert_settings_test.dart new file mode 100644 index 0000000..f2d4671 --- /dev/null +++ b/apps/phone_flutter/test/apply_alert_settings_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ward_pulse_phone/dashboard/apply_alert_settings.dart'; +import 'package:ward_pulse_phone/dashboard/dashboard_models.dart'; +import 'package:ward_pulse_phone/settings/alert_threshold_preferences.dart'; + +void main() { + test('default-off settings clear leftover alerts without FFI', () { + final snapshot = DashboardSnapshot.empty().withSyncIssue( + DashboardSyncIssue.rateLimited, + details: 'provider floor', + ); + final withAlerts = DashboardSnapshot( + generatedAt: snapshot.generatedAt, + overallStatus: snapshot.overallStatus, + accounts: snapshot.accounts, + todayTotal: snapshot.todayTotal, + weekTotal: snapshot.weekTotal, + monthTotal: snapshot.monthTotal, + alerts: const [ + AlertSummary(severity: 'warning', message: 'stale auto-fire'), + ], + watchSummary: snapshot.watchSummary, + syncIssue: snapshot.syncIssue, + syncDetails: snapshot.syncDetails, + ); + + final applied = applyUserAlertSettings( + withAlerts, + const AlertThresholdPreferences(), + ); + + expect(applied.alerts, isEmpty); + expect(applied.syncIssue, DashboardSyncIssue.rateLimited); + expect(applied.syncDetails, 'provider floor'); + }); +} diff --git a/apps/phone_flutter/test/catalog_outline_test.dart b/apps/phone_flutter/test/catalog_outline_test.dart new file mode 100644 index 0000000..f64de25 --- /dev/null +++ b/apps/phone_flutter/test/catalog_outline_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ward_pulse_phone/app/catalog_outline.dart'; +import 'package:ward_pulse_phone/dashboard/dashboard_models.dart'; +import 'package:ward_pulse_phone/settings/watch_ring_preferences.dart'; + +void main() { + WatchRingMetric metric(String id, String label) => WatchRingMetric( + id: id, + label: label, + usedPercent: 10, + status: ProviderStatus.ok, + ); + + testWidgets('one heading per kind, one per connection, rows under them', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: CatalogOutline( + catalog: [ + metric('allowance.codex.codex-weekly', 'Weekly plan'), + metric('budget.openai.plan.today', 'Today'), + metric('budget.anthropic.platform.week', 'Week'), + ], + rowBuilder: (_, title) => ListTile(title: Text(title)), + ), + ), + ), + ), + ); + + // The plan allowance and the plan budget are the same subscription, so they + // share one heading rather than appearing as two connections. + expect(find.text('Plan'), findsOneWidget); + expect(find.text('Codex'), findsOneWidget); + expect(find.text('Platform'), findsOneWidget); + expect(find.text('Anthropic'), findsOneWidget); + + // Rows keep only what tells them apart under that heading. + expect(find.text('Weekly plan'), findsOneWidget); + expect(find.text('Today'), findsOneWidget); + expect(find.text('Week'), findsOneWidget); + + // Plan before Platform whatever order the catalog was built in: the + // subscription is what a reader came to check. + expect( + tester.getTopLeft(find.text('Plan')).dy, + lessThan(tester.getTopLeft(find.text('Platform')).dy), + ); + }); + + testWidgets('the declared kind order does not follow the catalog', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: CatalogOutline( + // Platform first here, and it still lands second. + catalog: [ + metric('budget.anthropic.platform.week', 'Week'), + metric('allowance.codex.codex-weekly', 'Weekly plan'), + ], + rowBuilder: (_, title) => ListTile(title: Text(title)), + ), + ), + ), + ), + ); + + expect( + tester.getTopLeft(find.text('Plan')).dy, + lessThan(tester.getTopLeft(find.text('Platform')).dy), + ); + }); +} diff --git a/apps/phone_flutter/test/claude_account_client_test.dart b/apps/phone_flutter/test/claude_account_client_test.dart index 01a1394..3c06aa4 100644 --- a/apps/phone_flutter/test/claude_account_client_test.dart +++ b/apps/phone_flutter/test/claude_account_client_test.dart @@ -114,10 +114,7 @@ void main() { expect(result.session.accessToken, 'sk-ant-oat01-new'); expect(result.reportJson, contains('"accountId":"acct-1"')); expect(transport.methods, ['POST', 'GET']); - expect( - transport.headers[1]['Authorization'], - 'Bearer sk-ant-oat01-new', - ); + expect(transport.headers[1]['Authorization'], 'Bearer sk-ant-oat01-new'); expect(transport.headers[1]['anthropic-beta'], 'oauth-2025-04-20'); expect(transport.headers[1]['User-Agent'], contains('claude-code')); }); diff --git a/apps/phone_flutter/test/cursor_webview_cookies_test.dart b/apps/phone_flutter/test/cursor_webview_cookies_test.dart index 80c4763..afa593e 100644 --- a/apps/phone_flutter/test/cursor_webview_cookies_test.dart +++ b/apps/phone_flutter/test/cursor_webview_cookies_test.dart @@ -59,7 +59,9 @@ void main() { isTrue, ); expect( - isCursorSessionCookieHost(Uri.parse('https://authenticator.cursor.sh/')), + isCursorSessionCookieHost( + Uri.parse('https://authenticator.cursor.sh/'), + ), isFalse, ); expect( diff --git a/apps/phone_flutter/test/dashboard_repository_test.dart b/apps/phone_flutter/test/dashboard_repository_test.dart index a638210..e247cf4 100644 --- a/apps/phone_flutter/test/dashboard_repository_test.dart +++ b/apps/phone_flutter/test/dashboard_repository_test.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; @@ -58,6 +59,36 @@ void main() { expect(await repository.load(), same(mock)); }); + test('demo repository reshuffles by seed', () async { + var loads = 0; + final repository = DemoDashboardRepository( + loadDebugDashboardJson: (seed) { + loads += 1; + final json = + jsonDecode( + File( + '../../fixtures/snapshots/dashboard_today.json', + ).readAsStringSync(), + ) + as Map; + (json['accounts'] as List).first['accountId'] = 'seed-$seed'; + return jsonEncode(json); + }, + seedForLoad: () => 42, + ); + + final first = await repository.load(); + final second = await repository.load(); + expect(first.accounts.single.accountId, 'seed-42'); + expect(identical(first, second), isTrue); + expect(loads, 1); + + repository.invalidate(); + final third = await repository.load(); + expect(third.accounts.single.accountId, 'seed-42'); + expect(loads, 2); + }); + test('does not double-count cached input tokens', () { final bucket = UsageBucket( startAt: DateTime.utc(2026, 7, 19), diff --git a/apps/phone_flutter/test/dashboard_screen_test.dart b/apps/phone_flutter/test/dashboard_screen_test.dart index 27590cc..37f9272 100644 --- a/apps/phone_flutter/test/dashboard_screen_test.dart +++ b/apps/phone_flutter/test/dashboard_screen_test.dart @@ -2,10 +2,11 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:ward_pulse_phone/app/surface_order.dart'; import 'package:ward_pulse_phone/app/ward_pulse_theme.dart'; import 'package:ward_pulse_phone/dashboard/dashboard_models.dart'; import 'package:ward_pulse_phone/dashboard/dashboard_screen.dart'; -import 'package:ward_pulse_phone/settings/consumption_display_preferences.dart'; +import 'package:ward_pulse_phone/dashboard/status_pill.dart'; void main() { test('trims trailing zeros from quantity labels', () { @@ -21,18 +22,80 @@ void main() { expect(formatQuantityValue('100'), '100'); }); - test('explains unknown platform spend without a local limit', () { + test('the app bar counts sections, not the problems inside them', () { + final source = DashboardSnapshot.fromJsonString( + File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), + ); + Map account(String provider, List statuses) { + return source.primaryAccount!.toJson() + ..['accountId'] = '$provider-local' + ..['provider'] = provider + ..['status'] = 'ok' + ..['allowances'] = [ + for (final (index, status) in statuses.indexed) + { + 'id': '$provider-$index', + 'source': 'plan', + 'label': '$provider $index', + 'usedPercent': 50.0, + 'used': null, + 'limit': null, + 'remaining': null, + 'unlimited': false, + 'windowMinutes': null, + 'resetsAt': null, + 'status': status, + }, + ] + ..['buckets'] = [] + ..['modelBreakdown'] = []; + } + + final snapshot = DashboardSnapshot.fromJson( + source.toJson() + ..['accounts'] = [ + account('cursor', ['warning', 'rateLimited']), + account('claude', ['ok']), + account('codex', ['error']), + ], + ); + + final problems = dashboardProblems(snapshot); + + // Three unhealthy cards live in two families, and a tap can only take the + // reader to a family (PHONE_DASHBOARD_DESIGN.md). + expect(problems.sections, 2); + // The worst family, not the one that happened to arrive first: Codex is in + // error while Cursor is only rate limited. + expect(problems.first, 'codex'); + expect(problems.status, ProviderStatus.error); + }); + + testWidgets('aggregate spend card shows money and nothing to measure it', ( + tester, + ) async { final state = BudgetState.fromJson({ 'period': 'today', - 'spent': {'minorUnits': 0, 'currency': 'USD'}, + 'spent': {'minorUnits': 1240, 'currency': 'USD'}, 'limit': null, 'remaining': null, 'usedPercent': null, 'projectedTotal': null, 'status': 'unknown', }); - expect(state.statusExplanation, contains('no local budget limit')); - expect(state.statusExplanation, contains('Subscription credit')); + + await tester.pumpWidget( + MaterialApp( + theme: wardPulseLightTheme, + home: Scaffold(body: BudgetSummaryCard(title: 'Today', state: state)), + ), + ); + + expect(find.text('USD 12.40'), findsOneWidget); + // Limits are per connection, so the total has no ceiling: a bar or a pill + // here could only say Unknown, next to a sum that is exactly known. + expect(find.byType(LinearProgressIndicator), findsNothing); + expect(find.byType(StatusPill), findsNothing); }); testWidgets('shows allowances from every connected provider', (tester) async { @@ -72,10 +135,8 @@ void main() { theme: wardPulseLightTheme, home: Scaffold( body: DashboardScreen( + onRefresh: _noRefresh, snapshot: DashboardSnapshot.fromJson(dashboard), - displayPreferences: const ConsumptionDisplayPreferences( - purchased: true, - ), ), ), ), @@ -85,6 +146,8 @@ void main() { expect(find.text('Purchased credits'), findsOneWidget); expect(find.text('Unlimited'), findsOneWidget); expect(find.text('Unknown'), findsNothing); + // Healthy families say nothing at all. + expect(find.byType(Badge), findsNothing); expect(find.text('OpenAI'), findsOneWidget); await tester.scrollUntilVisible( @@ -108,7 +171,73 @@ void main() { expect(find.text('Today'), findsOneWidget); }); - testWidgets('hides platform spend when the display preference is off', ( + testWidgets('shows Cursor Models and Other Models plan pools', ( + tester, + ) async { + final source = DashboardSnapshot.fromJsonString( + File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), + ); + final cursor = + source.primaryAccount!.toJson() + ..['accountId'] = 'cursor-local' + ..['provider'] = 'cursor' + ..['allowances'] = [ + { + 'id': 'cursor-plan-models', + 'source': 'plan', + 'label': 'Cursor Models', + 'usedPercent': 47.0, + 'used': null, + 'limit': null, + 'remaining': null, + 'unlimited': false, + 'windowMinutes': null, + 'resetsAt': null, + 'status': 'ok', + }, + { + 'id': 'cursor-plan-other', + 'source': 'plan', + 'label': 'Other Models', + 'usedPercent': 100.0, + 'used': null, + 'limit': null, + 'remaining': null, + 'unlimited': false, + 'windowMinutes': null, + 'resetsAt': null, + 'status': 'rateLimited', + }, + ] + ..['buckets'] = [] + ..['modelBreakdown'] = []; + final dashboard = source.toJson()..['accounts'] = [cursor]; + + await tester.pumpWidget( + MaterialApp( + theme: wardPulseLightTheme, + home: Scaffold( + body: DashboardScreen( + onRefresh: _noRefresh, + snapshot: DashboardSnapshot.fromJson(dashboard), + ), + ), + ), + ); + + expect(find.text('Cursor Models'), findsOneWidget); + expect(find.text('Other Models'), findsOneWidget); + expect(find.text('53% left'), findsOneWidget); + expect(find.text('0% left'), findsOneWidget); + expect(find.byIcon(Icons.speed), findsOneWidget); + // A card marks a deviation, never health, and the header says how many + // deviate below it (PHONE_DASHBOARD_DESIGN.md). + expect(_pillsInCardOf(find.text('Cursor Models')), findsNothing); + expect(_pillsInCardOf(find.text('Other Models')), findsOneWidget); + expect(find.widgetWithText(Badge, '1'), findsOneWidget); + }); + + testWidgets('always shows platform spend when budgets are available', ( tester, ) async { final source = DashboardSnapshot.fromJsonString( @@ -124,20 +253,23 @@ void main() { theme: wardPulseLightTheme, home: Scaffold( body: DashboardScreen( + onRefresh: _noRefresh, snapshot: DashboardSnapshot.fromJson(dashboard), - displayPreferences: const ConsumptionDisplayPreferences( - platform: false, - ), ), ), ), ); - expect(find.text('Platform spend'), findsNothing); - expect(find.text('Today'), findsNothing); + await tester.scrollUntilVisible( + find.text('Platform spend'), + 300, + scrollable: find.byType(Scrollable).first, + ); + expect(find.text('Platform spend'), findsOneWidget); + expect(find.text('Today'), findsWidgets); }); - testWidgets('hides plan Unknowns for OpenAI-only and offers Settings help', ( + testWidgets('hides plan Unknowns for OpenAI-only and offers Providers help', ( tester, ) async { final source = DashboardSnapshot.fromJsonString( @@ -148,15 +280,16 @@ void main() { ..['provider'] = 'openai' ..['allowances'] = []; final dashboard = source.toJson()..['accounts'] = [openAi]; - var openedSettings = false; + var openedProviders = false; await tester.pumpWidget( MaterialApp( theme: wardPulseLightTheme, home: Scaffold( body: DashboardScreen( + onRefresh: _noRefresh, snapshot: DashboardSnapshot.fromJson(dashboard), - onOpenSettings: () => openedSettings = true, + onOpenProviders: () => openedProviders = true, ), ), ), @@ -168,12 +301,14 @@ void main() { await tester.tap(find.byTooltip('Why is this hidden?')); await tester.pumpAndSettle(); expect( - find.text('Connect a Codex subscription in Settings to see plan limits.'), + find.text( + 'Connect a Codex subscription on the Providers tab to see plan limits.', + ), findsOneWidget, ); - await tester.tap(find.text('Open Settings')); + await tester.tap(find.text('Open Providers')); await tester.pumpAndSettle(); - expect(openedSettings, isTrue); + expect(openedProviders, isTrue); await tester.scrollUntilVisible( find.text('Platform spend'), @@ -215,6 +350,7 @@ void main() { theme: wardPulseLightTheme, home: Scaffold( body: DashboardScreen( + onRefresh: _noRefresh, snapshot: DashboardSnapshot.fromJson(dashboard), ), ), @@ -276,6 +412,7 @@ void main() { theme: wardPulseLightTheme, home: Scaffold( body: DashboardScreen( + onRefresh: _noRefresh, snapshot: DashboardSnapshot.fromJson(dashboard), ), ), @@ -295,40 +432,42 @@ void main() { final source = DashboardSnapshot.fromJsonString( File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), ); - final claude = source.primaryAccount!.toJson() - ..['accountId'] = 'claude-local' - ..['provider'] = 'claude' - ..['allowances'] = [ - { - 'id': 'claude-five-hour', - 'source': 'plan', - 'label': '5-hour session', - 'usedPercent': 0, - 'used': null, - 'limit': null, - 'remaining': null, - 'unlimited': false, - 'windowMinutes': 300, - 'resetsAt': null, - 'status': 'ok', - }, - ] - ..['buckets'] = [] - ..['modelBreakdown'] = []; - final codex = source.primaryAccount!.toJson() - ..['accountId'] = 'codex-local' - ..['provider'] = 'codex' - ..['allowances'] = [] - ..['modelBreakdown'] = []; + final claude = + source.primaryAccount!.toJson() + ..['accountId'] = 'claude-local' + ..['provider'] = 'claude' + ..['allowances'] = [ + { + 'id': 'claude-five-hour', + 'source': 'plan', + 'label': '5-hour session', + 'usedPercent': 0, + 'used': null, + 'limit': null, + 'remaining': null, + 'unlimited': false, + 'windowMinutes': 300, + 'resetsAt': null, + 'status': 'ok', + }, + ] + ..['buckets'] = [] + ..['modelBreakdown'] = []; + final codex = + source.primaryAccount!.toJson() + ..['accountId'] = 'codex-local' + ..['provider'] = 'codex' + ..['allowances'] = [] + ..['modelBreakdown'] = []; // Keep fixture buckets on Codex only. - final dashboard = - source.toJson()..['accounts'] = [claude, codex]; + final dashboard = source.toJson()..['accounts'] = [claude, codex]; await tester.pumpWidget( MaterialApp( theme: wardPulseLightTheme, home: Scaffold( body: DashboardScreen( + onRefresh: _noRefresh, snapshot: DashboardSnapshot.fromJson(dashboard), ), ), @@ -348,6 +487,65 @@ void main() { expect(codexHeader.dy, lessThan(historyTitle.dy)); }); + testWidgets('keeps Cursor plan and platform under one provider plaque', ( + tester, + ) async { + final source = DashboardSnapshot.fromJsonString( + File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), + ); + final plan = + source.primaryAccount!.toJson() + ..['accountId'] = 'cursor-plan' + ..['provider'] = 'cursor' + ..['status'] = 'ok' + ..['allowances'] = [ + { + 'id': 'cursor-plan-models', + 'source': 'plan', + 'label': 'Cursor Models', + 'usedPercent': 47, + 'used': null, + 'limit': null, + 'remaining': null, + 'unlimited': false, + 'windowMinutes': null, + 'resetsAt': null, + 'status': 'ok', + }, + ] + ..['buckets'] = [] + ..['modelBreakdown'] = []; + final platform = + source.primaryAccount!.toJson() + ..['accountId'] = 'cursor-platform' + ..['provider'] = 'cursor' + ..['status'] = 'warning' + ..['allowances'] = [] + ..['modelBreakdown'] = []; + final dashboard = source.toJson()..['accounts'] = [plan, platform]; + + await tester.pumpWidget( + MaterialApp( + theme: wardPulseLightTheme, + home: Scaffold( + body: DashboardScreen( + onRefresh: _noRefresh, + snapshot: DashboardSnapshot.fromJson(dashboard), + ), + ), + ), + ); + + expect(find.text('Cursor'), findsOneWidget); + expect(find.text('Cursor Models'), findsOneWidget); + expect(find.text('Usage history'), findsOneWidget); + expect(find.byTooltip('Warning'), findsOneWidget); + + final cursorHeader = tester.getTopLeft(find.text('Cursor')); + final historyTitle = tester.getTopLeft(find.text('Usage history')); + expect(cursorHeader.dy, lessThan(historyTitle.dy)); + }); + testWidgets('explains missing purchased usage when enabled', (tester) async { final source = DashboardSnapshot.fromJsonString( File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), @@ -377,11 +575,8 @@ void main() { theme: wardPulseLightTheme, home: Scaffold( body: DashboardScreen( + onRefresh: _noRefresh, snapshot: DashboardSnapshot.fromJson(dashboard), - displayPreferences: const ConsumptionDisplayPreferences( - plan: true, - purchased: true, - ), ), ), ), @@ -396,4 +591,227 @@ void main() { findsOneWidget, ); }); + + group('the declared card order', () { + final source = DashboardSnapshot.fromJsonString( + File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), + ); + + Map account( + String provider, { + String status = 'ok', + int spent = 0, + String currency = 'USD', + }) { + return source.primaryAccount!.toJson() + ..['accountId'] = '$provider-local' + ..['provider'] = provider + ..['status'] = status + ..['allowances'] = [ + { + 'id': '$provider-plan', + 'source': 'plan', + 'label': '$provider plan', + 'usedPercent': 50.0, + 'used': null, + 'limit': null, + 'remaining': null, + 'unlimited': false, + 'windowMinutes': null, + 'resetsAt': null, + 'status': status, + }, + ] + ..['buckets'] = [] + ..['modelBreakdown'] = [] + ..['month'] = { + 'period': 'month', + 'spent': {'minorUnits': spent, 'currency': currency}, + 'limit': null, + 'remaining': null, + 'usedPercent': null, + 'projectedTotal': null, + 'status': status, + }; + } + + DashboardSnapshot snapshotOf(List> accounts) { + return DashboardSnapshot.fromJson( + source.toJson()..['accounts'] = accounts, + ); + } + + /// Provider plaques top to bottom. + List renderedOrder(WidgetTester tester) { + final headers = + [ + 'Codex', + 'Claude', + 'Cursor', + ].where((label) => find.text(label).evaluate().isNotEmpty).toList() + ..sort( + (left, right) => tester + .getTopLeft(find.text(left)) + .dy + .compareTo(tester.getTopLeft(find.text(right)).dy), + ); + return headers; + } + + Future show( + WidgetTester tester, + DashboardSnapshot snapshot, { + FrozenOrder? order, + }) async { + await tester.pumpWidget( + MaterialApp( + theme: wardPulseLightTheme, + home: Scaffold( + body: DashboardScreen( + snapshot: snapshot, + order: order, + onRefresh: _noRefresh, + ), + ), + ), + ); + } + + testWidgets('needs action first, then spend, then name', (tester) async { + await show( + tester, + snapshotOf([ + account('claude', spent: 100), + account('codex', spent: 900), + account('cursor', status: 'error'), + ]), + ); + + // Cursor is broken and comes first though it spent nothing; Codex + // outspends Claude and takes the rest. + expect(renderedOrder(tester), ['Cursor', 'Codex', 'Claude']); + }); + + testWidgets('equal keys fall back to the provider, not the label', ( + tester, + ) async { + await show( + tester, + snapshotOf([account('cursor'), account('codex'), account('claude')]), + ); + + expect(renderedOrder(tester), ['Claude', 'Codex', 'Cursor']); + }); + + testWidgets('the same accounts in another order render the same screen', ( + tester, + ) async { + final accounts = [ + account('claude', spent: 100), + account('codex', spent: 900), + account('cursor', status: 'error'), + ]; + + await show(tester, snapshotOf(accounts)); + final first = renderedOrder(tester); + + await show(tester, snapshotOf(accounts.reversed.toList())); + + expect(renderedOrder(tester), first); + }); + + testWidgets('a poll that only moves spend leaves the cards alone', ( + tester, + ) async { + final order = FrozenOrder(); + await show( + tester, + snapshotOf([ + account('claude', spent: 100), + account('codex', spent: 900), + ]), + order: order, + ); + expect(renderedOrder(tester), ['Codex', 'Claude']); + + // Claude overtakes Codex on spend; the reader's list must not swap under + // the finger, because no provider changed state. + await show( + tester, + snapshotOf([ + account('claude', spent: 5000), + account('codex', spent: 900), + ]), + order: order, + ); + + expect(renderedOrder(tester), ['Codex', 'Claude']); + }); + + testWidgets('a family falling into error climbs the same poll', ( + tester, + ) async { + final order = FrozenOrder(); + await show( + tester, + snapshotOf([ + account('claude', spent: 100), + account('codex', spent: 900), + ]), + order: order, + ); + expect(renderedOrder(tester), ['Codex', 'Claude']); + + // The same two families, so nothing joined or left — but one of them now + // needs the reader, and a held order must not sit on that. + await show( + tester, + snapshotOf([ + account('claude', status: 'error', spent: 100), + account('codex', spent: 900), + ]), + order: order, + ); + + expect(renderedOrder(tester), ['Claude', 'Codex']); + }); + + testWidgets('a family joining reranks, and the badge follows the cards', ( + tester, + ) async { + final order = FrozenOrder(); + await show( + tester, + snapshotOf([ + account('claude', spent: 100), + account('codex', spent: 900), + ]), + order: order, + ); + + final withCursor = snapshotOf([ + account('claude', spent: 100), + account('codex', spent: 900), + account('cursor', status: 'error'), + ]); + await show(tester, withCursor, order: order); + + expect(renderedOrder(tester), ['Cursor', 'Codex', 'Claude']); + // What the app-bar mark scrolls to is read off the same order, so it + // cannot point past a card sitting higher up. + expect(dashboardProblems(withCursor, order).first, 'cursor'); + }); + }); } + +/// Status pills inside the card that carries [label], so a leaf assertion stays +/// a leaf assertion once family and app-bar rollups change around it. +Finder _pillsInCardOf(Finder label) { + return find.descendant( + of: find.ancestor(of: label, matching: find.byType(Card)), + matching: find.byType(StatusPill), + ); +} + +/// These tests drive the screen, not the reload behind the pull. +Future _noRefresh() async {} diff --git a/apps/phone_flutter/test/metric_catalog_groups_test.dart b/apps/phone_flutter/test/metric_catalog_groups_test.dart new file mode 100644 index 0000000..59cc849 --- /dev/null +++ b/apps/phone_flutter/test/metric_catalog_groups_test.dart @@ -0,0 +1,137 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ward_pulse_phone/dashboard/dashboard_models.dart'; +import 'package:ward_pulse_phone/settings/metric_catalog_groups.dart'; +import 'package:ward_pulse_phone/settings/watch_ring_preferences.dart'; + +void main() { + test('a picker heading names the connection, not the id it came from', () { + // An allowance names its provider and a budget names its connection; both + // are the same subscription, so both land under one heading. + expect(metricConnectionOf('allowance.codex.codex-weekly'), ( + kind: MetricKind.plan, + id: 'openai.plan', + title: 'Codex', + )); + expect(metricConnectionOf('budget.openai.plan.today'), ( + kind: MetricKind.plan, + id: 'openai.plan', + title: 'Codex', + )); + expect(metricConnectionOf('budget.openai.platform.today'), ( + kind: MetricKind.platform, + id: 'openai.platform', + title: 'OpenAI', + )); + }); + + test('a row under its heading keeps only what tells it apart', () { + WatchRingMetric metric(String id, String label) => WatchRingMetric( + id: id, + label: label, + usedPercent: 10, + status: ProviderStatus.ok, + ); + + expect( + metricRowTitle(metric('budget.anthropic.platform.today', 'Today')), + 'Today', + ); + expect( + metricRowTitle(metric('allowance.codex.codex-weekly', 'Weekly plan')), + 'Weekly plan', + ); + // No separator to cut: the collapsed slot must not be renamed after the + // window it happens to resolve to today. + expect(metricRowTitle(metric(claudePlanRingId, 'Weekly')), 'Claude plan'); + }); + + group('the declared connection order', () { + WatchRingMetric metric(String id, {bool available = true}) => + WatchRingMetric( + id: id, + label: id, + usedPercent: available ? 10 : null, + status: ProviderStatus.ok, + unavailableReason: available ? null : 'Nothing to show.', + ); + + List connectionsUnder( + MetricKind kind, + List catalog, + ) { + final group = groupMetricCatalog( + catalog, + ).firstWhere((group) => group.title == kind.title); + return [for (final connection in group.connections) connection.title]; + } + + test('connections sort by their id, not by the heading a reader sees', () { + // Built Codex-first, and Claude still leads: `anthropic.plan` precedes + // `openai.plan`, which is not the order the two headings read in. + expect( + connectionsUnder(MetricKind.plan, [ + metric('budget.openai.plan.today'), + metric('budget.cursor.plan.today'), + metric('budget.anthropic.plan.today'), + ]), + ['Claude', 'Cursor', 'Codex'], + ); + }); + + test('a connection with nothing to select sinks below one that has', () { + expect( + connectionsUnder(MetricKind.plan, [ + metric('budget.anthropic.plan.today', available: false), + metric('budget.openai.plan.today'), + ]), + ['Codex', 'Claude'], + ); + }); + + test('one selectable row is enough to keep a connection up', () { + expect( + connectionsUnder(MetricKind.plan, [ + metric('budget.anthropic.plan.today', available: false), + metric('budget.anthropic.plan.week'), + metric('budget.openai.plan.today', available: false), + ]), + ['Claude', 'Codex'], + ); + }); + + test('a poll that only moves percentages leaves the order alone', () { + WatchRingMetric at(String id, double percent) => WatchRingMetric( + id: id, + label: id, + usedPercent: percent, + status: ProviderStatus.ok, + ); + + final before = connectionsUnder(MetricKind.plan, [ + at('budget.openai.plan.today', 5), + at('budget.anthropic.plan.today', 90), + ]); + // The two swap on every measure a reader can see, and the picker holds: + // percentages are not one of its keys. + final after = connectionsUnder(MetricKind.plan, [ + at('budget.openai.plan.today', 90), + at('budget.anthropic.plan.today', 5), + ]); + + expect(after, before); + }); + + test( + 'a provider this build does not know sorts on the provider itself', + () { + expect( + connectionsUnder(MetricKind.other, [ + metric('allowance.zeta.some-window'), + metric('allowance.alpha.some-window'), + ]), + ['alpha', 'zeta'], + ); + }, + ); + }); +} diff --git a/apps/phone_flutter/test/phone_widget_payload_test.dart b/apps/phone_flutter/test/phone_widget_payload_test.dart new file mode 100644 index 0000000..f7d6d6e --- /dev/null +++ b/apps/phone_flutter/test/phone_widget_payload_test.dart @@ -0,0 +1,273 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:ward_pulse_phone/dashboard/dashboard_models.dart'; +import 'package:ward_pulse_phone/dashboard/provider_status_color.dart'; +import 'package:ward_pulse_phone/widget/phone_widget_payload.dart'; +import 'package:ward_pulse_phone/widget/phone_widget_preferences.dart'; + +void main() { + final snapshot = DashboardSnapshot.fromJsonString( + File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), + ); + + test('builds remaining rows from available percent metrics', () { + final payload = buildPhoneWidgetPayload( + _asConnection(snapshot), + const PhoneWidgetPreferences(), + ); + expect(payload.stale, isFalse); + expect(payload.rows, isNotEmpty); + for (final row in payload.rows) { + expect(row.remainingPercent, inInclusiveRange(0, 100)); + expect(row.line, contains('% left ·')); + } + }); + + test('marks stale when overall status is stale', () { + final stale = snapshot.withStaleStatus( + syncIssue: DashboardSyncIssue.authentication, + ); + final payload = buildPhoneWidgetPayload( + stale, + const PhoneWidgetPreferences(), + ); + expect(payload.stale, isTrue); + }); + + test('empty selection yields empty payload', () { + final payload = buildPhoneWidgetPayload( + snapshot, + const PhoneWidgetPreferences(selectedIds: []), + ); + expect(payload.isEmpty, isTrue); + expect(payload.rows, isEmpty); + }); + + test('family accents follow the family named in the metric id', () { + final claude = providerFamilyColor('claude').toARGB32(); + expect(phoneWidgetAccentArgb('allowance.claude.plan'), claude); + // A budget ring carries its connection, so it takes that family too. + expect(phoneWidgetAccentArgb('budget.anthropic.platform.month'), claude); + expect( + phoneWidgetAccentArgb('budget.openai.platform.today'), + providerFamilyColor('codex').toARGB32(), + ); + // No family named in the id: fall back to the colourless accent. + expect( + phoneWidgetAccentArgb('budget.mock.plan.today'), + familyFallbackColor.toARGB32(), + ); + // The Cursor plan's own models leave the family colour; everything else + // Cursor keeps it. + expect( + phoneWidgetAccentArgb('allowance.cursor.cursor-plan-models'), + familyCursorOwnColor.toARGB32(), + ); + expect( + phoneWidgetAccentArgb('allowance.cursor.cursor-plan-other'), + providerFamilyColor('cursor').toARGB32(), + ); + }); + + test('keeps exhausted Cursor Models as 0% left beside Other Models', () { + final cursor = + snapshot.primaryAccount!.toJson() + ..['accountId'] = 'cursor-local' + ..['provider'] = 'cursor' + ..['status'] = 'rateLimited' + ..['allowances'] = [ + { + 'id': 'cursor-plan-models', + 'source': 'plan', + 'label': 'Cursor Models', + 'usedPercent': 100.0, + 'used': null, + 'limit': null, + 'remaining': null, + 'unlimited': false, + 'windowMinutes': null, + 'resetsAt': null, + 'status': 'rateLimited', + }, + { + 'id': 'cursor-plan-other', + 'source': 'plan', + 'label': 'Other Models', + 'usedPercent': 40.0, + 'used': null, + 'limit': null, + 'remaining': null, + 'unlimited': false, + 'windowMinutes': null, + 'resetsAt': null, + 'status': 'ok', + }, + ] + ..['buckets'] = [] + ..['modelBreakdown'] = []; + final withCursor = DashboardSnapshot.fromJson({ + ...snapshot.toJson(), + 'accounts': [cursor], + 'todayTotal': { + 'period': 'today', + 'spent': null, + 'limit': null, + 'remaining': null, + 'usedPercent': null, + 'projectedTotal': null, + 'status': 'unknown', + 'statusExplanation': null, + }, + 'weekTotal': { + 'period': 'week', + 'spent': null, + 'limit': null, + 'remaining': null, + 'usedPercent': null, + 'projectedTotal': null, + 'status': 'unknown', + 'statusExplanation': null, + }, + 'monthTotal': { + 'period': 'month', + 'spent': null, + 'limit': null, + 'remaining': null, + 'usedPercent': null, + 'projectedTotal': null, + 'status': 'unknown', + 'statusExplanation': null, + }, + }); + + final payload = buildPhoneWidgetPayload( + withCursor, + const PhoneWidgetPreferences(), + ); + expect( + payload.rows.map((row) => row.label), + containsAll(['Cursor Models', 'Cursor · Other Models']), + ); + expect( + payload.rows + .firstWhere((row) => row.label == 'Cursor Models') + .remainingPercent, + 0, + ); + }); + + test('appends per-provider purchased credits like Wear Glance', () { + final account = Map.from( + snapshot.primaryAccount!.toJson(), + ); + final claude = { + ...account, + 'accountId': 'claude-1', + 'provider': 'claude', + 'allowances': [ + { + 'id': 'claude-five-hour', + 'source': 'plan', + 'label': '5h', + 'usedPercent': 40.0, + 'used': null, + 'limit': null, + 'remaining': null, + 'unlimited': false, + 'windowMinutes': 300, + 'resetsAt': null, + 'status': 'ok', + }, + { + 'id': 'claude-extra-usage', + 'source': 'purchased', + 'label': 'Extra usage', + 'usedPercent': 20.0, + 'used': {'value': '80', 'unit': 'credits'}, + 'limit': {'value': '100', 'unit': 'credits'}, + 'remaining': {'value': '320', 'unit': 'credits'}, + 'unlimited': false, + 'windowMinutes': null, + 'resetsAt': null, + 'status': 'ok', + }, + ], + 'buckets': [], + 'modelBreakdown': [], + 'credits': [], + }; + final withCredits = DashboardSnapshot.fromJson({ + ...snapshot.toJson(), + 'accounts': [claude], + 'todayTotal': { + 'period': 'today', + 'spent': null, + 'limit': null, + 'remaining': null, + 'usedPercent': null, + 'projectedTotal': null, + 'status': 'unknown', + 'statusExplanation': null, + }, + 'weekTotal': { + 'period': 'week', + 'spent': null, + 'limit': null, + 'remaining': null, + 'usedPercent': null, + 'projectedTotal': null, + 'status': 'unknown', + 'statusExplanation': null, + }, + 'monthTotal': { + 'period': 'month', + 'spent': null, + 'limit': null, + 'remaining': null, + 'usedPercent': null, + 'projectedTotal': null, + 'status': 'unknown', + 'statusExplanation': null, + }, + }); + + final payload = buildPhoneWidgetPayload( + withCredits, + const PhoneWidgetPreferences( + selectedIds: ['allowance.claude.claude-five-hour'], + ), + ); + expect(payload.rows, hasLength(1)); + expect(payload.rows.single.creditsSuffix, '320 credits'); + expect(payload.rows.single.percentText, '60% left'); + expect(payload.rows.single.label, 'Claude · 5h'); + expect(payload.rows.single.line, '60% left · Claude · 5h · 320 credits'); + expect( + phoneWidgetCreditsSuffix(withCredits, 'budget.anthropic.platform.today'), + isNull, + reason: 'budget rings track spend, not a credit pool', + ); + expect( + phoneWidgetCreditsSuffix( + withCredits, + 'allowance.claude.claude-extra-usage', + ), + isNull, + reason: 'purchased-meter rows are the credit pool; do not repeat credits', + ); + }); +} + +/// Mock fixture as a real connection: mock accounts hold no metric slots. +DashboardSnapshot _asConnection(DashboardSnapshot snapshot) { + final account = + Map.from(snapshot.primaryAccount!.toJson()) + ..['accountId'] = 'anthropic-platform' + ..['provider'] = 'claude' + ..['connection'] = 'anthropic.platform'; + return DashboardSnapshot.fromJson({ + ...snapshot.toJson(), + 'accounts': [account], + }); +} diff --git a/apps/phone_flutter/test/phone_widget_preferences_test.dart b/apps/phone_flutter/test/phone_widget_preferences_test.dart new file mode 100644 index 0000000..e90f5bc --- /dev/null +++ b/apps/phone_flutter/test/phone_widget_preferences_test.dart @@ -0,0 +1,273 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:ward_pulse_phone/dashboard/dashboard_models.dart'; +import 'package:ward_pulse_phone/settings/watch_ring_preferences.dart'; +import 'package:ward_pulse_phone/widget/phone_widget_preferences.dart'; + +void main() { + test('defaults are unset and clamp to large slot count', () { + const prefs = PhoneWidgetPreferences(); + expect(prefs.usesDefaults, isTrue); + expect(prefs.migratedIds, isEmpty); + expect(phoneWidgetSlotCount, 6); + expect(phoneWidgetSlotCount, greaterThan(watchRingSlotCount)); + }); + + test('migrate expands collapsed Claude plan alias to window ids', () { + final migrated = migratePhoneWidgetSelectedIds([ + claudePlanRingId, + 'allowance.cursor.cursor-plan-models', + ]); + expect(migrated.first, 'allowance.claude.claude-five-hour'); + expect(migrated, contains('allowance.claude.claude-seven-day')); + expect(migrated, contains('allowance.cursor.cursor-plan-models')); + expect(migrated, isNot(contains(claudePlanRingId))); + expect(migrated.length, lessThanOrEqualTo(phoneWidgetSlotCount)); + }); + + test('migrate keeps Claude windows and purchased meters expanded', () { + final migrated = migratePhoneWidgetSelectedIds([ + 'allowance.claude.claude-five-hour', + 'allowance.claude.claude-extra-usage', + 'budget.anthropic.platform.today', + // Retired sum: no successor id, so it just leaves the selection. + 'budget.week', + 'allowance.claude.claude-seven-day', + claudePlanRingId, + ]); + expect(migrated, [ + 'allowance.claude.claude-five-hour', + 'allowance.claude.claude-extra-usage', + 'budget.anthropic.platform.today', + 'allowance.claude.claude-seven-day', + 'allowance.claude.claude-seven-day-opus', + 'allowance.claude.claude-seven-day-sonnet', + ]); + expect(migrated, isNot(contains(claudePlanRingId))); + }); + + test('a stored selection of only retired ids reads back as unset', () { + expect( + phoneWidgetPreferencesFromStoredIds(['budget.month']).usesDefaults, + isTrue, + ); + // Stored empty stays the user's explicit choice. + expect(phoneWidgetPreferencesFromStoredIds([]).selectedIds, isEmpty); + }); + + test('phone widget catalog expands Claude windows and purchased meters', () { + final base = DashboardSnapshot.fromJsonString( + File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), + ); + final mock = Map.from( + (base.toJson()['accounts'] as List).first as Map, + ); + final claude = { + ...mock, + 'accountId': 'claude-1', + 'provider': 'claude', + 'allowances': [ + { + 'id': 'claude-five-hour', + 'source': 'plan', + 'label': '5h', + 'usedPercent': 40.0, + 'used': null, + 'limit': null, + 'remaining': null, + 'unlimited': false, + 'windowMinutes': 300, + 'resetsAt': null, + 'status': 'ok', + }, + { + 'id': 'claude-seven-day', + 'source': 'plan', + 'label': 'Weekly', + 'usedPercent': 55.0, + 'used': null, + 'limit': null, + 'remaining': null, + 'unlimited': false, + 'windowMinutes': 10080, + 'resetsAt': null, + 'status': 'ok', + }, + { + 'id': 'claude-extra-usage', + 'source': 'purchased', + 'label': 'Extra usage', + 'usedPercent': 20.0, + 'used': null, + 'limit': null, + 'remaining': null, + 'unlimited': false, + 'windowMinutes': null, + 'resetsAt': null, + 'status': 'ok', + }, + ], + 'buckets': [], + 'modelBreakdown': [], + 'credits': [], + }; + final snapshot = DashboardSnapshot.fromJson({ + ...base.toJson(), + 'accounts': [claude], + }); + + final widgetIds = phoneWidgetCatalog(snapshot).map((m) => m.id).toSet(); + final watchIds = watchRingCatalog(snapshot).map((m) => m.id).toSet(); + expect(widgetIds, contains('allowance.claude.claude-five-hour')); + expect(widgetIds, contains('allowance.claude.claude-seven-day')); + expect(widgetIds, contains('allowance.claude.claude-extra-usage')); + expect(widgetIds, isNot(contains(claudePlanRingId))); + expect(watchIds, contains(claudePlanRingId)); + expect(watchIds, isNot(contains('allowance.claude.claude-extra-usage'))); + }); + + test('defaults prefer plan windows including exhausted Cursor pools', () { + final base = DashboardSnapshot.fromJsonString( + File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), + ); + final account = Map.from(base.primaryAccount!.toJson()); + final claude = { + ...account, + 'accountId': 'claude-1', + 'provider': 'claude', + 'allowances': [ + { + 'id': 'claude-five-hour', + 'source': 'plan', + 'label': '5h', + 'usedPercent': 40.0, + 'used': null, + 'limit': null, + 'remaining': null, + 'unlimited': false, + 'windowMinutes': 300, + 'resetsAt': null, + 'status': 'ok', + }, + { + 'id': 'claude-seven-day', + 'source': 'plan', + 'label': 'Weekly', + 'usedPercent': 55.0, + 'used': null, + 'limit': null, + 'remaining': null, + 'unlimited': false, + 'windowMinutes': 10080, + 'resetsAt': null, + 'status': 'ok', + }, + { + 'id': 'claude-extra-usage', + 'source': 'purchased', + 'label': 'Extra usage', + 'usedPercent': 10.0, + 'used': null, + 'limit': null, + 'remaining': null, + 'unlimited': false, + 'windowMinutes': null, + 'resetsAt': null, + 'status': 'ok', + }, + ], + 'buckets': [], + 'modelBreakdown': [], + 'credits': [], + }; + final cursor = { + ...account, + 'accountId': 'cursor-local', + 'provider': 'cursor', + 'status': 'rateLimited', + 'allowances': [ + { + 'id': 'cursor-plan-models', + 'source': 'plan', + 'label': 'Cursor Models', + 'usedPercent': 100.0, + 'used': null, + 'limit': null, + 'remaining': null, + 'unlimited': false, + 'windowMinutes': null, + 'resetsAt': null, + 'status': 'rateLimited', + }, + { + 'id': 'cursor-plan-other', + 'source': 'plan', + 'label': 'Other Models', + 'usedPercent': 40.0, + 'used': null, + 'limit': null, + 'remaining': null, + 'unlimited': false, + 'windowMinutes': null, + 'resetsAt': null, + 'status': 'ok', + }, + ], + 'buckets': [], + 'modelBreakdown': [], + 'credits': [], + }; + final snapshot = DashboardSnapshot.fromJson({ + ...base.toJson(), + 'accounts': [claude, cursor], + 'todayTotal': { + 'period': 'today', + 'spent': null, + 'limit': null, + 'remaining': null, + 'usedPercent': null, + 'projectedTotal': null, + 'status': 'unknown', + 'statusExplanation': null, + }, + 'weekTotal': { + 'period': 'week', + 'spent': null, + 'limit': null, + 'remaining': null, + 'usedPercent': null, + 'projectedTotal': null, + 'status': 'unknown', + 'statusExplanation': null, + }, + 'monthTotal': { + 'period': 'month', + 'spent': null, + 'limit': null, + 'remaining': null, + 'usedPercent': null, + 'projectedTotal': null, + 'status': 'unknown', + 'statusExplanation': null, + }, + }); + + final labels = + resolvePhoneWidgetMetrics( + snapshot, + const PhoneWidgetPreferences(), + ).map((m) => m.label).toList(); + expect( + labels, + containsAll(['5h', 'Weekly', 'Cursor Models', 'Other Models']), + ); + expect(labels.indexOf('5h'), lessThan(labels.indexOf('Extra usage'))); + + // The preview names families too, or two `Weekly plan` rows read alike. + expect( + phoneWidgetPayloadSubtitle(snapshot, const PhoneWidgetPreferences()), + contains('Cursor · Other Models'), + ); + }); +} diff --git a/apps/phone_flutter/test/phone_widget_sync_test.dart b/apps/phone_flutter/test/phone_widget_sync_test.dart new file mode 100644 index 0000000..25f3edd --- /dev/null +++ b/apps/phone_flutter/test/phone_widget_sync_test.dart @@ -0,0 +1,125 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:ward_pulse_phone/dashboard/dashboard_models.dart'; +import 'package:ward_pulse_phone/widget/phone_widget_payload.dart'; +import 'package:ward_pulse_phone/widget/phone_widget_preferences.dart'; +import 'package:ward_pulse_phone/widget/phone_widget_sync.dart'; + +void main() { + // Mock accounts hold no metric slots, so default prefs would write nothing. + final snapshot = _asConnection( + DashboardSnapshot.fromJsonString( + File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), + ), + ); + + test('overlapping syncs keep only the newer payload', () async { + final written = []; + final firstWriteStarted = Completer(); + final releaseFirstWrite = Completer(); + + final coordinator = PhoneWidgetSyncCoordinator( + write: (payload) async { + written.add(payload); + if (written.length == 1) { + firstWriteStarted.complete(); + await releaseFirstWrite.future; + } + }, + ); + + final emptyPrefs = const PhoneWidgetPreferences(selectedIds: []); + final defaultPrefs = const PhoneWidgetPreferences(); + + final first = coordinator.sync(snapshot, emptyPrefs); + await firstWriteStarted.future; + + final second = coordinator.sync(snapshot, defaultPrefs); + releaseFirstWrite.complete(); + await Future.wait([first, second]); + + expect(written, hasLength(2)); + expect(written.first.isEmpty, isTrue); + expect(written.last.isEmpty, isFalse); + }); + + test( + 'superseded sync skips write when a newer request is already queued', + () async { + final written = []; + final gate = Completer(); + + final coordinator = PhoneWidgetSyncCoordinator( + write: (payload) async { + written.add(payload); + if (written.length == 1) { + await gate.future; + } + }, + ); + + final emptyPrefs = const PhoneWidgetPreferences(selectedIds: []); + final defaultPrefs = const PhoneWidgetPreferences(); + + // Start a write, then queue two superseding requests before it finishes. + final first = coordinator.sync(snapshot, emptyPrefs); + await Future.delayed(Duration.zero); + final middle = coordinator.sync( + snapshot, + const PhoneWidgetPreferences( + selectedIds: ['budget.anthropic.platform.today'], + ), + ); + final latest = coordinator.sync(snapshot, defaultPrefs); + gate.complete(); + await Future.wait([first, middle, latest]); + + // First write may have started with empty prefs; middle is skipped; latest runs. + expect(written.length, inInclusiveRange(1, 2)); + expect(written.last.isEmpty, isFalse); + expect( + written.where((payload) => payload.isEmpty), + anyOf(isEmpty, hasLength(1)), + ); + }, + ); + + test( + 'a failed write reaches the caller without poisoning the queue', + () async { + var attempts = 0; + final coordinator = PhoneWidgetSyncCoordinator( + write: (payload) async { + attempts++; + if (attempts == 1) { + throw StateError('platform unavailable'); + } + }, + ); + + await expectLater( + coordinator.sync(snapshot, const PhoneWidgetPreferences()), + throwsStateError, + ); + + // The next caller must still get a write, not the previous failure. + await coordinator.sync(snapshot, const PhoneWidgetPreferences()); + expect(attempts, 2); + }, + ); +} + +/// Mock fixture as a real connection: mock accounts hold no metric slots. +DashboardSnapshot _asConnection(DashboardSnapshot snapshot) { + final account = + Map.from(snapshot.primaryAccount!.toJson()) + ..['accountId'] = 'anthropic-platform' + ..['provider'] = 'claude' + ..['connection'] = 'anthropic.platform'; + return DashboardSnapshot.fromJson({ + ...snapshot.toJson(), + 'accounts': [account], + }); +} diff --git a/apps/phone_flutter/test/plan_recoveries_test.dart b/apps/phone_flutter/test/plan_recoveries_test.dart new file mode 100644 index 0000000..065f432 --- /dev/null +++ b/apps/phone_flutter/test/plan_recoveries_test.dart @@ -0,0 +1,52 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:ward_pulse_phone/dashboard/plan_recoveries.dart'; + +void main() { + // The golden the Rust side builds from; parsing it here is what keeps the two + // ends of the FFI boundary describing the same payload. + final golden = + jsonDecode( + File( + '../../fixtures/snapshots/plan_recovery.json', + ).readAsStringSync(), + ) + as Map; + + test('window keys survive the trip to storage and back', () { + final keys = decodeWindowKeys(jsonEncode(golden['exhausted'])); + + expect(keys, [ + (accountId: 'claude-local', allowanceId: 'claude-weekly'), + (accountId: 'claude-local', allowanceId: 'claude-session'), + ]); + expect(jsonDecode(encodeWindowKeys(keys)), golden['exhausted']); + }); + + test('a recovery keeps its label when the reset instant is unreadable', () { + // The core passes provider instants through, so a broken one reaches here; + // losing the window over it would cost the whole poll's bookkeeping. + final recovery = planRecoveryFromJson({ + 'accountId': 'claude-local', + 'provider': 'claude', + 'allowanceId': 'claude-weekly', + 'label': 'Weekly plan', + 'resetsAt': 'whenever', + }); + + expect(recovery.label, 'Weekly plan'); + expect(recovery.resetsAt, isNull); + }); + + test('a store nothing can read counts as nothing remembered', () { + // One missed recovery beats a crash on a background poll, and the next one + // refills the list anyway. Readable-but-wrong counts as unreadable: those + // parse and then fail on the cast. + expect(decodeWindowKeys('not json'), isEmpty); + expect(decodeWindowKeys('null'), isEmpty); + expect(decodeWindowKeys('{}'), isEmpty); + expect(decodeWindowKeys('[{"accountId": "claude-local"}]'), isEmpty); + }); +} diff --git a/apps/phone_flutter/test/poll_cadence_test.dart b/apps/phone_flutter/test/poll_cadence_test.dart index 8126331..ea53ff2 100644 --- a/apps/phone_flutter/test/poll_cadence_test.dart +++ b/apps/phone_flutter/test/poll_cadence_test.dart @@ -75,29 +75,32 @@ void main() { expect(PollCadence.clampMinutes(36), 40); }); - test('headless interval respects the Android 15-minute WorkManager floor', () { - expect(PollCadence.headlessMinRefreshMinutes, 15); - expect( - PollCadence.headlessInterval(const Duration(minutes: 5)), - const Duration(minutes: 15), - ); - expect( - PollCadence.headlessInterval(const Duration(minutes: 14)), - const Duration(minutes: 15), - ); - expect( - PollCadence.headlessInterval(const Duration(minutes: 15)), - const Duration(minutes: 15), - ); - expect( - PollCadence.headlessInterval(const Duration(minutes: 20)), - const Duration(minutes: 20), - ); - expect( - PollCadence.headlessInterval(const Duration(minutes: 60)), - const Duration(minutes: 60), - ); - }); + test( + 'headless interval respects the Android 15-minute WorkManager floor', + () { + expect(PollCadence.headlessMinRefreshMinutes, 15); + expect( + PollCadence.headlessInterval(const Duration(minutes: 5)), + const Duration(minutes: 15), + ); + expect( + PollCadence.headlessInterval(const Duration(minutes: 14)), + const Duration(minutes: 15), + ); + expect( + PollCadence.headlessInterval(const Duration(minutes: 15)), + const Duration(minutes: 15), + ); + expect( + PollCadence.headlessInterval(const Duration(minutes: 20)), + const Duration(minutes: 20), + ); + expect( + PollCadence.headlessInterval(const Duration(minutes: 60)), + const Duration(minutes: 60), + ); + }, + ); } int _rustMinutes(String source, String constant) { diff --git a/apps/phone_flutter/test/provider_connection_test.dart b/apps/phone_flutter/test/provider_connection_test.dart index a665925..c49f791 100644 --- a/apps/phone_flutter/test/provider_connection_test.dart +++ b/apps/phone_flutter/test/provider_connection_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:ward_pulse_phone/providers/provider_connection.dart'; import 'package:ward_pulse_phone/sync/poll_cadence.dart'; @@ -22,6 +24,19 @@ void main() { expect(providerFamilyLabel(ProviderFamily.openai), 'OpenAI'); }); + test('storage keys round-trip back to their connection id', () { + for (final connection in providerConnectionCatalog()) { + expect( + ProviderConnectionId.fromStorageKey(connection.id.storageKey), + connection.id, + reason: connection.id.storageKey, + ); + } + // Ring ids may carry a key this phone build does not know yet. + expect(ProviderConnectionId.fromStorageKey('mock.plan'), isNull); + expect(ProviderConnectionId.fromStorageKey('anthropic'), isNull); + }); + test('plan sign-in rows omit pasted-secret hints', () { for (final connection in providerConnectionCatalog()) { final signInPlan = @@ -54,4 +69,38 @@ void main() { ); } }); + + test('Rust alert rules key off catalog storage keys', () { + final catalogKeys = + providerConnectionCatalog() + .map((connection) => connection.id.storageKey) + .toSet(); + + // Mock is debug-only data and has no connection row to configure. + final rustKeys = _rustConnectionStorageKeys().where( + (key) => key != 'mock.plan', + ); + + expect(catalogKeys, containsAll(rustKeys)); + }); +} + +/// Rust owns the connection keys, so this reads them from its source. +/// +/// Scoped to the `connection` module so unrelated constants cannot leak in. +Set _rustConnectionStorageKeys() { + final source = + File('../../core/ward-pulse-core/src/model/mod.rs').readAsStringSync(); + final table = RegExp( + r'pub mod connection \{.*?\n\}', + dotAll: true, + ).firstMatch(source); + expect(table, isNotNull, reason: 'connection module not found in Rust'); + + final keys = { + for (final match in RegExp(r'= "([^"]+)";').allMatches(table!.group(0)!)) + match.group(1)!, + }; + expect(keys, isNotEmpty, reason: 'no storage keys parsed from Rust'); + return keys; } diff --git a/apps/phone_flutter/test/provider_status_severity_test.dart b/apps/phone_flutter/test/provider_status_severity_test.dart new file mode 100644 index 0000000..001ec08 --- /dev/null +++ b/apps/phone_flutter/test/provider_status_severity_test.dart @@ -0,0 +1,67 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:ward_pulse_phone/dashboard/dashboard_models.dart'; +import 'package:ward_pulse_phone/dashboard/provider_status_severity.dart'; + +void main() { + test('worst status of nothing is unknown', () { + expect(worstProviderStatus([]), ProviderStatus.unknown); + }); + + test('ranks statuses exactly like the Rust core', () { + final order = _rustSeverityOrder(); + expect(order.toSet(), ProviderStatus.values.toSet()); + + for (var lower = 0; lower < order.length; lower++) { + for (var higher = lower + 1; higher < order.length; higher++) { + final pair = [order[lower], order[higher]]; + + expect(worstProviderStatus(pair), order[higher], reason: '$pair'); + expect( + worstProviderStatus(pair.reversed), + order[higher], + reason: '$pair reversed', + ); + } + } + }); +} + +/// Statuses ordered by `ProviderStatus::severity` in the Rust core, weakest +/// first. +/// +/// Scoped to the `fn severity` body so neighbouring `match` arms cannot leak in. +List _rustSeverityOrder() { + final source = + File('../../core/ward-pulse-core/src/model/mod.rs').readAsStringSync(); + final table = RegExp( + r'fn severity.*?\n \}', + dotAll: true, + ).firstMatch(source); + expect(table, isNotNull, reason: 'severity not found in Rust'); + + final ranks = { + for (final match in RegExp( + r'Self::(\w+) => (\d+),', + ).allMatches(table!.group(0)!)) + ProviderStatus.values.byName(_dartName(match.group(1)!)): int.parse( + match.group(2)!, + ), + }; + expect(ranks, isNotEmpty, reason: 'no severities parsed from Rust'); + // A tie would make the order below ambiguous and the comparisons meaningless. + expect( + ranks.values.toSet(), + hasLength(ranks.length), + reason: 'Rust severities must stay distinct', + ); + + return ranks.keys.toList() + ..sort((left, right) => ranks[left]!.compareTo(ranks[right]!)); +} + +/// `RateLimited` in Rust is `rateLimited` in Dart. +String _dartName(String rustVariant) { + return rustVariant[0].toLowerCase() + rustVariant.substring(1); +} diff --git a/apps/phone_flutter/test/providers_screen_test.dart b/apps/phone_flutter/test/providers_screen_test.dart new file mode 100644 index 0000000..64a56a6 --- /dev/null +++ b/apps/phone_flutter/test/providers_screen_test.dart @@ -0,0 +1,363 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ward_pulse_phone/dashboard/dashboard_models.dart'; +import 'package:ward_pulse_phone/providers/claude_account_service.dart'; +import 'package:ward_pulse_phone/providers/codex_account_service.dart'; +import 'package:ward_pulse_phone/providers/provider_connection.dart'; +import 'package:ward_pulse_phone/providers/providers_screen.dart'; +import 'package:ward_pulse_phone/providers/provider_credential_store.dart'; +import 'package:ward_pulse_phone/settings/alert_threshold_preferences.dart'; + +void main() { + testWidgets('Cursor plan row shows Not connected and help Advanced paste', ( + tester, + ) async { + final store = _MemoryCredentialStore(); + var credentialsChanged = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ProvidersScreen( + onRefresh: _noRefresh, + credentialStore: store, + codexAccountService: const EmptyCodexAccountService(), + claudeAccountService: const EmptyClaudeAccountService(), + onCredentialsChanged: () => credentialsChanged++, + alertThresholds: const AlertThresholdPreferences(), + onAlertThresholdsChanged: (_) async {}, + cursorPlanSignIn: (_) async => null, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible( + find.text('Cursor plan'), + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + + expect(find.text('Cursor plan'), findsOneWidget); + expect(find.text('Experimental · dashboard sign-in'), findsOneWidget); + expect(find.text('Not connected'), findsWidgets); + + await tester.tap(find.byTooltip('About Cursor sign-in')); + await tester.pumpAndSettle(); + + expect(find.text('Cursor sign-in'), findsOneWidget); + expect(find.text('Advanced paste'), findsOneWidget); + await tester.tap(find.text('Advanced paste')); + await tester.pumpAndSettle(); + + expect(find.text('Cursor plan · Paste token'), findsOneWidget); + const pasted = + 'user_01TEST::eyJhbGciOiJSUzI1NiJ9.payload.signature_padding=='; + await tester.enterText(find.byType(TextFormField).first, pasted); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(await store.readSecret(ProviderConnections.cursorPlan), pasted); + expect(credentialsChanged, 1); + expect(find.text('Connected'), findsWidgets); + }); + + testWidgets('Cursor plan Sign in uses injected port and marks Connected', ( + tester, + ) async { + final store = _MemoryCredentialStore(); + var signInCalls = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ProvidersScreen( + onRefresh: _noRefresh, + credentialStore: store, + codexAccountService: const EmptyCodexAccountService(), + claudeAccountService: const EmptyClaudeAccountService(), + onCredentialsChanged: () {}, + alertThresholds: const AlertThresholdPreferences(), + onAlertThresholdsChanged: (_) async {}, + cursorPlanSignIn: (_) async { + signInCalls++; + return 'user_01WEB::eyJhbGciOiJSUzI1NiJ9.payload.signature_padding=='; + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible( + find.text('Cursor plan'), + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cursor plan')); + await tester.pumpAndSettle(); + + expect(signInCalls, 1); + expect( + await store.readSecret(ProviderConnections.cursorPlan), + 'user_01WEB::eyJhbGciOiJSUzI1NiJ9.payload.signature_padding==', + ); + expect(find.text('Connected'), findsWidgets); + }); + + testWidgets('budget limits are offered to platform connections only', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ProvidersScreen( + onRefresh: _noRefresh, + credentialStore: _MemoryCredentialStore(), + codexAccountService: const EmptyCodexAccountService(), + claudeAccountService: const EmptyClaudeAccountService(), + onCredentialsChanged: () {}, + alertThresholds: const AlertThresholdPreferences(), + onAlertThresholdsChanged: (_) async {}, + cursorPlanSignIn: (_) async => null, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible( + find.text('Team Admin API'), + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + + expect( + _inRow('Cursor plan', find.byTooltip('Budget limits')), + findsNothing, + ); + expect( + _inRow('Cursor plan', find.byTooltip('Alert thresholds')), + findsOneWidget, + ); + expect( + _inRow('Team Admin API', find.byTooltip('Budget limits')), + findsOneWidget, + ); + }); + + testWidgets('a budget period without a limit cannot be alerted on', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ProvidersScreen( + onRefresh: _noRefresh, + credentialStore: _MemoryCredentialStore(), + codexAccountService: const EmptyCodexAccountService(), + claudeAccountService: const EmptyClaudeAccountService(), + onCredentialsChanged: () {}, + alertThresholds: const AlertThresholdPreferences().withConnection( + ProviderConnections.cursorPlatform, + const ConnectionAlertThresholds( + budget: ConnectionBudget(today: 2000), + ), + ), + onAlertThresholdsChanged: (_) async {}, + cursorPlanSignIn: (_) async => null, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible( + find.text('Team Admin API'), + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + await tester.tap( + _inRow('Team Admin API', find.byTooltip('Alert thresholds')), + ); + await tester.pumpAndSettle(); + + final periods = + tester + .widgetList>(find.byType(DropdownButton)) + .toList(); + expect(periods, hasLength(3)); + expect(periods[0].onChanged, isNotNull, reason: 'Today has a limit'); + expect(periods[1].onChanged, isNull); + expect(periods[2].onChanged, isNull); + expect( + find.text('Set this period’s limit under Budget limits'), + findsNWidgets(2), + ); + }); + + testWidgets('a recovery is not something a connection configures', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ProvidersScreen( + onRefresh: _noRefresh, + credentialStore: _MemoryCredentialStore(), + codexAccountService: const EmptyCodexAccountService(), + claudeAccountService: const EmptyClaudeAccountService(), + onCredentialsChanged: () {}, + alertThresholds: const AlertThresholdPreferences(), + onAlertThresholdsChanged: (_) async {}, + cursorPlanSignIn: (_) async => null, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // The switch lives in Settings, alone, because a recovery has no threshold + // to set: it either happened or it did not. A per-connection rule here + // would be a threshold pretending to be one. + expect( + find.textContaining(RegExp('recovery', caseSensitive: false)), + findsNothing, + ); + }); + + group('the declared card order', () { + Future> shown( + WidgetTester tester, { + ProviderCredentialStore? store, + Map statuses = const {}, + }) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ProvidersScreen( + onRefresh: _noRefresh, + credentialStore: store ?? _MemoryCredentialStore(), + codexAccountService: const EmptyCodexAccountService(), + claudeAccountService: const EmptyClaudeAccountService(), + onCredentialsChanged: () {}, + alertThresholds: const AlertThresholdPreferences(), + onAlertThresholdsChanged: (_) async {}, + cursorPlanSignIn: (_) async => null, + statuses: statuses, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + return ['OpenAI', 'Anthropic', 'Cursor']..sort( + (left, right) => tester + .getTopLeft(find.text(left)) + .dy + .compareTo(tester.getTopLeft(find.text(right)).dy), + ); + } + + testWidgets('without a snapshot the families keep a stable name order', ( + tester, + ) async { + // Nothing reported yet and nothing connected: the tab still has to open + // the same way every time. + expect(await shown(tester), ['Anthropic', 'Cursor', 'OpenAI']); + }); + + testWidgets('the family asking for attention comes first', (tester) async { + expect( + await shown( + tester, + statuses: const { + ProviderFamily.cursor: ProviderStatus.authRequired, + ProviderFamily.openai: ProviderStatus.ok, + ProviderFamily.anthropic: ProviderStatus.ok, + }, + ), + ['Cursor', 'Anthropic', 'OpenAI'], + ); + }); + + testWidgets('a subscription signed in without a pasted secret counts', ( + tester, + ) async { + // Nothing was pasted for OpenAI, but Codex is reporting, so the family is + // connected and outranks the two that report nothing at all. + expect( + await shown( + tester, + statuses: const {ProviderFamily.openai: ProviderStatus.ok}, + ), + ['OpenAI', 'Anthropic', 'Cursor'], + ); + }); + + testWidgets('a family with no credential sinks but stays on the tab', ( + tester, + ) async { + final store = _MemoryCredentialStore(); + await store.writeSecret(ProviderConnections.cursorPlatform, 'secret'); + + // Cursor has a secret and is healthy; the other two report nothing and + // hold nothing, so they follow it — still listed, because this tab is + // where one connects them. + expect( + await shown( + tester, + store: store, + statuses: const {ProviderFamily.cursor: ProviderStatus.ok}, + ), + ['Cursor', 'Anthropic', 'OpenAI'], + ); + }); + }); +} + +/// Scopes a finder to the catalog row titled [title]. +Finder _inRow(String title, Finder matching) => find.descendant( + of: find.widgetWithText(ListTile, title), + matching: matching, +); + +class _MemoryCredentialStore implements ProviderCredentialStore { + final _secrets = {}; + final _labels = {}; + + @override + Future readSecret(ProviderConnectionId id) async => _secrets[id]; + + @override + Future writeSecret(ProviderConnectionId id, String value) async { + _secrets[id] = value; + } + + @override + Future deleteSecret(ProviderConnectionId id) async { + _secrets.remove(id); + _labels.remove(id); + } + + @override + Future readLabel(ProviderConnectionId id) async => _labels[id]; + + @override + Future writeLabel(ProviderConnectionId id, String? value) async { + final trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) { + _labels.remove(id); + return; + } + _labels[id] = trimmed; + } +} + +/// These tests drive the screen, not the reload behind the pull. +Future _noRefresh() async {} diff --git a/apps/phone_flutter/test/pull_to_refresh_list_test.dart b/apps/phone_flutter/test/pull_to_refresh_list_test.dart new file mode 100644 index 0000000..f02a389 --- /dev/null +++ b/apps/phone_flutter/test/pull_to_refresh_list_test.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ward_pulse_phone/app/pull_to_refresh_list.dart'; + +void main() { + testWidgets('a pull opens a gap, spins in it, and reloads', (tester) async { + var reloads = 0; + // Held open, because the gap lasts exactly as long as the reload does. + final reload = Completer(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PullToRefreshList( + onRefresh: () { + reloads++; + return reload.future; + }, + children: const [ + SizedBox(height: 400, child: Text('first row')), + SizedBox(height: 400, child: Text('second row')), + ], + ), + ), + ), + ); + + final firstRowTop = tester.getTopLeft(find.text('first row')).dy; + + await tester.drag( + find.text('first row'), + const Offset(0, 300), + touchSlopY: 0, + ); + await tester.pump(); + expect(find.byType(RefreshProgressIndicator), findsOneWidget); + + // Once the reload starts, the list moves down by the gap the spinner turns + // in — the point of this widget: nothing of the content is covered. The + // indicator settles first, so the gap opens a few frames after the release. + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 300)); + expect( + tester.getTopLeft(find.text('first row')).dy, + greaterThan(firstRowTop), + ); + + expect(reloads, 1); + + // The gap closes once the reload lands. + reload.complete(); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('first row')).dy, firstRowTop); + }); + + testWidgets('a tab shorter than its viewport still answers a pull', ( + tester, + ) async { + var reloads = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PullToRefreshList( + onRefresh: () async => reloads++, + children: const [Text('only row')], + ), + ), + ), + ); + + await tester.drag( + find.text('only row'), + const Offset(0, 300), + touchSlopY: 0, + ); + await tester.pumpAndSettle(); + + expect(reloads, 1); + }); +} diff --git a/apps/phone_flutter/test/recovery_notification_preferences_test.dart b/apps/phone_flutter/test/recovery_notification_preferences_test.dart new file mode 100644 index 0000000..ad94ce0 --- /dev/null +++ b/apps/phone_flutter/test/recovery_notification_preferences_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ward_pulse_phone/dashboard/dashboard_models.dart'; +import 'package:ward_pulse_phone/settings/watch_ring_preferences.dart'; +import 'package:ward_pulse_phone/sync/watch_sync_service.dart'; +import 'package:ward_pulse_phone/settings/recovery_notification_preferences.dart'; + +void main() { + test('the switch is the phone\'s alone, so other surfaces cannot lose it', () { + final payload = + WatchDashboardSummaryPayload.fromSnapshot( + DashboardSnapshot.empty(generatedAt: DateTime.utc(2026, 8, 17)), + const WatchRingPreferences(), + ).encode(); + + // Reinstalling the watch app or re-adding the widget cannot reset a setting + // they never carry: it lives in phone storage and travels nowhere. + expect(payload, isNot(contains('recovery'))); + expect(payload, isNot(contains('notify'))); + }); + + test('a phone that was never asked interrupts', () { + // Unset is not "off": the reader has said nothing yet, and Android still + // gates the post behind its own permission. + expect(recoveryNotificationsFrom(null), isTrue); + }); + + test('only an explicit no turns it off', () { + expect(recoveryNotificationsFrom('false'), isFalse); + expect(recoveryNotificationsFrom('true'), isTrue); + // Anything unreadable leans the way the unset store does. + expect(recoveryNotificationsFrom('yes please'), isTrue); + }); +} diff --git a/apps/phone_flutter/test/recovery_notifications_test.dart b/apps/phone_flutter/test/recovery_notifications_test.dart new file mode 100644 index 0000000..42abc50 --- /dev/null +++ b/apps/phone_flutter/test/recovery_notifications_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ward_pulse_phone/sync/recovery_notifications.dart'; + +void main() { + int idOf(String accountId, String allowanceId) => recoveryNotificationId(( + accountId: accountId, + provider: 'claude', + allowanceId: allowanceId, + label: 'Weekly plan', + resetsAt: null, + )); + + test('a window keeps its id, so a second telling replaces the first', () { + // The reporting order risks a repeat on purpose, and that is only harmless + // while the id stays the same one — run after run, not just call after + // call, which is why it is not a `hashCode`. + expect(idOf('claude-local', 'weekly'), 2077179748); + }); + + test('what it says is the family and the window, and nothing else', () { + const recovery = ( + accountId: 'claude-local', + provider: 'claude', + allowanceId: 'claude-weekly', + label: 'Weekly plan', + resetsAt: null, + ); + + final title = recoveryNotificationTitle(recovery); + + expect(title, 'Claude · Weekly plan'); + // The rule from SECURITY_MODEL.md, read off the text a locked screen shows. + expect(title, isNot(contains(recovery.accountId))); + expect(title, isNot(contains(recovery.allowanceId))); + expect(recoveryNotificationBody, 'Usable again.'); + }); + + test('two windows of one account do not share an id', () { + expect(idOf('claude-local', 'weekly'), isNot(idOf('claude-local', 'five'))); + }); + + test('one window name under two accounts does not collide', () { + expect( + idOf('claude-local', 'weekly'), + isNot(idOf('codex-local', 'weekly')), + ); + }); +} diff --git a/apps/phone_flutter/test/recovery_watchlist_test.dart b/apps/phone_flutter/test/recovery_watchlist_test.dart new file mode 100644 index 0000000..7388af6 --- /dev/null +++ b/apps/phone_flutter/test/recovery_watchlist_test.dart @@ -0,0 +1,484 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:ward_pulse_phone/dashboard/dashboard_models.dart'; +import 'package:ward_pulse_phone/dashboard/plan_recoveries.dart'; +import 'package:ward_pulse_phone/sync/recovery_notifications.dart'; +import 'package:ward_pulse_phone/settings/watch_ring_preferences.dart'; +import 'package:ward_pulse_phone/sync/poll_cadence.dart'; +import 'package:ward_pulse_phone/sync/recovery_wake.dart'; +import 'package:ward_pulse_phone/sync/watch_sync_service.dart'; +import 'package:ward_pulse_phone/sync/recovery_watchlist.dart'; + +void main() { + test('a phone that has never looked remembers nothing', () async { + // Not an error state: the first poll is what fills the list, and until then + // there is no past for an edge to be measured against. + expect(await const DisabledRecoveryWatchlistStore().read(), isEmpty); + }); + + test('demo data is remembered as nothing at all', () async { + final store = _MemoryRecoveryWatchlistStore(); + await store.write(const [ + (accountId: 'claude-local', allowanceId: 'claude-weekly'), + ]); + + // Demo windows are invented: left in the list, they would make the first + // live poll after leaving demo mode look like a recovery. The snapshot + // below is never read, because demo mode short-circuits before the core is + // asked anything. + await syncPlanRecoveries( + DashboardSnapshot.empty(generatedAt: DateTime.utc(2026, 8, 17)), + store, + const SilentRecoveryNotifier(), + const DisabledRecoveryWakeScheduler(), + mockData: true, + ); + + expect(await store.read(), isEmpty); + }); + + test('a later look replaces the list rather than adding to it', () async { + final store = _MemoryRecoveryWatchlistStore(); + await store.write(const [ + (accountId: 'claude-local', allowanceId: 'claude-weekly'), + ]); + + // The weekly window refilled and the session one ran out. What is spent now + // is the whole answer, not a running tally. + await syncPlanRecoveries( + DashboardSnapshot.empty(generatedAt: DateTime.utc(2026, 8, 17)), + store, + const SilentRecoveryNotifier(), + const DisabledRecoveryWakeScheduler(), + readRecoveries: (_, _) => const [], + readWindows: + (_) => const [ + (accountId: 'claude-local', allowanceId: 'claude-session'), + ], + ); + + expect(await store.read(), const [ + (accountId: 'claude-local', allowanceId: 'claude-session'), + ]); + }); + + test('a window that came back is reported, then forgotten', () async { + final store = _MemoryRecoveryWatchlistStore(); + await store.write(const [ + (accountId: 'claude-local', allowanceId: 'claude-weekly'), + ]); + final notifier = _RecordingRecoveryNotifier(); + final wake = _RecordingWake(); + + await syncPlanRecoveries( + DashboardSnapshot.empty(generatedAt: DateTime.utc(2026, 8, 17)), + store, + notifier, + wake, + readRecoveries: + (_, exhausted) => [ + for (final key in exhausted) + ( + accountId: key.accountId, + provider: 'claude', + allowanceId: key.allowanceId, + label: 'Weekly plan', + resetsAt: DateTime.utc(2026, 8, 18), + ), + ], + readWindows: (_) => const [], + ); + + expect(notifier.reported.single.label, 'Weekly plan'); + // Reported first, remembered second: an empty snapshot leaves nothing spent. + expect(await store.read(), isEmpty); + }); + + test('a notifier that refuses one window does not stop the rest', () async { + final store = _MemoryRecoveryWatchlistStore(); + await store.write(const [ + (accountId: 'claude-local', allowanceId: 'claude-weekly'), + (accountId: 'claude-local', allowanceId: 'claude-session'), + ]); + final notifier = _RecordingRecoveryNotifier(refuse: 'claude-weekly'); + final wake = _RecordingWake(); + + await syncPlanRecoveries( + DashboardSnapshot.empty(generatedAt: DateTime.utc(2026, 8, 17)), + store, + notifier, + wake, + readRecoveries: + (_, exhausted) => [ + for (final key in exhausted) + ( + accountId: key.accountId, + provider: 'claude', + allowanceId: key.allowanceId, + label: key.allowanceId, + resetsAt: null, + ), + ], + readWindows: (_) => const [], + ); + + // The second window is still told, and the list still moves on — otherwise + // a stuck list would go on missing every window exhausted after it. + expect(notifier.reported.single.allowanceId, 'claude-session'); + expect(await store.read(), isEmpty); + }); + + test('the wake is booked for the soonest window still spent', () async { + final source = DashboardSnapshot.fromJsonString( + File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), + ); + final snapshot = _withAllowances(source, { + 'weekly': DateTime.utc(2026, 8, 18, 9), + 'session': DateTime.utc(2026, 8, 17, 14), + }); + final store = _MemoryRecoveryWatchlistStore(); + final wake = _RecordingWake(); + + await syncPlanRecoveries( + snapshot, + store, + const SilentRecoveryNotifier(), + wake, + readRecoveries: (_, _) => const [], + readWindows: + (_) => const [ + (accountId: 'claude-local', allowanceId: 'weekly'), + (accountId: 'claude-local', allowanceId: 'session'), + ], + // Pinned, or the wall clock decides the answer: both instants have to be + // ahead for "the soonest" to mean anything. + now: () => DateTime.utc(2026, 8, 17, 12), + ); + + // Two windows are spent; one wake is enough, because the poll it starts + // looks at both. The earlier instant is the one worth booking. + expect(wake.booked, [DateTime.utc(2026, 8, 17, 14)]); + expect(wake.cancelled, 0); + }); + + test('a window whose reset moves gets the wake booked again', () async { + final source = DashboardSnapshot.fromJsonString( + File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), + ); + final store = _MemoryRecoveryWatchlistStore(); + final wake = _RecordingWake(); + const spent = [(accountId: 'claude-local', allowanceId: 'weekly')]; + + // Pinned: these instants are only "ahead" until the day they are not, and + // a test that rots with the calendar is a test nobody trusts. + final rightNow = DateTime.utc(2026, 8, 17, 12); + Future pollWithReset(DateTime resetsAt) => syncPlanRecoveries( + _withAllowances(source, {'weekly': resetsAt}), + store, + const SilentRecoveryNotifier(), + wake, + readRecoveries: (_, _) => const [], + readWindows: (_) => spent, + now: () => rightNow, + ); + + await pollWithReset(DateTime.utc(2026, 8, 18, 9)); + await pollWithReset(DateTime.utc(2026, 8, 18, 15)); + + // The provider moved the window; the booking follows it. On the platform + // side this is what `ExistingWorkPolicy.replace` is for — the default would + // keep the first instant and the reader would be woken too early. + expect(wake.booked, [ + DateTime.utc(2026, 8, 18, 9), + DateTime.utc(2026, 8, 18, 15), + ]); + }); + + test('a wake never lands sooner than the poll floor', () async { + final source = DashboardSnapshot.fromJsonString( + File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), + ); + final wake = _RecordingWake(); + final rightNow = DateTime.utc(2026, 8, 17, 12); + + await syncPlanRecoveries( + // The window rolls in a minute, well inside the floor. + _withAllowances(source, { + 'weekly': rightNow.add(const Duration(minutes: 1)), + }), + _MemoryRecoveryWatchlistStore(), + const SilentRecoveryNotifier(), + wake, + readRecoveries: (_, _) => const [], + readWindows: + (_) => const [(accountId: 'claude-local', allowanceId: 'weekly')], + now: () => rightNow, + ); + + // Waking on the instant would put two polls a minute apart, and the floor + // is the strictest a connection allows — a recovery is not an exemption. + expect(wake.booked, [ + rightNow.add(const Duration(minutes: PollCadence.minRefreshMinutes)), + ]); + }); + + test('a reset the provider has not caught up to books nothing', () async { + final source = DashboardSnapshot.fromJsonString( + File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), + ); + final wake = _RecordingWake(); + final rightNow = DateTime.utc(2026, 8, 17, 12); + + await syncPlanRecoveries( + // The window says it rolled an hour ago and still reads spent, which is + // what a provider aggregating hourly looks like. + _withAllowances(source, { + 'weekly': rightNow.subtract(const Duration(hours: 1)), + }), + _MemoryRecoveryWatchlistStore(), + const SilentRecoveryNotifier(), + wake, + readRecoveries: (_, _) => const [], + readWindows: + (_) => const [(accountId: 'claude-local', allowanceId: 'weekly')], + now: () => rightNow, + ); + + // Booking here would wake, see the same thing, and book again — a + // five-minute loop for as long as the provider lags. The cadence covers it. + expect(wake.booked, isEmpty); + expect(wake.cancelled, 1); + }); + + test('a reset the provider has not caught up to books nothing', () async { + final source = DashboardSnapshot.fromJsonString( + File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), + ); + final wake = _RecordingWake(); + final rightNow = DateTime.utc(2026, 8, 17, 12); + + await syncPlanRecoveries( + // The window says it rolled an hour ago and still reads spent, which is + // what a provider aggregating hourly looks like. + _withAllowances(source, { + 'weekly': rightNow.subtract(const Duration(hours: 1)), + }), + _MemoryRecoveryWatchlistStore(), + const SilentRecoveryNotifier(), + wake, + readRecoveries: (_, _) => const [], + readWindows: + (_) => const [(accountId: 'claude-local', allowanceId: 'weekly')], + now: () => rightNow, + ); + + // Booking here would wake, see the same thing, and book again — a + // five-minute loop for as long as the provider lags. The cadence covers it. + expect(wake.booked, isEmpty); + expect(wake.cancelled, 1); + }); + + test('the switch silences the telling, not the watching', () async { + final source = DashboardSnapshot.fromJsonString( + File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), + ); + final store = _MemoryRecoveryWatchlistStore(); + await store.write(const [ + (accountId: 'claude-local', allowanceId: 'weekly'), + ]); + final notifier = _RecordingRecoveryNotifier(); + final wake = _RecordingWake(); + final rightNow = DateTime.utc(2026, 8, 17, 12); + final resetsAt = rightNow.add(const Duration(hours: 3)); + + await syncPlanRecoveries( + _withAllowances(source, {'weekly': resetsAt}), + store, + notifier, + wake, + notifications: false, + readRecoveries: + (_, _) => const [ + ( + accountId: 'claude-local', + provider: 'claude', + allowanceId: 'weekly', + label: 'Weekly plan', + resetsAt: null, + ), + ], + readWindows: + (_) => const [(accountId: 'claude-local', allowanceId: 'weekly')], + now: () => rightNow, + ); + + // Nothing is said, and everything else carries on: the list moves and the + // wake is booked, so turning the switch back on picks up where it was. + expect(notifier.reported, isEmpty); + expect(await store.read(), hasLength(1)); + expect(wake.booked, [resetsAt]); + }); + + test('nothing spent takes the wake off the books', () async { + final store = _MemoryRecoveryWatchlistStore(); + final wake = _RecordingWake(); + + await syncPlanRecoveries( + DashboardSnapshot.empty(generatedAt: DateTime.utc(2026, 8, 17)), + store, + const SilentRecoveryNotifier(), + wake, + readRecoveries: (_, _) => const [], + readWindows: (_) => const [], + ); + + expect(wake.booked, isEmpty); + expect(wake.cancelled, 1); + }); + + test('a reported recovery adds nothing to alerts, phone or watch', () async { + final source = DashboardSnapshot.fromJsonString( + File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), + ); + final snapshot = _withAllowances(source, { + 'weekly': DateTime.utc(2026, 8, 18, 9), + }); + final store = _MemoryRecoveryWatchlistStore(); + await store.write(const [ + (accountId: 'claude-local', allowanceId: 'weekly'), + ]); + final notifier = _RecordingRecoveryNotifier(); + + await syncPlanRecoveries( + snapshot, + store, + notifier, + _RecordingWake(), + readRecoveries: + (_, _) => const [ + ( + accountId: 'claude-local', + provider: 'claude', + allowanceId: 'weekly', + label: 'Weekly plan', + resetsAt: null, + ), + ], + readWindows: (_) => const [], + ); + + // A recovery is an edge, not a condition: it is told once and leaves no + // standing state behind. An alert would be listed on the Dashboard and + // counted on the watch, and this one is neither. + expect(notifier.reported, hasLength(1)); + expect(snapshot.alerts, isEmpty); + expect( + WatchDashboardSummaryPayload.fromSnapshot( + snapshot, + const WatchRingPreferences(), + ).encode(), + contains('"alerts":[]'), + ); + }); + + test('with nothing remembered the core is not even asked', () async { + final store = _MemoryRecoveryWatchlistStore(); + final notifier = _RecordingRecoveryNotifier(); + final wake = _RecordingWake(); + var asked = false; + + await syncPlanRecoveries( + DashboardSnapshot.empty(generatedAt: DateTime.utc(2026, 8, 17)), + store, + notifier, + wake, + readRecoveries: (_, _) { + asked = true; + return const []; + }, + readWindows: (_) => const [], + ); + + // No past, no edge — and no snapshot serialized across the FFI boundary to + // be told so, which is most polls. + expect(asked, isFalse); + expect(notifier.reported, isEmpty); + }); +} + +class _MemoryRecoveryWatchlistStore implements RecoveryWatchlistStore { + List _keys = const []; + + @override + Future> read() async => _keys; + + @override + Future write(List keys) async => _keys = keys; +} + +class _RecordingRecoveryNotifier implements RecoveryNotifier { + _RecordingRecoveryNotifier({this.refuse}); + + /// Allowance id this notifier throws on, standing in for a platform that + /// refuses to post — a revoked permission, say. + final String? refuse; + + final reported = []; + + @override + Future notify(PlanRecovery recovery) async { + if (recovery.allowanceId == refuse) { + throw StateError('nowhere to post ${recovery.allowanceId}'); + } + reported.add(recovery); + } + + @override + Future requestPermission() async => true; +} + +class _RecordingWake implements RecoveryWakeScheduler { + final booked = []; + var cancelled = 0; + + @override + Future scheduleAt(DateTime resetsAt) async => booked.add(resetsAt); + + @override + Future cancel() async => cancelled++; +} + +/// The golden snapshot with one account whose windows reset at [resets]. +DashboardSnapshot _withAllowances( + DashboardSnapshot source, + Map resets, +) { + return DashboardSnapshot.fromJson({ + ...source.toJson(), + 'accounts': [ + { + ...source.primaryAccount!.toJson(), + 'accountId': 'claude-local', + 'provider': 'claude', + 'allowances': [ + for (final entry in resets.entries) + { + 'id': entry.key, + 'source': 'plan', + 'label': entry.key, + 'usedPercent': 100.0, + 'used': null, + 'limit': null, + 'remaining': null, + 'windowMinutes': null, + 'resetsAt': entry.value.toIso8601String(), + 'status': 'warning', + }, + ], + 'buckets': [], + 'modelBreakdown': [], + }, + ], + }); +} diff --git a/apps/phone_flutter/test/settings_cursor_plan_test.dart b/apps/phone_flutter/test/settings_cursor_plan_test.dart deleted file mode 100644 index af832a3..0000000 --- a/apps/phone_flutter/test/settings_cursor_plan_test.dart +++ /dev/null @@ -1,159 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:ward_pulse_phone/providers/claude_account_service.dart'; -import 'package:ward_pulse_phone/providers/codex_account_service.dart'; -import 'package:ward_pulse_phone/providers/provider_connection.dart'; -import 'package:ward_pulse_phone/providers/provider_credential_store.dart'; -import 'package:ward_pulse_phone/settings/consumption_display_preferences.dart'; -import 'package:ward_pulse_phone/settings/refresh_interval_preferences.dart'; -import 'package:ward_pulse_phone/settings/settings_screen.dart'; -import 'package:ward_pulse_phone/settings/watch_ring_preferences.dart'; - -void main() { - testWidgets('Cursor plan row shows Not connected and help Advanced paste', ( - tester, - ) async { - final store = _MemoryCredentialStore(); - var credentialsChanged = 0; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SettingsScreen( - snapshot: null, - credentialStore: store, - codexAccountService: const EmptyCodexAccountService(), - claudeAccountService: const EmptyClaudeAccountService(), - displayPreferences: const ConsumptionDisplayPreferences(), - onDisplayPreferencesChanged: (_) async {}, - refreshInterval: const RefreshIntervalPreference(), - onRefreshIntervalChanged: (_) async {}, - ringPreferences: const WatchRingPreferences(), - onRingPreferencesChanged: (_) async {}, - onSyncWatch: () async {}, - debugDataAvailable: false, - mockDataEnabled: false, - onMockDataEnabledChanged: (_) async {}, - onCredentialsChanged: () => credentialsChanged++, - cursorPlanSignIn: (_) async => null, - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.scrollUntilVisible( - find.text('Cursor plan'), - 300, - scrollable: find.byType(Scrollable).first, - ); - await tester.pumpAndSettle(); - - expect(find.text('Cursor plan'), findsOneWidget); - expect(find.text('Experimental · dashboard sign-in'), findsOneWidget); - expect(find.text('Not connected'), findsWidgets); - - await tester.tap(find.byTooltip('About Cursor sign-in')); - await tester.pumpAndSettle(); - - expect(find.text('Cursor sign-in'), findsOneWidget); - expect(find.text('Advanced paste'), findsOneWidget); - await tester.tap(find.text('Advanced paste')); - await tester.pumpAndSettle(); - - expect(find.text('Cursor plan · Paste token'), findsOneWidget); - const pasted = - 'user_01TEST::eyJhbGciOiJSUzI1NiJ9.payload.signature_padding=='; - await tester.enterText(find.byType(TextFormField).first, pasted); - await tester.tap(find.text('Save')); - await tester.pumpAndSettle(); - - expect(await store.readSecret(ProviderConnections.cursorPlan), pasted); - expect(credentialsChanged, 1); - expect(find.text('Connected'), findsWidgets); - }); - - testWidgets('Cursor plan Sign in uses injected port and marks Connected', ( - tester, - ) async { - final store = _MemoryCredentialStore(); - var signInCalls = 0; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SettingsScreen( - snapshot: null, - credentialStore: store, - codexAccountService: const EmptyCodexAccountService(), - claudeAccountService: const EmptyClaudeAccountService(), - displayPreferences: const ConsumptionDisplayPreferences(), - onDisplayPreferencesChanged: (_) async {}, - refreshInterval: const RefreshIntervalPreference(), - onRefreshIntervalChanged: (_) async {}, - ringPreferences: const WatchRingPreferences(), - onRingPreferencesChanged: (_) async {}, - onSyncWatch: () async {}, - debugDataAvailable: false, - mockDataEnabled: false, - onMockDataEnabledChanged: (_) async {}, - onCredentialsChanged: () {}, - cursorPlanSignIn: (_) async { - signInCalls++; - return 'user_01WEB::eyJhbGciOiJSUzI1NiJ9.payload.signature_padding=='; - }, - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.scrollUntilVisible( - find.text('Cursor plan'), - 300, - scrollable: find.byType(Scrollable).first, - ); - await tester.pumpAndSettle(); - await tester.tap(find.text('Cursor plan')); - await tester.pumpAndSettle(); - - expect(signInCalls, 1); - expect( - await store.readSecret(ProviderConnections.cursorPlan), - 'user_01WEB::eyJhbGciOiJSUzI1NiJ9.payload.signature_padding==', - ); - expect(find.text('Connected'), findsWidgets); - }); -} - -class _MemoryCredentialStore implements ProviderCredentialStore { - final _secrets = {}; - final _labels = {}; - - @override - Future readSecret(ProviderConnectionId id) async => _secrets[id]; - - @override - Future writeSecret(ProviderConnectionId id, String value) async { - _secrets[id] = value; - } - - @override - Future deleteSecret(ProviderConnectionId id) async { - _secrets.remove(id); - _labels.remove(id); - } - - @override - Future readLabel(ProviderConnectionId id) async => _labels[id]; - - @override - Future writeLabel(ProviderConnectionId id, String? value) async { - final trimmed = value?.trim(); - if (trimmed == null || trimmed.isEmpty) { - _labels.remove(id); - return; - } - _labels[id] = trimmed; - } -} diff --git a/apps/phone_flutter/test/surface_order_test.dart b/apps/phone_flutter/test/surface_order_test.dart new file mode 100644 index 0000000..f3d4b9b --- /dev/null +++ b/apps/phone_flutter/test/surface_order_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ward_pulse_phone/app/surface_order.dart'; +import 'package:ward_pulse_phone/dashboard/dashboard_models.dart'; + +void main() { + Money usd(int minorUnits) => Money(minorUnits: minorUnits, currency: 'USD'); + + group('status', () { + test('the worse status comes first', () { + expect( + compareByStatus(ProviderStatus.error, ProviderStatus.ok), + lessThan(0), + ); + expect( + compareByStatus(ProviderStatus.ok, ProviderStatus.error), + greaterThan(0), + ); + }); + + test('a status reported by nobody still outranks a healthy one', () { + expect( + compareByStatus(ProviderStatus.unknown, ProviderStatus.ok), + lessThan(0), + ); + }); + + test('the same status ties, leaving the next key to decide', () { + expect( + compareByStatus(ProviderStatus.warning, ProviderStatus.warning), + 0, + ); + }); + }); + + group('spend', () { + test('the larger amount comes first', () { + expect(compareBySpend(usd(100), usd(900)), greaterThan(0)); + expect(compareBySpend(usd(900), usd(100)), lessThan(0)); + }); + + test('nothing spent reads as zero rather than as missing', () { + expect(compareBySpend(null, usd(1)), greaterThan(0)); + expect(compareBySpend(null, null), 0); + expect(compareBySpend(null, usd(0)), 0); + }); + + test('two currencies tie: the device has no exchange rate', () { + expect( + compareBySpend(Money(minorUnits: 100000, currency: 'JPY'), usd(5000)), + 0, + ); + }); + }); + + group('frozen order', () { + test('holds its order while the same keys come back reranked', () { + final order = FrozenOrder(); + + expect(order.hold(['codex', 'claude', 'cursor']), [ + 'codex', + 'claude', + 'cursor', + ]); + // Spend moved and the ranking flipped; the reader's list must not. + expect(order.hold(['cursor', 'claude', 'codex']), [ + 'codex', + 'claude', + 'cursor', + ]); + }); + + test('does not move when the caller reorders the list it handed in', () { + final order = FrozenOrder(); + final ranked = ['codex', 'claude']; + + order.hold(ranked); + ranked.sort(); + + expect(order.hold(['claude', 'codex']), ['codex', 'claude']); + }); + + test('takes the new order once a key joins or leaves', () { + final order = FrozenOrder(); + order.hold(['codex', 'claude']); + + expect(order.hold(['cursor', 'codex', 'claude']), [ + 'cursor', + 'codex', + 'claude', + ]); + // And keeps the fresh one from then on. + expect(order.hold(['claude', 'codex', 'cursor']), [ + 'cursor', + 'codex', + 'claude', + ]); + expect(order.hold(['codex']), ['codex']); + }); + }); +} diff --git a/apps/phone_flutter/test/timestamp_format_test.dart b/apps/phone_flutter/test/timestamp_format_test.dart index c73d558..26e3676 100644 --- a/apps/phone_flutter/test/timestamp_format_test.dart +++ b/apps/phone_flutter/test/timestamp_format_test.dart @@ -6,7 +6,10 @@ void main() { final value = DateTime.utc(2026, 7, 25, 18, 42, 0); expect(formatLocal(value), isNot(contains('UTC'))); - expect(formatLocal(value), matches(RegExp(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$'))); + expect( + formatLocal(value), + matches(RegExp(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$')), + ); }); test('formatUtc keeps an explicit UTC label for disclosure', () { diff --git a/apps/phone_flutter/test/usage_history_chart_test.dart b/apps/phone_flutter/test/usage_history_chart_test.dart index 19767d3..8bcb0f6 100644 --- a/apps/phone_flutter/test/usage_history_chart_test.dart +++ b/apps/phone_flutter/test/usage_history_chart_test.dart @@ -21,6 +21,26 @@ void main() { expect(find.text('2026-07-14'), findsOneWidget); }); + testWidgets('plots Cursor-style request buckets without inventing cost', ( + tester, + ) async { + final buckets = [ + _requestBucket(DateTime.utc(2026, 7, 18), 77), + _requestBucket(DateTime.utc(2026, 7, 19), 110), + ]; + + final painter = await _pumpChart(tester, buckets); + + expect(find.text('187 requests'), findsOneWidget); + expect(find.text('No totals'), findsNothing); + expect(find.text('2026-07-18'), findsOneWidget); + expect(find.text('2026-07-20'), findsOneWidget); + expect( + await tester.runAsync(() => _primaryPixelCount(painter)), + greaterThan(0), + ); + }); + testWidgets('renders dense token history without inventing zero usage', ( tester, ) async { @@ -121,3 +141,16 @@ UsageBucket _tokenBucket(DateTime start, int tokens) { model: null, ); } + +UsageBucket _requestBucket(DateTime start, int requests) { + return UsageBucket( + startAt: start, + endAt: start.add(const Duration(days: 1)), + cost: null, + inputTokens: null, + outputTokens: null, + cachedTokens: null, + requests: requests, + model: null, + ); +} diff --git a/apps/phone_flutter/test/watch_credits_glance_test.dart b/apps/phone_flutter/test/watch_credits_glance_test.dart index 13417b2..689b44d 100644 --- a/apps/phone_flutter/test/watch_credits_glance_test.dart +++ b/apps/phone_flutter/test/watch_credits_glance_test.dart @@ -1,6 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:ward_pulse_phone/dashboard/dashboard_models.dart'; -import 'package:ward_pulse_phone/settings/consumption_display_preferences.dart'; import 'package:ward_pulse_phone/sync/watch_credits_glance.dart'; void main() { @@ -11,35 +10,18 @@ void main() { expect(compactCreditCount(1_400_000), '1.4M'); }); - test('resolves remaining purchased credits when enabled', () { + test('resolves remaining purchased credits', () { final snapshot = _snapshotWithPurchased(remaining: '500'); - final glance = resolveWatchCreditsGlance( - snapshot, - const ConsumptionDisplayPreferences(plan: true, purchased: true), - ); + final glance = resolveWatchCreditsGlance(snapshot); expect(glance, isNotNull); expect(glance!.text, '500'); expect(glance.label, 'Credits left'); expect(glance.provider, 'codex'); }); - test('omits credits glance when purchased display is off', () { - final snapshot = _snapshotWithPurchased(remaining: '500'); - expect( - resolveWatchCreditsGlance( - snapshot, - const ConsumptionDisplayPreferences(plan: true, purchased: false), - ), - isNull, - ); - }); - test('shows infinity for unlimited purchased credits', () { final snapshot = _snapshotWithPurchased(remaining: null, unlimited: true); - final glance = resolveWatchCreditsGlance( - snapshot, - const ConsumptionDisplayPreferences(purchased: true), - ); + final glance = resolveWatchCreditsGlance(snapshot); expect(glance?.text, '∞'); }); } diff --git a/apps/phone_flutter/test/watch_ring_preferences_test.dart b/apps/phone_flutter/test/watch_ring_preferences_test.dart index 7ae0f1d..82b1257 100644 --- a/apps/phone_flutter/test/watch_ring_preferences_test.dart +++ b/apps/phone_flutter/test/watch_ring_preferences_test.dart @@ -10,20 +10,45 @@ void main() { File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), ); + test('mock accounts offer no rings at all', () { + expect(watchRingCatalog(snapshot), isEmpty); + expect(watchRingCatalog(null), isEmpty); + }); + + test('offers a budget ring per period a connection reports spend for', () { + final catalog = watchRingCatalog(_planAndBudgetSnapshot()); + + // Week reports no spend, so it never becomes a slot. + expect(catalog.map((ring) => ring.id), [ + 'allowance.codex.codex-weekly', + 'budget.anthropic.platform.today', + 'budget.anthropic.platform.month', + ]); + final today = catalog[1]; + expect(today.isAvailable, isFalse); + // The reason names the budget entry, not the alert dialog. + expect(today.unavailableReason, contains('Providers · Budget limits')); + expect(today.catalogTitle, 'Anthropic platform · Today'); + expect(catalog.last.isAvailable, isTrue); + }); + test('defaults to the first available percent metrics when unset', () { - final rings = resolveWatchRings(snapshot, const WatchRingPreferences()); + final rings = resolveWatchRings( + _planAndBudgetSnapshot(), + const WatchRingPreferences(), + ); + // Plan windows first; a budget ring without a limit is not a candidate. expect(rings.map((ring) => ring.id), [ - 'budget.today', - 'budget.week', - 'budget.month', + 'allowance.codex.codex-weekly', + 'budget.anthropic.platform.month', ]); expect(rings.every((ring) => ring.usedPercent != null), isTrue); }); test('honors an explicit empty selection', () { final rings = resolveWatchRings( - snapshot, + _planAndBudgetSnapshot(), const WatchRingPreferences(selectedIds: []), ); @@ -32,22 +57,139 @@ void main() { test('keeps selection order and drops unavailable ids', () { final rings = resolveWatchRings( - snapshot, + _planAndBudgetSnapshot(), const WatchRingPreferences( - selectedIds: ['budget.week', 'missing', 'budget.today'], + selectedIds: [ + 'budget.anthropic.platform.month', + 'missing', + 'allowance.codex.codex-weekly', + ], ), ); - expect(rings.map((ring) => ring.id), ['budget.week', 'budget.today']); + expect(rings.map((ring) => ring.id), [ + 'budget.anthropic.platform.month', + 'allowance.codex.codex-weekly', + ]); }); - test('clamps stored ids to four slots', () { + test('clamps stored ids to three slots', () { const preferences = WatchRingPreferences( selectedIds: ['a', 'b', 'c', 'd', 'e'], ); - expect(preferences.clampedIds, ['a', 'b', 'c', 'd']); - expect(jsonEncode(preferences.clampedIds), '["a","b","c","d"]'); + expect(preferences.clampedIds, ['a', 'b', 'c']); + expect(jsonEncode(preferences.clampedIds), '["a","b","c"]'); + }); + + test('the surface keeps four rings when they are three bands', () { + final withPair = _cursorPairAndCodexSnapshot(); + const picked = WatchRingPreferences( + selectedIds: [ + cursorOwnPoolId, + cursorOtherPoolId, + 'allowance.codex.codex-weekly', + ], + ); + + final surface = orderWatchRingsForSurface( + resolveWatchRings(withPair, picked), + snapshot: withPair, + ); + // Cutting at three rings dropped a pool and the band arrived undivided. + expect(surface.map((ring) => ring.id), contains(cursorOwnPoolId)); + expect(surface.map((ring) => ring.id), contains(cursorOtherPoolId)); + final packed = pairCursorPools(surface); + // Two bands: Codex is tighter and sorts first, the pair follows whole. + expect(packed.length, 2); + expect(packed.last.$1.id, cursorOwnPoolId); + expect(packed.last.$2?.id, cursorOtherPoolId); + }); + + test( + 'a pool still joins its partner from behind a ring that does not fit', + () { + // The second pool is free once the first is in, so a full budget must not + // hide it: stopping at the first misfit would send the watch half a pair. + const crowded = WatchRingPreferences( + selectedIds: [ + 'allowance.claude.plan', + 'budget.anthropic.platform.week', + cursorOwnPoolId, + 'budget.anthropic.platform.today', + cursorOtherPoolId, + ], + ); + + expect(crowded.clampedIds, [ + 'allowance.claude.plan', + 'budget.anthropic.platform.week', + cursorOwnPoolId, + cursorOtherPoolId, + ]); + expect(watchRingSlotCost(crowded.clampedIds), 3); + }, + ); + + test('a pair leaves room for a third band', () { + // Four rows, three bands: counting ids instead of bands cut the last tick, + // so the third arc could never be chosen once a pair was. + const withPair = WatchRingPreferences( + selectedIds: [ + cursorOwnPoolId, + cursorOtherPoolId, + 'allowance.claude.plan', + 'budget.anthropic.platform.week', + ], + ); + + expect(withPair.clampedIds, withPair.selectedIds); + expect(watchRingSlotCost(withPair.migratedIds), 3); + }); + + WatchRingMetric ring(String id, double used) => WatchRingMetric( + id: id, + label: id, + usedPercent: used, + status: ProviderStatus.ok, + ); + + test('the Cursor pools travel as one entry, own models first', () { + final packed = pairCursorPools([ + ring(cursorOtherPoolId, 90), + ring('allowance.claude.plan', 40), + ring(cursorOwnPoolId, 10), + ]); + + // One band, so one entry — and it keeps the place of the tighter half. + expect(packed.length, 2); + expect(packed.first.$1.id, cursorOwnPoolId); + expect(packed.first.$2?.id, cursorOtherPoolId); + expect(packed.last.$1.id, 'allowance.claude.plan'); + expect(packed.last.$2, isNull); + }); + + test('one Cursor pool alone is an ordinary ring', () { + final packed = pairCursorPools([ring(cursorOwnPoolId, 10)]); + + expect(packed.length, 1); + expect(packed.single.$2, isNull); + }); + + test('two pools cost one slot, one pool costs one too', () { + // The pair is a band, not two rings: picking both spends a single slot, so + // a third ring still fits (`WATCH_RING_DESIGN.md`, Split band). + expect(watchRingSlotCost([cursorOwnPoolId, cursorOtherPoolId]), 1); + expect(watchRingSlotCost([cursorOwnPoolId]), 1); + expect( + watchRingSlotCost([ + cursorOwnPoolId, + cursorOtherPoolId, + 'allowance.claude.plan', + 'budget.anthropic.platform.week', + ]), + 3, + ); }); test('surface order puts tightest remaining first and drops exhausted', () { @@ -72,10 +214,7 @@ void main() { ), ]; - expect(orderWatchRingsForSurface(rings).map((ring) => ring.id), [ - 'c', - 'a', - ]); + expect(orderWatchRingsForSurface(rings).map((ring) => ring.id), ['c', 'a']); }); test('equal plan percent prefers lower credit request runway', () { @@ -85,23 +224,20 @@ void main() { codexCredits: 3, claudeCredits: 12, ); - final rings = orderWatchRingsForSurface( - [ - const WatchRingMetric( - id: 'allowance.claude.plan', - label: 'Weekly', - usedPercent: 40, - status: ProviderStatus.ok, - ), - const WatchRingMetric( - id: 'allowance.codex.codex-weekly', - label: 'Weekly plan', - usedPercent: 40, - status: ProviderStatus.ok, - ), - ], - snapshot: dash, - ); + final rings = orderWatchRingsForSurface([ + const WatchRingMetric( + id: 'allowance.claude.plan', + label: 'Weekly', + usedPercent: 40, + status: ProviderStatus.ok, + ), + const WatchRingMetric( + id: 'allowance.codex.codex-weekly', + label: 'Weekly plan', + usedPercent: 40, + status: ProviderStatus.ok, + ), + ], snapshot: dash); expect(rings.map((ring) => ring.id), [ 'allowance.codex.codex-weekly', @@ -109,15 +245,16 @@ void main() { ]); }); - test('unlimited credits do not win secondary tightness over finite runway', () { - final dash = _twoPlanProvidersSnapshot( - codexUsed: 50, - claudeUsed: 50, - codexCredits: 4, - claudeUnlimitedPurchased: true, - ); - final rings = orderWatchRingsForSurface( - [ + test( + 'unlimited credits do not win secondary tightness over finite runway', + () { + final dash = _twoPlanProvidersSnapshot( + codexUsed: 50, + claudeUsed: 50, + codexCredits: 4, + claudeUnlimitedPurchased: true, + ); + final rings = orderWatchRingsForSurface([ const WatchRingMetric( id: 'allowance.claude.plan', label: 'Weekly', @@ -130,35 +267,28 @@ void main() { usedPercent: 50, status: ProviderStatus.ok, ), - ], - snapshot: dash, - ); + ], snapshot: dash); - expect(rings.first.id, 'allowance.codex.codex-weekly'); - }); + expect(rings.first.id, 'allowance.codex.codex-weekly'); + }, + ); test('missing credits on both sides falls through to stable ring id', () { - final dash = _twoPlanProvidersSnapshot( - codexUsed: 50, - claudeUsed: 50, - ); - final rings = orderWatchRingsForSurface( - [ - const WatchRingMetric( - id: 'allowance.claude.plan', - label: 'Weekly', - usedPercent: 50, - status: ProviderStatus.ok, - ), - const WatchRingMetric( - id: 'allowance.codex.codex-weekly', - label: 'Weekly plan', - usedPercent: 50, - status: ProviderStatus.ok, - ), - ], - snapshot: dash, - ); + final dash = _twoPlanProvidersSnapshot(codexUsed: 50, claudeUsed: 50); + final rings = orderWatchRingsForSurface([ + const WatchRingMetric( + id: 'allowance.claude.plan', + label: 'Weekly', + usedPercent: 50, + status: ProviderStatus.ok, + ), + const WatchRingMetric( + id: 'allowance.codex.codex-weekly', + label: 'Weekly plan', + usedPercent: 50, + status: ProviderStatus.ok, + ), + ], snapshot: dash); expect(rings.map((ring) => ring.id), [ 'allowance.claude.plan', @@ -187,14 +317,15 @@ void main() { sevenDayUsed: 70, codexUsed: 10, ); - final collapsed = collapseClaudePlanRing(dash.accounts - .firstWhere((a) => a.provider == 'claude') - .allowances)!; + final collapsed = + collapseClaudePlanRing( + dash.accounts.firstWhere((a) => a.provider == 'claude').allowances, + )!; expect(collapsed.id, claudePlanRingId); expect(collapsed.label, 'Weekly'); - expect(collapsed.settingsTitle, 'Claude plan'); - expect(collapsed.settingsSubtitle, 'Weekly · 30% left · OK'); + expect(collapsed.catalogTitle, 'Claude plan'); + expect(collapsed.catalogSubtitle, 'Weekly · 30% left · OK'); expect(collapsed.usedPercent, 70); }); @@ -204,9 +335,10 @@ void main() { sevenDayUsed: 55, codexUsed: 10, ); - final collapsed = collapseClaudePlanRing(dash.accounts - .firstWhere((a) => a.provider == 'claude') - .allowances)!; + final collapsed = + collapseClaudePlanRing( + dash.accounts.firstWhere((a) => a.provider == 'claude').allowances, + )!; expect(collapsed.label, '5h'); expect(collapsed.usedPercent, 55); @@ -218,9 +350,10 @@ void main() { sevenDayUsed: 100, codexUsed: 10, ); - final collapsed = collapseClaudePlanRing(dash.accounts - .firstWhere((a) => a.provider == 'claude') - .allowances)!; + final collapsed = + collapseClaudePlanRing( + dash.accounts.firstWhere((a) => a.provider == 'claude').allowances, + )!; expect(collapsed.label, '5h'); expect(collapsed.usedPercent, 40); @@ -229,13 +362,13 @@ void main() { test('migrates legacy Claude window ids to one plan slot', () { expect( migrateWatchRingSelectedIds([ - 'budget.today', + 'budget.anthropic.platform.month', 'allowance.claude.claude-five-hour', 'allowance.claude.claude-seven-day', 'allowance.codex.codex-weekly', ]), [ - 'budget.today', + 'budget.anthropic.platform.month', claudePlanRingId, 'allowance.codex.codex-weekly', ], @@ -269,6 +402,381 @@ void main() { expect(rings.map((r) => r.label), ['Weekly', 'Weekly plan']); }); }); + + test('drops retired purchased meter ring ids from prefs', () { + expect( + migrateWatchRingSelectedIds([ + 'allowance.claude.claude-extra-usage', + 'allowance.cursor.cursor-plan-models', + 'allowance.cursor.cursor-on-demand', + 'allowance.codex.codex-purchased-credits', + ]), + // A pool id is a catalog row and survives as itself. + [cursorOwnPoolId], + ); + }); + + test('a stored pair keeps both pools and still costs one slot', () { + const stored = [cursorOwnPoolId, cursorOtherPoolId, claudePlanRingId]; + + expect(migrateWatchRingSelectedIds(stored), stored); + // Three ids, two bands: the budget for a third ring is untouched. + expect(watchRingSlotCost(migrateWatchRingSelectedIds(stored)), 2); + }); + + test('the retired one-row id unfolds into the pools it stood for', () { + // One build stored the pair as a single row; those users keep both pools. + expect(migrateWatchRingSelectedIds([cursorPlanRingId, claudePlanRingId]), [ + cursorOwnPoolId, + cursorOtherPoolId, + claudePlanRingId, + ]); + }); + + test('a stored selection of only retired ids reads back as unset', () { + // Upgrade path: the summed budgets were the only rings a platform-only + // user could pick, so an emptied selection must not mean "no rings". + expect( + watchRingPreferencesFromStoredIds([ + 'budget.today', + 'budget.week', + ]).usesDefaults, + isTrue, + ); + // Stored empty stays the user's explicit choice. + expect(watchRingPreferencesFromStoredIds([]).selectedIds, isEmpty); + expect( + watchRingPreferencesFromStoredIds([ + 'budget.today', + 'budget.anthropic.platform.month', + ]).selectedIds, + ['budget.anthropic.platform.month'], + ); + }); + + test('drops the retired summed budget ring ids from prefs', () { + // The sum stood for no single connection, so there is no successor id. + expect( + migrateWatchRingSelectedIds([ + 'budget.today', + 'budget.anthropic.platform.month', + 'budget.week', + 'budget.month', + ]), + ['budget.anthropic.platform.month'], + ); + }); + + test('includes Cursor Models and Other Models in watch ring catalog', () { + final cursor = _asPlanConnection( + Map.from( + (snapshot.toJson()['accounts'] as List).first as Map, + ), + 'cursor.plan', + ); + cursor['provider'] = 'cursor'; + cursor['accountId'] = 'cursor-local'; + cursor['allowances'] = [ + { + 'id': 'cursor-plan-models', + 'source': 'plan', + 'label': 'Cursor Models', + 'usedPercent': 47.0, + 'used': null, + 'limit': null, + 'remaining': null, + 'unlimited': false, + 'windowMinutes': null, + 'resetsAt': null, + 'status': 'ok', + }, + { + 'id': 'cursor-plan-other', + 'source': 'plan', + 'label': 'Other Models', + 'usedPercent': 100.0, + 'used': null, + 'limit': null, + 'remaining': null, + 'unlimited': false, + 'windowMinutes': null, + 'resetsAt': null, + 'status': 'rateLimited', + }, + { + 'id': 'cursor-on-demand', + 'source': 'purchased', + 'label': 'On-demand usage', + 'usedPercent': 45.0, + 'used': {'value': '4.50', 'unit': 'credits'}, + 'limit': {'value': '10.00', 'unit': 'credits'}, + 'remaining': {'value': '5.50', 'unit': 'credits'}, + 'unlimited': false, + 'windowMinutes': null, + 'resetsAt': null, + 'status': 'ok', + }, + ]; + final withCursor = DashboardSnapshot.fromJson({ + ...snapshot.toJson(), + 'accounts': [cursor], + 'todayTotal': { + 'period': 'today', + 'spent': null, + 'limit': null, + 'remaining': null, + 'usedPercent': null, + 'projectedTotal': null, + 'status': 'unknown', + 'statusExplanation': null, + }, + 'weekTotal': { + 'period': 'week', + 'spent': null, + 'limit': null, + 'remaining': null, + 'usedPercent': null, + 'projectedTotal': null, + 'status': 'unknown', + 'statusExplanation': null, + }, + 'monthTotal': { + 'period': 'month', + 'spent': null, + 'limit': null, + 'remaining': null, + 'usedPercent': null, + 'projectedTotal': null, + 'status': 'unknown', + 'statusExplanation': null, + }, + }); + + // Both pools are offered on their own: a band is shared only when the user + // ticks both (`WATCH_RING_DESIGN.md`, Split band). + final catalogIds = + watchRingCatalog(withCursor).map((ring) => ring.id).toList(); + expect(catalogIds, contains(cursorOwnPoolId)); + expect(catalogIds, contains(cursorOtherPoolId)); + expect(catalogIds, isNot(contains(cursorPlanRingId))); + expect(catalogIds, isNot(contains('allowance.cursor.cursor-on-demand'))); + + final surface = orderWatchRingsForSurface( + resolveWatchRings(withCursor, const WatchRingPreferences()), + snapshot: withCursor, + ); + // Exhausted Other Models (100% used) is omitted from the face. + // Purchased on-demand is never a ring candidate. + expect(surface.map((ring) => ring.id), [ + 'allowance.cursor.cursor-plan-models', + ]); + }); + + test('a pool answers to the row the user ticked', () { + final withPair = _cursorPairAndCodexSnapshot(); + const picked = WatchRingPreferences(selectedIds: [cursorOwnPoolId]); + + // The Watchface tab ticks catalog rows, so the selection speaks their ids. + expect(watchRingSelectionIds(withPair, picked), [cursorOwnPoolId]); + expect(resolveWatchRings(withPair, picked).map((ring) => ring.id), [ + cursorOwnPoolId, + ]); + // Unticking removes what was ticked; before this the screen looked for a + // pool by an id the catalog never showed, and the ring lived on. + final left = [...watchRingSelectionIds(withPair, picked)] + ..remove(cursorOwnPoolId); + expect(left, isEmpty); + }); + + test('the band is shared only when both pools are ticked', () { + final withPair = _cursorPairAndCodexSnapshot(); + const both = WatchRingPreferences( + selectedIds: [cursorOwnPoolId, cursorOtherPoolId], + ); + + // Two rows, one band: the payload packs them into a single entry. + final packed = pairCursorPools(resolveWatchRings(withPair, both)); + expect(packed.length, 1); + expect(packed.single.$1.id, cursorOwnPoolId); + expect(packed.single.$2?.id, cursorOtherPoolId); + + // One pool alone stays an ordinary ring, undivided. + const own = WatchRingPreferences(selectedIds: [cursorOwnPoolId]); + final alone = pairCursorPools(resolveWatchRings(withPair, own)); + expect(alone.single.$2, isNull); + }); + + test('the payload preview counts a pair as one band', () { + final withPair = _cursorPairAndCodexSnapshot(); + + // Three pools, two bands, three payload entries would be wrong: the pair is + // one entry and one slot, named by the row the user checked. + expect( + watchRingPayloadSubtitle(withPair, const WatchRingPreferences()), + '2 rings · Codex · Weekly plan, Cursor plan', + ); + + // Alone, a pair reads like any single band — through its tighter half. + expect( + watchRingPayloadSubtitle( + withPair, + const WatchRingPreferences( + selectedIds: [cursorOwnPoolId, cursorOtherPoolId], + ), + ), + 'Cursor plan 53% left', + ); + }); + + test('catalog titles name the family without repeating it', () { + WatchRingMetric metric(String id, String label) => WatchRingMetric( + id: id, + label: label, + usedPercent: 10, + status: ProviderStatus.ok, + ); + + // The collision this exists for: one `Weekly plan` per provider. + expect( + metric('allowance.codex.codex-weekly', 'Weekly plan').catalogTitle, + 'Codex · Weekly plan', + ); + expect( + metric('allowance.claude.claude-seven-day', 'Weekly plan').catalogTitle, + 'Claude · Weekly plan', + ); + + // Already opens with its family. + expect( + metric( + 'allowance.cursor.cursor-plan-models', + 'Cursor Models', + ).catalogTitle, + 'Cursor Models', + ); + // A budget ring names its connection: three `Month` rows are ambiguous. + expect( + metric('budget.anthropic.platform.month', 'Month').catalogTitle, + 'Anthropic platform · Month', + ); + // Unknown connection key — keep the bare period rather than invent one. + expect(metric('budget.mock.plan.today', 'Today').catalogTitle, 'Today'); + }); + + test('excludes purchased Claude Extra usage from watch ring catalog', () { + final dash = _claudeCodexSnapshot( + fiveHourUsed: 40, + sevenDayUsed: 70, + codexUsed: 10, + ); + final claude = Map.from( + (dash.toJson()['accounts'] as List).firstWhere( + (account) => (account as Map)['provider'] == 'claude', + ) + as Map, + ); + final allowances = List.from(claude['allowances'] as List); + allowances.add({ + 'id': 'claude-extra-usage', + 'source': 'purchased', + 'label': 'Extra usage', + 'usedPercent': 84.0, + 'used': {'value': '84.90', 'unit': 'credits'}, + 'limit': {'value': '100.00', 'unit': 'credits'}, + 'remaining': {'value': '15.10', 'unit': 'credits'}, + 'unlimited': false, + 'windowMinutes': null, + 'resetsAt': null, + 'status': 'warning', + }); + claude['allowances'] = allowances; + final withExtra = DashboardSnapshot.fromJson({ + ...dash.toJson(), + 'accounts': [claude, ...(dash.toJson()['accounts'] as List).skip(1)], + }); + + final catalogIds = + watchRingCatalog(withExtra).map((ring) => ring.id).toList(); + expect(catalogIds, contains(claudePlanRingId)); + expect(catalogIds, isNot(contains('allowance.claude.claude-extra-usage'))); + }); +} + +/// A plan window plus a spend-reporting platform connection: Anthropic +/// platform reports Today (no limit yet) and Month (limit set), never Week. +DashboardSnapshot _planAndBudgetSnapshot() { + final source = DashboardSnapshot.fromJsonString( + File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), + ); + final codex = _asPlanConnection( + source.primaryAccount!.toJson() + ..['accountId'] = 'codex-local' + ..['provider'] = 'codex' + ..['allowances'] = [ + _planAllowance( + id: 'codex-weekly', + label: 'Weekly plan', + usedPercent: 55, + windowMinutes: 10080, + ), + ] + ..['buckets'] = [] + ..['modelBreakdown'] = [], + 'openai.plan', + ); + final platform = + source.primaryAccount!.toJson() + ..['accountId'] = 'anthropic-platform' + ..['provider'] = 'claude' + ..['connection'] = 'anthropic.platform' + ..['allowances'] = [] + ..['buckets'] = [] + ..['modelBreakdown'] = [] + ..['today'] = _spentBudget('today') + ..['week'] = _reportsNothingBudget('week') + ..['month'] = _spentBudget('month', usedPercent: 40); + + return DashboardSnapshot.fromJson( + source.toJson()..['accounts'] = [codex, platform], + ); +} + +/// Plan connections report no spend, so they offer no budget ring. +Map _asPlanConnection( + Map account, + String connection, +) { + return account + ..['connection'] = connection + ..['today'] = _reportsNothingBudget('today') + ..['week'] = _reportsNothingBudget('week') + ..['month'] = _reportsNothingBudget('month'); +} + +Map _reportsNothingBudget(String period) { + return { + 'period': period, + 'spent': null, + 'limit': null, + 'remaining': null, + 'usedPercent': null, + 'projectedTotal': null, + 'status': 'unknown', + }; +} + +/// Reported spend; a percentage only exists once the user set a local limit. +Map _spentBudget(String period, {double? usedPercent}) { + final limited = usedPercent != null; + return { + 'period': period, + 'spent': {'minorUnits': 4000, 'currency': 'USD'}, + 'limit': limited ? {'minorUnits': 10000, 'currency': 'USD'} : null, + 'remaining': limited ? {'minorUnits': 6000, 'currency': 'USD'} : null, + 'usedPercent': usedPercent, + 'projectedTotal': null, + 'status': 'ok', + }; } DashboardSnapshot _claudeCodexSnapshot({ @@ -279,44 +787,99 @@ DashboardSnapshot _claudeCodexSnapshot({ final source = DashboardSnapshot.fromJsonString( File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), ); - final claude = source.primaryAccount!.toJson() - ..['accountId'] = 'claude-local' - ..['provider'] = 'claude' - ..['allowances'] = [ - _planAllowance( - id: 'claude-five-hour', - label: '5-hour session', - usedPercent: fiveHourUsed, - windowMinutes: 300, - ), - _planAllowance( - id: 'claude-seven-day', - label: 'Weekly plan', - usedPercent: sevenDayUsed, - windowMinutes: 10080, - ), - ] - ..['buckets'] = [] - ..['modelBreakdown'] = []; - final codex = source.primaryAccount!.toJson() - ..['accountId'] = 'codex-local' - ..['provider'] = 'codex' - ..['allowances'] = [ - _planAllowance( - id: 'codex-weekly', - label: 'Weekly plan', - usedPercent: codexUsed, - windowMinutes: 10080, - ), - ] - ..['buckets'] = [] - ..['modelBreakdown'] = []; + final claude = _asPlanConnection( + source.primaryAccount!.toJson() + ..['accountId'] = 'claude-local' + ..['provider'] = 'claude' + ..['allowances'] = [ + _planAllowance( + id: 'claude-five-hour', + label: '5-hour session', + usedPercent: fiveHourUsed, + windowMinutes: 300, + ), + _planAllowance( + id: 'claude-seven-day', + label: 'Weekly plan', + usedPercent: sevenDayUsed, + windowMinutes: 10080, + ), + ] + ..['buckets'] = [] + ..['modelBreakdown'] = [], + 'anthropic.plan', + ); + final codex = _asPlanConnection( + source.primaryAccount!.toJson() + ..['accountId'] = 'codex-local' + ..['provider'] = 'codex' + ..['allowances'] = [ + _planAllowance( + id: 'codex-weekly', + label: 'Weekly plan', + usedPercent: codexUsed, + windowMinutes: 10080, + ), + ] + ..['buckets'] = [] + ..['modelBreakdown'] = [], + 'openai.plan', + ); return DashboardSnapshot.fromJson( source.toJson()..['accounts'] = [claude, codex], ); } +/// Cursor with both plan pools alive, beside a tighter Codex plan. +DashboardSnapshot _cursorPairAndCodexSnapshot() { + final source = DashboardSnapshot.fromJsonString( + File('../../fixtures/snapshots/dashboard_today.json').readAsStringSync(), + ); + final cursor = _asPlanConnection( + source.primaryAccount!.toJson() + ..['accountId'] = 'cursor-local' + ..['provider'] = 'cursor' + ..['allowances'] = [ + _planAllowance( + id: 'cursor-plan-models', + label: 'Cursor Models', + usedPercent: 47, + windowMinutes: 43200, + ), + _planAllowance( + id: 'cursor-plan-other', + label: 'Other Models', + usedPercent: 38, + windowMinutes: 43200, + ), + ] + ..['buckets'] = [] + ..['modelBreakdown'] = [], + 'cursor.plan', + ); + final codex = _asPlanConnection( + source.primaryAccount!.toJson() + ..['accountId'] = 'codex-local' + ..['provider'] = 'codex' + ..['allowances'] = [ + _planAllowance( + id: 'codex-weekly', + label: 'Weekly plan', + usedPercent: 92, + windowMinutes: 10080, + ), + ] + ..['buckets'] = [] + ..['modelBreakdown'] = [], + 'openai.plan', + ); + + return DashboardSnapshot.fromJson( + source.toJson()..['accounts'] = [cursor, codex], + ); +} + DashboardSnapshot _twoPlanProvidersSnapshot({ required double codexUsed, required double claudeUsed, @@ -352,18 +915,24 @@ DashboardSnapshot _twoPlanProvidersSnapshot({ codexAllowances.add(_purchasedAllowance(remaining: codexCredits)); } - final claude = source.primaryAccount!.toJson() - ..['accountId'] = 'claude-local' - ..['provider'] = 'claude' - ..['allowances'] = claudeAllowances - ..['buckets'] = [] - ..['modelBreakdown'] = []; - final codex = source.primaryAccount!.toJson() - ..['accountId'] = 'codex-local' - ..['provider'] = 'codex' - ..['allowances'] = codexAllowances - ..['buckets'] = [] - ..['modelBreakdown'] = []; + final claude = _asPlanConnection( + source.primaryAccount!.toJson() + ..['accountId'] = 'claude-local' + ..['provider'] = 'claude' + ..['allowances'] = claudeAllowances + ..['buckets'] = [] + ..['modelBreakdown'] = [], + 'anthropic.plan', + ); + final codex = _asPlanConnection( + source.primaryAccount!.toJson() + ..['accountId'] = 'codex-local' + ..['provider'] = 'codex' + ..['allowances'] = codexAllowances + ..['buckets'] = [] + ..['modelBreakdown'] = [], + 'openai.plan', + ); return DashboardSnapshot.fromJson( source.toJson()..['accounts'] = [claude, codex], diff --git a/apps/phone_flutter/test/watch_sync_service_test.dart b/apps/phone_flutter/test/watch_sync_service_test.dart index efe94bf..ba1a034 100644 --- a/apps/phone_flutter/test/watch_sync_service_test.dart +++ b/apps/phone_flutter/test/watch_sync_service_test.dart @@ -4,7 +4,6 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:ward_pulse_phone/dashboard/dashboard_models.dart'; import 'package:ward_pulse_phone/sync/watch_sync_service.dart'; -import 'package:ward_pulse_phone/settings/consumption_display_preferences.dart'; import 'package:ward_pulse_phone/settings/watch_ring_preferences.dart'; import 'package:ward_pulse_phone/sync/manual_refresh_window.dart'; @@ -21,7 +20,6 @@ void main() { final payload = WatchDashboardSummaryPayload.fromSnapshot( dashboard, - const ConsumptionDisplayPreferences(), const WatchRingPreferences(), // Match fixture: PollCadence floor already elapsed. clock: dashboard.generatedAt.add(ManualRefreshWindow.floor), @@ -30,6 +28,85 @@ void main() { expect(jsonDecode(payload.encode()), expected); }); + test('sends budget rings keyed by connection', () { + final json = + jsonDecode( + File( + '../../fixtures/snapshots/dashboard_today.json', + ).readAsStringSync(), + ) + as Map; + final account = (json['accounts'] as List).first as Map; + // Mock accounts hold no rings, so only the connection differs here. + account['provider'] = 'claude'; + account['connection'] = 'anthropic.platform'; + + final payload = + jsonDecode( + WatchDashboardSummaryPayload.fromSnapshot( + DashboardSnapshot.fromJson(json), + const WatchRingPreferences(), + ).encode(), + ) + as Map; + + final rings = payload['rings'] as List; + expect(rings.map((ring) => ring['id']), [ + 'budget.anthropic.platform.week', + 'budget.anthropic.platform.month', + 'budget.anthropic.platform.today', + ]); + // Wear prefixes a family onto a bare pool name; a budget ring arrives named, + // so Glance shows the connection (`glance-legend-budget.svg`). + expect(rings.first['label'], 'Anthropic platform · Week'); + // Money travels as structure, currency included: the face strip spells the + // symbol itself instead of assuming every connection bills in dollars. + expect(rings.first['spent'], {'minorUnits': 7130, 'currency': 'USD'}); + expect(rings.first['limit'], {'minorUnits': 25000, 'currency': 'USD'}); + }); + + test('packs a Cursor pair into one ring carrying its second pool', () { + final json = + jsonDecode( + File( + '../../fixtures/snapshots/dashboard_today.json', + ).readAsStringSync(), + ) + as Map; + final account = (json['accounts'] as List).first as Map; + account['provider'] = 'cursor'; + account['connection'] = 'cursor.plan'; + account['allowances'] = [ + _cursorPool('cursor-plan-models', 'Cursor Models', 47.0), + _cursorPool('cursor-plan-other', 'Other Models', 62.0), + ]; + final expected = jsonDecode( + File( + '../../fixtures/snapshots/watch_dashboard_summary_paired.json', + ).readAsStringSync(), + ); + + final dashboard = DashboardSnapshot.fromJson(json); + final payload = + jsonDecode( + WatchDashboardSummaryPayload.fromSnapshot( + dashboard, + const WatchRingPreferences( + selectedIds: [cursorOwnPoolId, cursorOtherPoolId], + ), + // Match fixture: PollCadence floor already elapsed. + clock: dashboard.generatedAt.add(ManualRefreshWindow.floor), + ).encode(), + ) + as Map; + + // One band, so one entry: the second pool rides inside the first + // (`WATCH_RING_DESIGN.md`, Split band). The golden is the whole payload + // because Wear parses this very file (`WatchSummaryStoreTest`) — a renamed + // key has to fail on one side or the other, not quietly on neither. + expect(payload, expected); + }); + test('marks the previous watch summary stale after a sync failure', () { final dashboard = DashboardSnapshot.fromJsonString( @@ -42,7 +119,6 @@ void main() { jsonDecode( WatchDashboardSummaryPayload.fromSnapshot( dashboard, - const ConsumptionDisplayPreferences(), const WatchRingPreferences(), ).encode(), ) @@ -58,7 +134,7 @@ void main() { ); }); - test('sends only selected allowance sources', () { + test('sends all reported allowance sources', () { final json = jsonDecode( File( @@ -99,30 +175,50 @@ void main() { jsonDecode( WatchDashboardSummaryPayload.fromSnapshot( DashboardSnapshot.fromJson(json), - const ConsumptionDisplayPreferences( - plan: false, - purchased: true, - ), const WatchRingPreferences(), ).encode(), ) as Map; - expect(payload['schemaVersion'], 7); + expect(payload['schemaVersion'], 9); expect(payload['creditsGlance'], { 'text': '12.5', 'label': 'Credits left', 'provider': 'mock', }); expect(payload['dataMode'], 'mock'); - expect((payload['allowances'] as List), hasLength(1)); - expect((payload['allowances'] as List).first['source'], 'purchased'); - expect( - (payload['allowances'] as List).first['label'], - 'Mock · Purchased credits', - ); + final allowances = payload['allowances'] as List; + expect(allowances, hasLength(2)); + expect(allowances.map((row) => row['source']), ['plan', 'purchased']); + expect(allowances.last['label'], 'Mock · Purchased credits'); }); + test( + 'marks multi-provider debug dashboards as mock dataMode when forced', + () { + final json = + jsonDecode( + File( + '../../fixtures/snapshots/dashboard_today.json', + ).readAsStringSync(), + ) + as Map; + (json['accounts'] as List).first['provider'] = 'cursor'; + + final payload = + jsonDecode( + WatchDashboardSummaryPayload.fromSnapshot( + DashboardSnapshot.fromJson(json), + const WatchRingPreferences(), + mockDataMode: true, + ).encode(), + ) + as Map; + + expect(payload['dataMode'], 'mock'); + }, + ); + test('sends unlimited purchased usage explicitly', () { final json = jsonDecode( @@ -152,7 +248,6 @@ void main() { jsonDecode( WatchDashboardSummaryPayload.fromSnapshot( DashboardSnapshot.fromJson(json), - const ConsumptionDisplayPreferences(purchased: true), const WatchRingPreferences(), ).encode(), ) @@ -170,7 +265,6 @@ void main() { jsonDecode( WatchDashboardSummaryPayload.fromSnapshot( dashboard, - const ConsumptionDisplayPreferences(), const WatchRingPreferences(), manualRefreshAnchorAt: lastSync, clock: lastSync.add(const Duration(minutes: 2)), @@ -185,3 +279,20 @@ void main() { ); }); } + +/// A plan pool as the Cursor adapter reports it: a percent and nothing to bill. +Map _cursorPool(String id, String label, double usedPercent) { + return { + 'id': id, + 'source': 'plan', + 'label': label, + 'usedPercent': usedPercent, + 'used': null, + 'limit': null, + 'remaining': null, + 'unlimited': false, + 'windowMinutes': null, + 'resetsAt': null, + 'status': 'ok', + }; +} diff --git a/apps/watchface_wff/README.md b/apps/watchface_wff/README.md index 3c3c17a..8d00640 100644 --- a/apps/watchface_wff/README.md +++ b/apps/watchface_wff/README.md @@ -3,8 +3,9 @@ Declarative Watch Face Format v2 package for a glanceable WardPulse summary. The face follows the locked concentric baseline in `docs/product/WATCH_RING_DESIGN.md`: -remaining arcs (up to four ring slots), large time hero, quiet watermark, and a sunk token -strip. Ring / token data comes from Wear OS complication providers. Missing rings stay empty +remaining arcs (up to three ring slots; melt clockwise from 12), large time hero, quiet +watermark, and sunk family strips (`%` / `% · credits` on the matching provider). Ring and +strip data come from Wear OS `RANGED_VALUE` complication providers. Missing rings stay empty instead of inventing filler. Tapping the face or a WardPulse complication opens the Wear OS app; ambient keeps muted arcs, large time, and a quieter mark (strips off). @@ -13,13 +14,17 @@ app; ambient keeps muted arcs, large time, and a quieter mark (strips off). From the repository root: ```sh +just render-watchface just validate-watchface just check-watchface just build-watchface ANDROID_SERIAL="$WEAR_SERIAL" just run-watchface ``` -`validate-watchface` uses the checksum-pinned official WFF validator. `build-watchface` +`render-watchface` writes `res/raw/watchface.xml` and the ring-type drawables in +`res/drawable-nodpi/` from `tools/render-watchface.mjs` — edit the generator, never its output; +`check-watchface` fails on drift. `validate-watchface` uses the checksum-pinned official WFF +validator. `build-watchface` produces debug APK and AAB artifacts under `apps/watchface_wff/build/outputs/`. The run command installs the APK and selects `app.wardpulse.watchface` on the target Wear device. diff --git a/apps/watchface_wff/design/README.md b/apps/watchface_wff/design/README.md index 6d14981..745da0b 100644 --- a/apps/watchface_wff/design/README.md +++ b/apps/watchface_wff/design/README.md @@ -7,5 +7,5 @@ node tools/render-watch-ring-designs.mjs ``` -Representative copies: `round-3-plan-credits`, `round-1-plan-credits`, `round-credits-only`, -`round-ambient-3`. Runtime face: `src/main/res/raw/watchface.xml` (concentric remaining arcs). +Representative copies: `round-3-plan-credits`, `round-3-plan-split`, `round-1-plan-credits`, +`round-credits-only`, `round-ambient-3`. Runtime face: `src/main/res/raw/watchface.xml` (concentric remaining arcs). diff --git a/apps/watchface_wff/design/round-1-plan-credits.svg b/apps/watchface_wff/design/round-1-plan-credits.svg index 96a150d..f2320eb 100644 --- a/apps/watchface_wff/design/round-1-plan-credits.svg +++ b/apps/watchface_wff/design/round-1-plan-credits.svg @@ -9,23 +9,27 @@ - - + 10:08 - - - 8% · 500 + + + 8% · 500 diff --git a/apps/watchface_wff/design/round-3-budget-periods.svg b/apps/watchface_wff/design/round-3-budget-periods.svg new file mode 100644 index 0000000..aa5d793 --- /dev/null +++ b/apps/watchface_wff/design/round-3-budget-periods.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + 7D7D7D7D7D7D7D7D7D7D7D7D7D7D7D7D7D7D7D7D7D7D7D7D + + + MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM + + + DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD + + 10:08 + + + $71.30/250 + + + $212.10/800 + + + $12.40/50 + diff --git a/apps/watchface_wff/design/round-3-plan-credits.svg b/apps/watchface_wff/design/round-3-plan-credits.svg index 7d0ab0c..2db814c 100644 --- a/apps/watchface_wff/design/round-3-plan-credits.svg +++ b/apps/watchface_wff/design/round-3-plan-credits.svg @@ -9,53 +9,65 @@ - - + - - + - - + 10:08 - - - 8% · 500 - - - 39% - - - 72% + + + 8% · 500 + + + 39% + + + 53% diff --git a/apps/watchface_wff/design/round-3-plan-split.svg b/apps/watchface_wff/design/round-3-plan-split.svg new file mode 100644 index 0000000..b1469df --- /dev/null +++ b/apps/watchface_wff/design/round-3-plan-split.svg @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + 10:08 + + + 8% + + + 39% + + + 53% · 62% + + diff --git a/apps/watchface_wff/design/round-3-plan-untouched.svg b/apps/watchface_wff/design/round-3-plan-untouched.svg new file mode 100644 index 0000000..630e701 --- /dev/null +++ b/apps/watchface_wff/design/round-3-plan-untouched.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + 10:08 + + + 8% + + + 39% + + + 100% · 75% + + diff --git a/apps/watchface_wff/design/round-ambient-3.svg b/apps/watchface_wff/design/round-ambient-3.svg index 4745cb6..d8a5ec6 100644 --- a/apps/watchface_wff/design/round-ambient-3.svg +++ b/apps/watchface_wff/design/round-ambient-3.svg @@ -9,23 +9,26 @@ - - + - - + - - + + + + + + + + + + + + + + 7D7D7D7D7D7D7D7D7D7D7D7D7D7D7D7D7D7D7D7D7D7D7D7D + + + MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM + + + DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD + + 10:08 + diff --git a/apps/watchface_wff/design/round-credits-only.svg b/apps/watchface_wff/design/round-credits-only.svg index 2c663b5..aa89175 100644 --- a/apps/watchface_wff/design/round-credits-only.svg +++ b/apps/watchface_wff/design/round-credits-only.svg @@ -15,13 +15,16 @@ 10:08 - - - 500 + + + 500 diff --git a/apps/watchface_wff/src/main/res/drawable-nodpi/ring1_type_day.png b/apps/watchface_wff/src/main/res/drawable-nodpi/ring1_type_day.png new file mode 100644 index 0000000..33eafd9 Binary files /dev/null and b/apps/watchface_wff/src/main/res/drawable-nodpi/ring1_type_day.png differ diff --git a/apps/watchface_wff/src/main/res/drawable-nodpi/ring1_type_month.png b/apps/watchface_wff/src/main/res/drawable-nodpi/ring1_type_month.png new file mode 100644 index 0000000..c643f46 Binary files /dev/null and b/apps/watchface_wff/src/main/res/drawable-nodpi/ring1_type_month.png differ diff --git a/apps/watchface_wff/src/main/res/drawable-nodpi/ring1_type_week.png b/apps/watchface_wff/src/main/res/drawable-nodpi/ring1_type_week.png new file mode 100644 index 0000000..1646a47 Binary files /dev/null and b/apps/watchface_wff/src/main/res/drawable-nodpi/ring1_type_week.png differ diff --git a/apps/watchface_wff/src/main/res/drawable-nodpi/ring2_type_day.png b/apps/watchface_wff/src/main/res/drawable-nodpi/ring2_type_day.png new file mode 100644 index 0000000..8413dc2 Binary files /dev/null and b/apps/watchface_wff/src/main/res/drawable-nodpi/ring2_type_day.png differ diff --git a/apps/watchface_wff/src/main/res/drawable-nodpi/ring2_type_month.png b/apps/watchface_wff/src/main/res/drawable-nodpi/ring2_type_month.png new file mode 100644 index 0000000..7c2def9 Binary files /dev/null and b/apps/watchface_wff/src/main/res/drawable-nodpi/ring2_type_month.png differ diff --git a/apps/watchface_wff/src/main/res/drawable-nodpi/ring2_type_week.png b/apps/watchface_wff/src/main/res/drawable-nodpi/ring2_type_week.png new file mode 100644 index 0000000..6eb735f Binary files /dev/null and b/apps/watchface_wff/src/main/res/drawable-nodpi/ring2_type_week.png differ diff --git a/apps/watchface_wff/src/main/res/drawable-nodpi/ring3_type_day.png b/apps/watchface_wff/src/main/res/drawable-nodpi/ring3_type_day.png new file mode 100644 index 0000000..582f469 Binary files /dev/null and b/apps/watchface_wff/src/main/res/drawable-nodpi/ring3_type_day.png differ diff --git a/apps/watchface_wff/src/main/res/drawable-nodpi/ring3_type_month.png b/apps/watchface_wff/src/main/res/drawable-nodpi/ring3_type_month.png new file mode 100644 index 0000000..9779f05 Binary files /dev/null and b/apps/watchface_wff/src/main/res/drawable-nodpi/ring3_type_month.png differ diff --git a/apps/watchface_wff/src/main/res/drawable-nodpi/ring3_type_week.png b/apps/watchface_wff/src/main/res/drawable-nodpi/ring3_type_week.png new file mode 100644 index 0000000..75b5145 Binary files /dev/null and b/apps/watchface_wff/src/main/res/drawable-nodpi/ring3_type_week.png differ diff --git a/apps/watchface_wff/src/main/res/raw/watchface.xml b/apps/watchface_wff/src/main/res/raw/watchface.xml index abdd4e9..f9ca17a 100644 --- a/apps/watchface_wff/src/main/res/raw/watchface.xml +++ b/apps/watchface_wff/src/main/res/raw/watchface.xml @@ -4,11 +4,18 @@ (docs/product/WATCH_RING_DESIGN.md, baseline 2026-07-25). Visual target: apps/wear_android/design/round-*-plan*.svg - — radial lift background, ~14px visual stroke (WeightedStroke thickness - compensated), equal-width sunk strips (rx=6) stacked for a clear 3-up aperture. + — radial lift background, 40-unit ring stroke (WFF paints about half of it), equal-width + sunk strips (88, rx=6) sized for "100% · 10.0M". Strip slots are RANGED_VALUE so accents + use the same ColorRamp as arcs ([COMPLICATION.RANGED_VALUE_COLORS]). + + Ring type: the budget period from COMPLICATION.TITLE (D / 7D / M) repeated around the + band and punched out in the background colour at low alpha, so the arc underneath keeps + the family and the type stays a shade in the band. BoundingArc clips ring-slot content — strips use BoundingBox slots after clock. - Center strip TEXT = full label (100% · 500); lower strips TEXT = digits, Template adds %%. + Strip TEXT = full label (`46%` or `100% · 500` on the owning family). + Remaining melt: Transform startAngle (1 - value/max)*359.9, endAngle fixed 359.9 + so usage opens clockwise from 12 and remaining ends at 12. --> @@ -27,6 +34,7 @@ + @@ -58,37 +66,159 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + + = [COMPLICATION.RANGED_VALUE_MAX]]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + = [COMPLICATION.RANGED_VALUE_MAX]]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -114,7 +244,7 @@ centerY="225" width="356" height="356" - thickness="28" + thickness="40" startAngle="0" endAngle="359.9" isRoundEdge="TRUE" /> @@ -126,37 +256,159 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + + = [COMPLICATION.RANGED_VALUE_MAX]]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + = [COMPLICATION.RANGED_VALUE_MAX]]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -180,9 +432,9 @@ @@ -194,37 +446,159 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + + = [COMPLICATION.RANGED_VALUE_MAX]]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + = [COMPLICATION.RANGED_VALUE_MAX]]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -237,61 +611,208 @@ width="450" height="450" slotId="106" - displayName="@string/ring4_complication" + displayName="@string/outer_half_complication" supportedTypes="RANGED_VALUE EMPTY" isCustomizable="FALSE"> - - 0]]> - + + + - - + + + + + + + + + + = [COMPLICATION.RANGED_VALUE_MAX]]]> + + + + + + + + + + + + + + + + + + + + + + + + - + - - + + + = [COMPLICATION.RANGED_VALUE_MAX]]]> + + + + + + + + + + + + + + + + + + + + + + + + + - - + endAngle="359.9"> + + + + = [COMPLICATION.RANGED_VALUE_MAX]]]> + + + + + + + + + + + + + + + + + + + + @@ -302,7 +823,7 @@ - + + - + - + - - - + + + - - + + - - - + + + + - + - + @@ -389,36 +923,45 @@ - - - + + + - - + + - - - + + + + - - + - -