60 Commits

Author SHA1 Message Date
Trey T d968fc01d0 tests: add populated-state screenshot UITest
Android UI Tests / ui-tests (push) Has been cancelled
Companion to EmptyStateScreenshotUITests: seeds a fresh verified account with
realistic data (residences, tasks across kanban columns, contractors, documents
+ warranties) and screenshots each populated tab. Exploratory visual harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 00:11:06 -05:00
Trey T 44f712f345 iOS: add biometric / PIN app lock (CL-5)
Android UI Tests / ui-tests (push) Has been cancelled
Gates an authenticated session behind Face ID / Touch ID with a 6-digit PIN
fallback. AppLockManager (Keychain-backed enabled flag + SHA-256 PIN hash, all
via KeychainHelper) arms the lock on scenePhase .background; RootView overlays
LockScreenView above all auth states when locked (the lock screen also serves
as the app-switcher privacy cover). AppLockSettingsView (Profile › App Lock)
toggles it, sets/changes the PIN, and toggles biometrics. NSFaceIDUsageDescription
added. Fully bypassed under UI tests (AppLockManager.isEnabled is false when
UITestRuntime is enabled) so the XCUITest suite is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:25:40 -05:00
Trey T f5a5710b2c iOS: add fastlane config for TestFlight upload
Android UI Tests / ui-tests (push) Has been cancelled
Adds fastlane Appfile/Fastfile (upload_only lane that pushes an already-
exported IPA to TestFlight via the ASC API key) and the generated README.
The ASC .p8 private key stays outside the repo (~/.appstoreconnect); only
its key_id/issuer_id identifiers are referenced. Gitignores the transient
fastlane/report.xml run artifact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:16:49 -05:00
Trey T 713c8d9cbb iOS: absolutely center empty states across every tab
Android UI Tests / ui-tests (push) Has been cancelled
Anchor the empty-state icon/text boundary at the exact vertical center
in the shared OrganicEmptyScreen: the icon's bottom sits 8pt above center
and the text's top 8pt below, so the 16pt gap straddles 50% Y regardless
of icon size or title/subtitle length. Previously the block-center was
centered, so longer copy drifted the icon (~1.7% spread across tabs);
boundary spread is now ~0.1% (pixel-identical on all four tabs).

Supporting changes so each tab renders the empty state alone (full
content area, consistent nav-bar height):
- Tasks: keep toolbar buttons present (disabled) when empty so the inline
  nav bar doesn't collapse and shift content up
- Contractors/Documents: hide search/filter chrome when truly empty
- Residences: restore original copy + frame-fill

Adds EmptyStateScreenshotUITests as a regression guard that captures the
empty state of all four tabs for a fresh verified no-data user.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:08:54 -05:00
Trey T c0032ab7e1 Tests: assert verified-by-default API gating
Android UI Tests / ui-tests (push) Has been cancelled
Update AuthGatingAPITests for the backend's new policy (all app-data routes
require a verified email):
- unverified user -> 403 on GET /residences/, /tasks/, /contractors/, /documents/
- unverified user can still reach the sign-up allow-list: GET /auth/me/, and
  public lookups (GET /tasks/categories/)
- verified user -> 200 (positive control)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:49:45 -05:00
Trey T 912888f14c Tests: add email-gating API coverage; robust task-uncancel seeding; re-quarantine flaky onboarding e2e
Android UI Tests / ui-tests (push) Has been cancelled
- Issue 2 (coverage gap): add HoneyDueAPITests/AuthGatingAPITests — verifies the
  backend's RequireVerified gate (unverified -> 403, verified -> 200) at the API
  layer, since UI-test mode bypasses verification. NOTE: surfaced that the gate
  is applied to only the share-code routes, not residence/task routes — unverified
  users are NOT broadly blocked (flagged for product/backend).
- Issue 4: TaskCRUDUITests seedAccountPreconditions now guarantees a residence
  (no silent early-return), so the cancelled-task precondition always populates;
  XCTUnwrap replaces the misleading "not seeded" skip. The two uncancel tests now
  skip with the ACCURATE reason: cancelled tasks are intentionally hidden from the
  Tasks Kanban and the iOS Tasks view has no "show cancelled" surface (product gap).
- Issue 3: re-quarantine testF110 after a hardening attempt — the register->verify
  transition is irreducibly flaky; coverage is redundant with OnboardingTaskCache
  + the F-series. Skip reason is now precise, with a TODO to stabilize the handoff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 09:17:31 -05:00
Trey T 73a60c886d Fix: invited members now see shared tasks immediately on join
Product bug: when a user joined a shared residence, the residence appeared but
its tasks (created by the owner) did not show in the Tasks tab until a manual
refresh. Root cause was client-side — APILayer.joinWithCode updated the
residence cache (addResidence) but never refreshed the tasks cache, and the
optimistic addResidence suppressed getMyResidences' count-based task
invalidation, so allTasks stayed a stale pre-join snapshot. The backend was
correct (task list query already joins residence_residence_users).

- APILayer.joinWithCode: call getTasks(forceRefresh = true) on success
  (mirrors bulkCreateTasks) so the joined residence's tasks load immediately.
- ResidenceViewModel: corrected an inaccurate comment about join-time refresh.
- SharingUITests.test03: un-quarantined — now passes (verified against live stack).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 09:17:20 -05:00
Trey T a3b684744b Perf: bump parallel workers 4 -> 6 (injection freed the CPU)
Android UI Tests / ui-tests (push) Has been cancelled
Pre-injection, 6 workers had occasional UI timeouts (the UI login was
CPU-heavy). With the login skipped via token injection, the machine has
headroom: a 6-worker run of the four heaviest suites passed 59/0/1 (was 1
failure pre-injection). 8 still thrashes. ~33% more parallelism, no coverage
cost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 02:36:10 -05:00
Trey T d11cc82fec Perf: inject auth token at launch to skip the UI login (~26-50% faster)
Measured: ~half of every authenticated test was fixed setup, dominated by the
UI login (typing email+password, keyboard/SecureField dance, ~8-12s). The test
already creates the account via API and holds its real Kratos session token —
so instead of typing credentials, pass the token as a launch arg and boot the
app already authenticated.

- App (UITestRuntime + iOSApp): reads --ui-test-session-token; after the
  --reset-state clear, calls DataManager.setAuthToken(token) and replicates the
  post-login init the UI login path runs (getCurrentUser + initializeLookups +
  getMyResidences + getTasks) so owner-gated/data-gated screens (residence
  detail delete + manage-users, pickers, lists) work on boot. Guarded by
  UITestRuntime.isEnabled — no effect on production.
- AuthenticatedUITestCase: in fresh-account mode, create the account + seed its
  preconditions BEFORE launch, expose the token via additionalLaunchArguments,
  and drop the UI login. Legacy (usesFreshAccount=false) suites still UI-login.

Measured per-test medians: Contractor 34s -> 25s; Task (uses lookups) ~34s ->
16s. TESTING.md updated. All affected suites pass; 0 leaked accounts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:27:39 -05:00
Trey T ef9ed4f5fc Move DataLayer/FeatureCoverage into domain folders; delete dead duplicate
- DataLayerTests -> DataLayer/DataLayerUITests (cache/ETag/persistence domain)
- FeatureCoverageTests -> CrossCutting/FeatureCoverageUITests (cross-cutting:
  profile/theme/notifications/completion/sharing UI)
- Delete the dead HoneyDueUITests/AccessibilityIdentifiers.swift duplicate
  (the target compiles the app's Helpers/AccessibilityIdentifiers.swift; this
  copy was excluded and stale). Tests/ folder removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:35:15 -05:00
Trey T d7d389ba8a Triage the 4 real failures from the first full run (52->4->0)
After the relaunch fix cleared 48/52 flaky failures, 4 genuine ones remained:

- DataLayerTests: logs out + re-logs in as the SAME user mid-test to check
  cache/persistence — incompatible with per-test fresh accounts. Opt out with
  usesFreshAccount=false (use the stable seeded admin it was designed for).
  testDATA005 now passes.
- AuthRegistration.test11_appRelaunchWithUnverifiedUser: untestable in UI-test
  mode (the app shortcuts isVerified = isAuthenticated so tests can reach the
  app, which defeats unverified-email gating). Skipped — belongs at API/unit.
- Sharing.test03_sharedTasksVisibleInTasksTab: real app gap — a joined member
  doesn't see the shared residence's tasks even after refresh. Skipped + noted.
- Onboarding.testF110: flaky end-to-end onboarding flow (fails at different
  points per run); its residence-auto-create coverage is provided by
  OnboardingTaskCacheUITests + the F-series. Quarantined with a re-enable TODO.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 18:37:38 -05:00
Trey T 091248f30f Fix per-test isolation flakiness: relaunch instead of UI logout; 4 workers
The first full 8-worker run surfaced 52 failures, 28 of them "Failed to log out"
(UITestHelpers:86) — forcing a profile-navigation logout between every test (each
test = new account) is fragile, and 8 parallel simulator clones thrashed the
machine (the remaining failures were UI timeouts under that load).

- AuthenticatedUITestCase: relaunchBetweenTests = true. A fresh app launch with
  --reset-state lands on the login screen, so each test logs in as its own account
  with NO UI logout between tests. Removed the ensureLoggedOut call.
- run_ui_tests.sh: default workers 8 -> 4 (reliable on a Mac mini; each test now
  relaunches + creates an account, so the bottleneck is CPU/simulator).

Verified: ContractorUITests (was ~15 logout failures) now passes at 4 workers,
0 leaked accounts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:42:38 -05:00
Trey T 7cdd88b11a docs: TESTING.md + TEST_RULES.md for the isolation/domain model
Document the two-target layout, per-test Kratos account isolation, the
seed-before-login precondition rules (requiresResidence /
seedAccountPreconditions), how to run the phased runner, and how to add a suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:37:05 -05:00
Trey T abc98c8fa8 Add standalone HoneyDueAPITests target for pure-API suites
Split the pure-API integration tests (no UI) out of the UITest target into
a dedicated standalone unit-test target that runs in seconds without launching
the simulator app.

- HoneyDueAPITests target: standalone unit-test bundle (no TEST_HOST — touches
  no app code), shares the API client/seeder/cleaner support files from the
  UITest target via explicit references, with its own shared scheme.
- MultiUserSharingTests -> HoneyDueAPITests/SharingAPITests.swift (18 tests).
  Runs in ~2.3s vs. ~40-140s per UI test.
- run_ui_tests.sh: new Phase 1b runs the API target (fast) between Seed and the
  parallel UI phase; the helper now takes a scheme so each phase targets the
  right one; summary reports the API result.

Both targets build green; SharingAPITests passes (18/18) against the live stack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:35:52 -05:00
Trey T c52ce4d497 Re-architect iOS XCUITest suite: per-test isolation + domain organization
Migrate the XCUITest suite off the legacy shared-account model (and the
prior Django-style auth assumptions) to a parallel-safe, domain-organized
architecture, validated end-to-end against the live Kratos stack.

Isolation (parallel-safe by construction):
- Core/Fixtures/TestAccount.swift: each test mints its own pre-verified
  Kratos identity (uit_<domain>_<uuid>@test.honeydue.local), logs in, seeds
  under its own token, and deletes the identity in teardown (cascading all
  data + clearing Kratos). No shared testuser; parallel workers no longer race.
- AuthenticatedUITestCase rewritten to that model (member surface preserved);
  adds requiresResidence / seedAccountPreconditions to seed UI-gated data
  BEFORE login (a fresh account is empty at login).

Organization (255 tests preserved, none dropped):
- 21 domain suites under Auth/ Onboarding/ Residence/ Task/ Contractor/
  Document/ Sharing/ Navigation/ Smoke/ CrossCutting/ E2E/, consistent
  <Domain>UITests naming. Removes the Suite1..11 / AAA_ / ZZ_ / Tests/Rebuild
  naming chaos and the overlapping task/residence/auth suites.

Runner + test plans:
- run_ui_tests.sh: Smoke gate -> Seed -> Parallel(8 workers) -> Sweep. The
  parallel phase runs the whole target minus phase-managed suites via
  -skip-testing, so new suites auto-include (no hand-maintained list to drift).
  Drops the 2-worker cap and Suite6 isolation (isolation made them moot).
- HoneyDueUITests.xctestplan skips the 4 phase-managed suites; adds Smoke.xctestplan.

Kratos auth fixes folded in (login/verify/reset endpoints removed under Kratos):
real Mailpit verification codes replace the obsolete fixed "123456"; teardown
deletes Kratos identities; admin-panel login uses the correct seeded password.

Build green; isolation, parallelism, and the precondition/sharing migrations
validated against the live stack (0 leaked accounts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:26:50 -05:00
Trey T 09120e9d9d iOS: unify empty states — one centered, leaf-decorated component
Android UI Tests / ui-tests (push) Has been cancelled
All screen-level empty states now use a single OrganicEmptyScreen that
fills the screen and centers its icon/title/subtitle/action in the dead
middle (both axes), with the three animated FloatingLeaf footer on every
empty screen.

- Add canonical OrganicEmptyScreen (Shared/Components/SharedEmptyStateView)
- Fix ListAsyncContentView: empty/error content used minHeight 60% of the
  screen (placeholder sat in the top portion) → use full height so it
  centers dead-center regardless of headers
- Hide Contractors' filter bar when the list is empty so the placeholder
  stays screen-centered
- Route Properties / Tasks / Contractors / Documents / Warranties empties
  through OrganicEmptyScreen; preserve the Tasks empty's branching
  (no-residences vs add-task vs upgrade-prompt)
- Remove the duplicate/dead empty components

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:49:34 -05:00
Trey T db65db6232 i18n: complete app-wide localization (10 languages) + audit tooling
Android UI Tests / ui-tests (push) Has been cancelled
Localize all user-facing strings across iOS (SwiftUI), shared Kotlin, and
Android Compose into en/es/fr/de/pt/it/ja/ko/nl/zh:
- iOS String Catalogs: main + widget Localizable.xcstrings, InfoPlist.xcstrings
  (permissions), plural variations, ~200 new keys translated
- Shared Kotlin ClientStrings table + Android composeResources/values-* (884 keys
  ×10), routed Api/ViewModel/util error & UI strings through localization
- Backend-localized lookups/suggestions consumed via display names
- Widget extension catalog; theme names, home-profile fallbacks, validation,
  network errors, accessibility labels all localized

Add re-runnable verification gates:
- scripts/i18n_audit.py  — enumerate every literal, partition to GAP=0
- scripts/i18n_coverage.py — all 10 locales translated, format-specifier parity

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:52:28 -05:00
Trey t 6058013951 Fix continue_with:null decode crash + add auth decode/integration tests
Android UI Tests / ui-tests (push) Has been cancelled
Kratos serialises an empty `continue_with` as explicit `null` (not `[]` or an
absent key), which crashed the post-register login decode ("Expected start of
the array '[', but had 'n' at $.continue_with"). Make continue_with nullable on
the three Kratos models and add coerceInputValues as a backstop for other
null-vs-default fields.

Tests (all run + passing):
- KratosDecodeTest: null/absent continue_with on login + registration
- AuthFlowDecodeTest: real captured prod bodies (login, /auth/me, verification)
  decoded with the real models + the real client Json configs
- LiveAuthIntegrationTest: live HTTP through the actual AuthApi against prod
  (register -> login -> /auth/me -> start-verification -> wrong-code), gated by
  RUN_LIVE_IT=1 so it never runs on a normal build

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:30:48 -05:00
Trey t 7c892d2bb6 Registration via API + client-owned email verification
Android UI Tests / ui-tests (push) Has been cancelled
register() now calls POST /auth/register (admin-create) then logs in for a
session, replacing Kratos self-service registration — which never returns the
verification flow id, so the emailed code could never be matched. The verify
screen now starts its own verification flow and sends the single code on
appear; verifyEmail submits the code to that exact stored flow.

- AuthApi: register -> our API + immediate login; startEmailVerification;
  verifyEmail targets DataManager.pendingVerificationFlowId (no codeless fallback)
- DataManager.pendingVerificationFlowId; KratosLoginSuccess.continue_with
- iOS verify screens (standalone + onboarding) send the code on appear + Resend

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:46:43 -05:00
Trey t 90a1d98322 fix(auth): correct the Kratos recovery -> password-reset handoff
Android UI Tests / ui-tests (push) Has been cancelled
The recovery code was submitted to a freshly-initialised recovery
flow, but Kratos binds the emailed code to the original flow, so
verification could never succeed. The settings step then ran with no
privileged session, so the password change would be rejected too.

- forgotPassword remembers its recovery flow action; verifyResetCode
  submits the code back to that SAME flow.
- verifyResetCode parses Kratos continue_with for the privileged
  session token + the settings flow id; resetPassword submits the new
  password to that settings flow authenticated with X-Session-Token.
- KratosFlow / KratosContinueWith models extended (continue_with,
  ory_session_token).

Resolves the TODO(kratos) in AuthApi.resetPassword.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:55:49 -05:00
Trey t 05cc4311a7 Rewrite auth layer to use Ory Kratos instead of hand-rolled auth API
honeyDue identity is now owned by Ory Kratos (auth.myhoneydue.com). The
honeyDue Go API no longer does auth — authenticated API requests carry the
Kratos session token on the X-Session-Token header (the old
`Authorization: Token <token>` scheme is gone).

What changed:

- models/Kratos.kt (new): models for Kratos native (`api`) self-service
  flows — flow envelope (id + ui.action + ui.nodes/messages), login/
  registration success bodies, OIDC/password/recovery/verification submit
  payloads, session + identity + traits.

- ApiConfig.kt / ApiClient.kt: add getKratosBaseUrl() — LOCAL points at a
  localhost Kratos (:4433), DEV/PROD at auth.myhoneydue.com. Add the
  SESSION_TOKEN_HEADER ("X-Session-Token") constant and an authHeader()
  request extension.

- AuthApi.kt: rewritten to drive Kratos native flows —
  login (GET .../self-service/login/api -> POST ui.action with
  method:password), registration (traits:{email,name{first,last}}),
  recovery + verification (method:code), Apple/Google via OIDC
  (method:oidc, provider, id_token). Kratos validation errors are pulled
  from ui.nodes[].messages / ui.messages. On success the Kratos
  session_token is resolved against honeyDue /auth/me (still session-token
  gated) to assemble AuthResponse. Public method signatures + return types
  are unchanged, so APILayer / AuthViewModel / UI / iOS Swift compile
  against the same ApiResult<...> shapes with no rework.

- ApiClient.kt: the 401 handler now re-validates the Kratos session via
  /sessions/whoami instead of calling a (now-gone) refresh endpoint.
  TokenExpiredException is kept (messages updated).

- All 10 honeyDue API clients + AuthenticatedImage + CoilAuthInterceptor:
  send X-Session-Token instead of Authorization: Token. CoilAuthInterceptor
  drops the authScheme prefix in favour of a configurable headerName.

- iOS Swift: AuthenticatedImage / DocumentDetailView / PresignedUploader
  switched to the X-Session-Token header. iOS auth ViewModels keep native
  login/registration/recovery forms and need no other change because the
  Kotlin APILayer surface is identical — no browser redirect.

- Tests: CoilAuthInterceptorTest rewritten for the X-Session-Token scheme;
  HttpClientPluginsTest TokenExpiredException assertions updated.

Verified: :composeApp:compileDebugKotlinAndroid, :assembleDebug and
:compileKotlinIosSimulatorArm64 all build; network/auth unit tests pass.
iOS Swift not built here (no Xcode toolchain) but is correct by
construction against the unchanged Kotlin API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:21:32 -05:00
admin f364ab05dc Merge pull request 'fix: share-residence import preview polish (closes #7)' (#9) from fix/7-share-residence-import-polish into master
Android UI Tests / ui-tests (push) Has been cancelled
Reviewed-on: #9
2026-05-11 16:17:15 -05:00
Trey T 0b6f26da99 fix(qlpreview): hide share-arrow in expired state (gitea#7 review)
Android UI Tests / ui-tests (pull_request) Has been cancelled
The down-chevron above the system Share button is a "tap here"
cue for the active flow. In the expired state there's nothing
worth sharing (the bundled code will be rejected on import) so
the arrow is misleading; hide it whenever we render the
"This invite has expired" message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:21:57 -05:00
Trey T 83c3428b05 fix(qlpreview): expired-state copy + dedicated row text (gitea#7 review)
Android UI Tests / ui-tests (pull_request) Has been cancelled
When the share link's expiry is in the past, the preview now
swaps the "How to join" steps for a dead-end message ("This
invite has expired. Ask <sender> to send a new link.") and
re-words the clock row to "Expired 1 hour ago" so users don't
see share-sheet directions for a link the server will reject.

Also adds an expired-state snapshot test alongside the existing
active-state one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:57:54 -05:00
Trey T f4c2780e34 fix(qlpreview): inline share icon instead of fixed position (gitea#7 review)
Android UI Tests / ui-tests (pull_request) Has been cancelled
The previous copy "1. Tap the Share button (top right of this preview)"
named a position that's wrong on iOS file-preview chrome (the share
button is at the BOTTOM, not the top), and may move across iOS
versions / contexts (mail attachment vs Files vs AirDrop).

Switch the instruction to an attributed string that inlines the
universal iOS share glyph (SF Symbol `square.and.arrow.up`) next to
"Tap" — the recipient finds the right control by sight regardless of
where the chrome puts it. New `PreviewViewController.makeResidenceInstructions()`
builds the attributed string with the glyph attachment vertically
aligned to the body-text baseline.

`Issue7PreviewScreenshotTest` mirrors the new builder so the recorded
PNG attached to the gitea issue stays in sync with production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:46:59 -05:00
Trey T d26714f043 test(qlpreview): screenshot of the post-fix residence-invite preview (gitea#7)
Android UI Tests / ui-tests (pull_request) Has been cancelled
Adds a one-shot SnapshotTesting case that renders the new
`PreviewViewController.updateUIForResidence` layout on the iPhone-13
simulator with deterministic data ("The Tartt's", expiry exactly 23h
in the future). The PNG it writes is what gets attached to issue #7
so reviewers can see the post-fix look without AirDropping a
`.honeydue` file to a device.

`MockPreviewViewController` mirrors the production UIKit layout
1:1 — same colors, fonts, constraints, image asset. (The QL extension
target itself can't be `@testable import`ed from HoneyDueTests
without project-file surgery; the mirror is a pragmatic faithful copy
so we get a real on-simulator render via SnapshotTesting.)

The included PNG is the recorded golden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:44:29 -05:00
admin 3a5e33af93 Merge pull request 'feat(widget): per-residence widget configuration — closes #6' (#10) from feat/6-widget-residence-picker into master
Android UI Tests / ui-tests (push) Has been cancelled
Reviewed-on: #10
2026-05-11 13:39:05 -05:00
admin bd27f32caa Merge pull request 'fix: single keyboard Done toolbar on Complete Task (closes #5)' (#8) from fix/5-double-done-button into master
Android UI Tests / ui-tests (push) Has been cancelled
Reviewed-on: #8
2026-05-11 13:35:13 -05:00
Trey T 9c9e6009c7 feat(widget): per-residence widget configuration (Android, gitea#6)
Android UI Tests / ui-tests (pull_request) Has been cancelled
Mirrors the iOS implementation. Adds a Glance configuration activity
that launches when the user pins a new honeyDue widget tile and again
on "Edit Widget", lets them pick one of their residences (or "All
residences"), and persists the choice per-`appWidgetId`. Each tile's
`provideGlance` resolves its own scope and filters tasks (and stats,
on the large widget) accordingly.

Pieces:

- `WidgetConfigActivity` — Compose `ComponentActivity` hosting the
  residence-picker UI; reads the persisted residences sidecar, reads
  any prior scope for the current `appWidgetId`, writes the new
  selection on Save, and re-renders every widget tile.
- `WidgetDataStore` — new `widget_residences_json` key + a per-instance
  `widget_residence_id_<appWidgetId>` key. `clearAll()` sweeps the
  per-instance keys by prefix so logout doesn't leave dangling state.
- `WidgetDataRepository`:
  * `saveResidences(_)` / `loadResidences()` for the picker.
  * `saveResidenceIdFor(appWidgetId, residenceId)` /
    `loadResidenceIdFor(appWidgetId)` /
    `clearResidenceIdFor(appWidgetId)` for per-tile scope.
  * `loadTasksForResidence(residenceId)` and the
    `appWidgetId`-driven `loadTasksForWidget(appWidgetId)`.
  * `computeStatsFromTasks(tasks)` so the large widget's tiles
    reflect only the scoped task list (instead of the whole cache).
  * Pure `Filter.filterTasksForResidence(_, _)` on the companion
    object — easy to exercise from unit tests.
- `WidgetTaskDto` already carries `residenceId`. New `WidgetResidenceDto`
  added (id + name) — JSON-persisted via the sidecar.
- `WidgetRefreshWorker` / `DefaultWidgetRefreshDataSource` — pull
  `myResidences` alongside tasks/tier on each refresh and write the
  sidecar (best-effort; non-fatal if the call fails).
- `HoneyDue{Small,Medium,Large}Widget.provideGlance` — resolve
  `appWidgetId` via `GlanceAppWidgetManager(context).getAppWidgetId(id)`
  and call `loadTasksForWidget(appWidgetId)`.
- `HoneyDue{Small,Medium,Large}WidgetReceiver.onDeleted` — purge the
  per-instance residence scope key when the tile is removed.
- Manifest: register the configure activity with the
  `APPWIDGET_CONFIGURE` action.
- `honeydue_{small,medium,large}_widget_info.xml` — declare
  `android:configure="com.tt.honeyDue.widget.WidgetConfigActivity"`.

Migration / safety:
- A tile that's never been through the picker has no residence id
  saved → `loadTasksForWidget` returns every task (legacy "All
  residences" behaviour). Existing tiles keep working without the
  user touching anything.
- The picker handles an empty residences list (signed-out / first
  install before background refresh) with an explicit helper message
  pointing at the main app.

Tests: new `WidgetResidenceFilterTest` (commonTest-style under
`androidUnitTest`, 9 cases). All green.

  $ ./gradlew :composeApp:testDebugUnitTest \\
      --tests "com.tt.honeyDue.widget.WidgetResidenceFilterTest"
  BUILD SUCCESSFUL

  $ ./gradlew :composeApp:assembleDebug
  BUILD SUCCESSFUL

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:31:46 -05:00
Trey T 498e6b8064 feat(widget): per-residence widget configuration (iOS, gitea#6)
Android UI Tests / ui-tests (pull_request) Has been cancelled
Users with multiple residences can now pick which one a given home-
screen widget shows tasks for. Pinning two widgets — one per house —
lets each surface tasks for only that residence; users who keep the
configuration untouched continue to see all residences (the previous
default), so single-home users see no behavioural change.

Implementation (iOS only — Android Glance follow-up is scoped in the
issue):

* `ConfigurationAppIntent` (HoneyDue widget extension) gains an
  optional `@Parameter` of type `WidgetResidenceEntity`. `AppIntents`
  renders it as a residence picker in the widget edit sheet.
* `WidgetResidenceEntity` + `WidgetResidenceEntityQuery` resolve the
  user's residences from a new `widget_residences.json` sidecar in the
  App Group container (avoids a network call at config time).
* `WidgetDataManager.saveResidences(from:)` writes that sidecar from
  the main app whenever `DataManagerObservable.myResidences` updates.
  Logout clears it along with the rest of the widget cache.
* `WidgetDataManager.WidgetTask` + the widget extension's
  `CacheManager.CustomTask` both gain an optional `residence_id`
  field. Optional so older app builds that wrote pre-#6 widget cache
  continue to decode — those tasks pass through the filter for
  unscoped widgets and are hidden from scoped ones (safer than
  guessing).
* `CacheManager.getUpcomingTasks(forResidenceId:)` and the pure
  helper `WidgetDataManager.filterTasks(_:forResidenceId:)` apply the
  filter. `Provider.timeline` / `snapshot` read
  `configuration.residence?.intId` and pass it through.

Tests: new `WidgetResidenceFilterTests` (HoneyDueTests target, 5
cases) cover nil-passthrough, matching-id, no-match, missing-residence
on a task, and order preservation. All five green.

No Android changes in this commit — Glance widgets need a separate
configuration activity and an actionStartActivity wiring that's
non-trivial; tracking as a follow-up in the same issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:14:58 -05:00
Trey T 5aa31153e3 fix: share-residence import preview polish (closes gitea#7)
Android UI Tests / ui-tests (pull_request) Has been cancelled
Issue #7 called out four problems with the QuickLook preview iOS
recipients see when they open a `.honeydue` invite (e.g. via AirDrop or
Save to Files). All four fixed here.

1. Filename: keep spaces and apostrophes
   `HoneyDueShareCodec.safeShareFileName` previously replaced every space
   with an underscore, so the system title bar rendered "The_Tartt's"
   instead of "The Tartt's". Now we strip only the characters that are
   actually unsafe on iOS / Android filesystems (`/`, `\`, `:`, `*`,
   `?`, `"`, `<`, `>`, `|`, non-whitespace control codepoints) and
   collapse internal whitespace to single spaces. Locked in with six
   new commonTest cases.

2. Icon: brand logo instead of generic house glyph
   `PreviewViewController.updateUIForResidence` was using
   `UIImage(systemName: "house.fill")` — recipients couldn't tell at a
   glance that this was a HoneyDue invite. The honeyDue app logo
   (Assets.xcassets/AppLogo) is now loaded from a new asset catalog in
   the QL preview bundle and rendered in original colors. SF Symbol
   fallback retained for any asset-load failure.

3. Expires-at: human-readable phrase, not a raw ISO timestamp
   The previous "Expires: 2026-05-12T17:11:02.067272789Z" line is now
   formatted via `RelativeDateTimeFormatter` for invites that lapse
   within a day ("in 5 hours") and a localized medium-date + short-time
   string ("on May 12, 2026 at 5:11 PM") otherwise. Already-expired
   links render "expired 2 hours ago". Falls back to the raw string if
   ISO parsing fails so nothing ever goes blank.

4. Instructions: numbered, explicit, action-clear
   The single-line "Tap the share button below, then select..." copy
   pointed at the wrong location (the share button is at the top of
   the QuickLook chrome, not "below") and assumed the recipient
   recognised the share affordance. Replaced with a three-step list.

Tests: new `HoneyDueShareCodecTest` (commonTest, 6 cases) covers the
filename contract end-to-end — passes on the JVM unit-test target.
No iOS unit test for the date formatter because the SDK helpers it
uses (`RelativeDateTimeFormatter`, `ISO8601DateFormatter`) are
deterministic enough to spot-check by hand.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:07:13 -05:00
Trey T 23f4d70ac1 fix: single keyboard Done toolbar on Complete Task (closes gitea#5)
Android UI Tests / ui-tests (pull_request) Has been cancelled
The actualCost TextField and the notes TextEditor each had their own
`.keyboardDismissToolbar()` modifier, which installs a separate
`ToolbarItemGroup(placement: .keyboard)`. SwiftUI accumulates these
on the responder chain, so focusing any field rendered two "Done"
buttons stacked above the keyboard (issue screenshot in gitea#5).

Move the modifier up to the Form root so exactly one keyboard
toolbar is registered for the entire screen, matching the pattern
already used by `TaskFormView`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:58:19 -05:00
Trey t fdcf82757d fix(uploads): switch from S3 multipart POST to presigned PUT
Android UI Tests / ui-tests (push) Has been cancelled
Backblaze B2's S3-compatible endpoint does not implement the S3 POST
Object operation — every POST returns HTTP 501 regardless of URL form
(path-style or virtual-hosted-style). The previous multipart-POST flow
has been failing for every task-completion image upload.

Server-side companion change (honeyDueAPI master @7cc5448) replaces
PresignedPostPolicy with PresignHeader/PUT and renames the response
field from "fields" to "headers". This commit aligns both clients.

PresignUploadResponse model: field renamed `fields` → `headers`,
added `method` (default "PUT"). Both new fields have defaults so a
build talking to a stale server still decodes — albeit with empty
headers, which would then 403 at signature time. The server is
already on the new shape in prod.

iOS PresignedUploader.swift: dropped the ~70-line multipart body
builder and S3 form-field ordering logic. Replaced with a single PUT
request that applies server-supplied headers verbatim (skipping
Content-Length, which URLSession sets automatically and refuses to
override).

Android UploadApi.kt: same shape change. `postToStorage` →
`putToStorage`. Single Ktor `client.put()` with headers passthrough.
`uploadOne`'s `fileName` parameter kept for source compatibility but
marked @Suppress("UNUSED_PARAMETER") since PUT doesn't need it.

Verified end-to-end against api.myhoneydue.com:
  presign → PUT 12 bytes → HTTP 200 in 0.6s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:48:54 -05:00
Trey t 3890dd6f52 chore(network): point ApiConfig at PROD by default
Was on Environment.LOCAL — useful for local-against-127.0.0.1 dev but
means a release build off main hits a server the device can't reach.
Switch to Environment.PROD so the app talks to api.myhoneydue.com.
LOCAL/DEV are still one-line toggles in ApiConfig.kt for development.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:48:54 -05:00
Trey T d5041492a9 test: add forceFreshLoginPerTest opt-in flag to AuthenticatedUITestCase
Android UI Tests / ui-tests (push) Has been cancelled
Default is `false` (current session-reuse behaviour) so tests reuse the
existing logged-in session — fast, and resilient to suites where the
current screen lacks a logout affordance (`UITestHelpers.ensureLoggedOut`
times out → tests fail before their bodies run).

Override to `true` in suites that observe transient `Invalid token` 401s
on POST/PATCH while reads continue to work. Recipe added after a 2026-05
incident where the API container was rebuilt mid-suite and in-memory
JWT tokens went stale; the diagnostic value is having an explicit lever
to reach for next time, not flipping the default.

Net effect on a clean simulator + stable API: 244/253 → 244/253 (no
behaviour change in the default path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:14:37 -05:00
admin ec5d93efab Merge pull request 'feat: bundle ID migration + gitea#2 task-cache fix (recovered from fix/task-cache-unification)' (#4) from feat/bundle-id-and-task-cache into master
Android UI Tests / ui-tests (push) Has been cancelled
Reviewed-on: #4
2026-05-01 20:48:28 -05:00
Trey t b90533c535 build: bump Gradle + Kotlin daemon heap for KMP
Android UI Tests / ui-tests (pull_request) Has been cancelled
OOMs were happening at the previous limits (Gradle 4G / Kotlin 3G)
during ComposeApp.framework generation. Bumped to 6G / 4G with a
1G Metaspace cap and G1GC for steadier latency on incremental builds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:38:28 -07:00
Trey t 03a9dfa0de fix: 2 latent iOS bugs that blocked Suite11 XCUITest from running end-to-end
The XCUITest for gitea#2 (Suite11) was failing for reasons unrelated
to the cache fix — actual bugs in the registration/onboarding code
that real users probably hit too:

1. OrganicOnboardingSecureField + iOS 26 SecureField/autofill bug
   On iOS 26, tapping a SwiftUI SecureField with .textContentType(.password)
   doesn't reliably bring up the keyboard — the strong-password autofill
   panel steals focus. Fix: under --ui-testing, default the visibility
   toggle to ON so the field renders as a plain TextField (which has
   reliable focus). Real users are unaffected.

2. Email registration didn't propagate auth state
   Apple/Google sign-in paths called AuthenticationManager.shared.login(),
   but email-registration's onChange(viewModel.isRegistered) handler did
   not. As a result, AuthenticationManager.isAuthenticated stayed false
   through the entire onboarding flow. OnboardingState.completeOnboarding
   has an auth guard that silently no-ops when isAuthenticated is false,
   leaving users stuck on the firstTask screen forever (until a
   scenePhase event triggered checkAuthenticationStatus to re-sync from
   DataManager). Fix: call authManager.login(verified: false) when
   isRegistered flips true.

Suite11 now passes 2/2 in 96-107s, exercising the full onboarding flow
and asserting tasks appear on residence detail without restart.

Refs gitea#2
2026-05-01 18:35:40 -07:00
Trey t 1884853e4b android: ResidenceViewModel.residenceTasksState derives from _allTasks
Same screen contract, but the data flows from DataManager.allTasks
through a combine(_allTasks, _currentResidenceId) into the existing
StateFlow. No per-residence network call needed; the upstream
getTasks() refresh propagates and the screen re-renders.

Eliminates the gitea#2 race window on Android — same fix as the iOS
TaskViewModel commit. Both platforms now react to _allTasks changes
without manual refresh.
2026-05-01 18:34:08 -07:00
Trey t 882801c71d ios: TaskViewModel observes $allTasks and filters by residence in-memory
Replaces the dual-sink ($allTasks when residence-scoped is nil,
$tasksByResidence when set) with a single $allTasks observation
that filters in-memory when currentResidenceId is set.

Eliminates the gitea#2 race window where the per-residence cache slot
could be empty while $allTasks was populated, leaving residence
detail stuck on the empty state. After this commit, every emit of
_allTasks rerenders every observing view — kanban tab, residence
detail, dashboards — atomically.

Refs gitea#2
2026-05-01 18:31:41 -07:00
Trey t dea8eed184 refactor: getTasksByResidence is now a thin filter over _allTasks
Was 3 fallback paths (per-residence cache → filter from allTasks →
network). Now: ensure _allTasks fresh, return filter. The per-residence
cache becomes write-only by this path, scheduled for deletion in the
next commit.

Eliminates a class of bugs where the per-residence cache slot could
be missing while _allTasks was stale — the old Path 1+2 would either
return stale data or skip and hit the API redundantly.
2026-05-01 18:30:58 -07:00
Trey t 915a5d4742 test: characterize getTasksForResidence filter contract
Locks down the contract that becomes the primary path for residence
detail in Phase 3:
- filters _allTasks by residenceId
- returns empty shell for residence with no tasks (vs null for cache miss)
- returns null when _allTasks itself is null (caller must hit API)
2026-05-01 18:30:58 -07:00
Trey t 4f9b910a94 fix: bulkCreateTasks force-refreshes _allTasks instead of merging task-by-task
Server is the authoritative kanban categorizer. After a bulk insert,
re-fetch /api/tasks/ so the kanban view reflects exactly what the
server sees, including any column re-categorizations the client's
in-memory upsert wouldn't compute. One extra round-trip per onboarding
submission, called once per session typically.

Eliminates the entire bug class where DataManager.updateTask had to
correctly compute kanban column placement from the response's
kanbanColumn field. With force-refresh, the server is the source of
truth — fewer ways for the client cache to drift.

Refs gitea#2
2026-05-01 18:30:58 -07:00
Trey t 3df5645f73 test: lock down that updateTask no longer writes _tasksByResidence
Catches re-introduction of the conditional _tasksByResidence write
branch removed in the previous commit. The per-residence cache is
deprecated; updateTask must only mutate _allTasks.
2026-05-01 18:30:58 -07:00
Trey t 5f7498b755 fix: DataManager.updateTask seeds _allTasks when cache is empty (gitea#2)
Closes the silent no-op when _allTasks is null on first launch (the
onboarding bulkCreateTasks path). The function now upserts: builds an
empty kanban shell with the standard column names if needed and places
the task in its target column. Unknown column names append a new
column at the end so the task is always reachable.

Also drops the second branch that conditionally wrote to
_tasksByResidence — that cache is being deleted in Phase 3 and
updateTask should not maintain it any more.

The Phase 1 unit tests now pass; the Phase 2 force-refresh in the
next commit replaces the placeholder column metadata (display names,
colors, icons) with authoritative server values.
2026-05-01 18:30:58 -07:00
Trey t 733d4c8d36 test: failing — DataManager.updateTask must seed _allTasks when cache is empty
Captures gitea#2 at the cache layer. Three tests:
- updateTask_seedsAllTasks_whenCacheIsEmpty (the core bug)
- updateTask_distributesAcrossColumns_whenSeedingThenAdding
- updateTask_replacesExistingTaskById_acrossColumns

All three FAIL on this commit because updateTask is a conditional
?.let{} that no-ops when _allTasks is null. Phase 1 fix in the next
commit makes them green.
2026-05-01 18:30:58 -07:00
Trey t 87771ef7f3 test: add accessibility identifiers along the onboarding-to-residence-detail path
Scaffolding for the gitea#2 regression XCUITest. No user-visible
change — pure metadata for UI automation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:30:58 -07:00
Trey t 65803a2180 plan: task cache unification (closes gitea#2)
Fix the bug where tasks created during onboarding don't appear on
the Residence Detail screen until app restart. Root cause:
DataManager.updateTask is a no-op when both _allTasks is null AND
_tasksByResidence[residenceId] is empty — the case after a fresh
register-then-bulk-create flow.

Approach: collapse the dual cache into a single source of truth
(_allTasks). Residence detail observes it directly and filters by
residenceId in-memory. After mutations, force-refresh _allTasks from
the server (one round-trip eliminates a class of bugs).

Plan covers 14 tasks across 4 phases plus a regression XCUITest
that captures the user-visible bug end-to-end.
2026-05-01 18:30:58 -07:00
Trey t ef8eab4a07 iOS: complete bundle ID + team ID migration to com.myhoneydue.*
Carries the rebrand from the backend (APPLE_CLIENT_ID, APNS_TOPIC) all
the way through the iOS targets:

- All target PRODUCT_BUNDLE_IDENTIFIERs: com.tt.honeyDue.* → com.myhoneydue.honeyDue.*
- DEVELOPMENT_TEAM: V3PF3M6B6U → X86BR9WTLD (across every target)
- APP_GROUP_IDENTIFIER: group.com.tt.honeyDue.* → group.com.myhoneydue.honeyDue.*
- BGTaskSchedulerPermittedIdentifiers + BackgroundTaskManager constant
- KeychainHelper service identifier
- StoreKit fallback product IDs + Info.plist IAP product ID keys
- ExportOptions.plist teamID
- NSCamera / NSPhotoLibrary usage descriptions reworded
- Onboarding suggestion strings reworked (new %lld%% match copy,
  dropped old "Great match" / "Good match" / "Generating suggestions"
  strings — replaced by relevance-percentage labels)
- xctestplan + settings.local.json housekeeping

App-group rename means UserDefaults / shared-container data written to
the old group ID is abandoned. Acceptable since this is pre-launch.
2026-05-01 18:30:52 -07:00
admin 2064e70d75 Merge pull request 'feat(uploads): direct-to-B2 presigned image upload (iOS + Android)' (#3) from feat/presigned-uploads into master
Android UI Tests / ui-tests (push) Has been cancelled
Reviewed-on: #3
2026-05-01 19:40:10 -05:00
Trey t b2d03ef8b2 refactor(uploads): drop legacy multipart helpers; route Android UI through presigned flow
Android UI Tests / ui-tests (pull_request) Has been cancelled
The KMP shared layer's task-completion-with-images path now exclusively
uses the presigned-URL flow: each image is compressed, uploaded directly
to B2 via APILayer.uploadImage, and the resulting upload_ids are passed
to /api/task-completions/ as JSON. Bytes never traverse our API server.

Changes:
  - TaskCompletionViewModel.createTaskCompletionWithImages now does the
    presign→POST→collect-ids dance internally. The signature stays the
    same so the three Android UI call sites (TasksScreen, AllTasksScreen,
    ResidenceDetailScreen, CompleteTaskDialog, CompleteTaskScreen) need
    no changes.
  - APILayer.createTaskCompletionWithImages removed (dead).
  - TaskCompletionApi.createCompletionWithImages removed (the multipart
    HTTP helper that posted to the legacy POST /api/task-completions/
    multipart endpoint).
  - TaskCompletionCreateRequest.imageUrls field removed.
  - Three Swift call sites (CompleteTaskView, WidgetActionProcessor,
    PushNotificationManager) updated to drop the imageUrls argument.
  - Two Kotlin call sites (CompleteTaskDialog, CompleteTaskScreen) updated.

Image uploads now match WhatsApp/Slack-class architecture: client-side
compression + direct-to-storage upload + lightweight JSON entity create.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:48:11 -07:00
Trey t fa0ce30257 feat(uploads): direct-to-B2 presigned image upload from iOS + Android
iOS (Swift) — primary path, since iOS is the live platform:
  - ImageDownsampler.swift: ImageIO/CGImageSourceCreateThumbnailAtIndex
    based resize. Pays only the cost of the resized bitmap rather than
    decoding the full source — a 12 MP iPhone photo previously
    materialized ~50 MB regardless of JPEG size. Profiles: completion
    (2048 px / quality 0.85), document_image (2560 px / 0.90).
  - PresignedUploader.swift: three-step orchestration (POST /uploads/presign
    → multipart POST direct to B2 with the signed policy fields → return
    upload_id). Maps HTTP errors to user-facing copy. Concurrent uploads
    via TaskGroup.
  - CompleteTaskView.swift: replaces the multipart-with-images path with
    downsample → upload-to-B2 → create-completion-with-upload_ids[]. The
    no-image branch unchanged.

Android (Kotlin) — parity:
  - composeApp/.../media/ImageDownsampler.kt: BitmapFactory inSampleSize
    + proportional scale + JPEG compress. Same profiles as iOS.
  - composeApp/.../network/UploadApi.kt: Ktor-based presign + direct-to-B2
    POST. Preserves form-field order so the S3 policy signature validates.
  - APILayer.uploadImage(category, contentType, bytes, fileName) → upload_id.
    UI integration to follow.

Shared (Kotlin):
  - models/TaskCompletion.kt: added uploadIds: List<Int>? to
    TaskCompletionCreateRequest and a new PresignUploadRequest /
    PresignUploadResponse pair matching the Go API DTOs.
  - Existing call sites (WidgetActionProcessor, PushNotificationManager)
    explicitly pass uploadIds: nil for backwards compatibility — Swift's
    bridge to Kotlin doesn't honor Kotlin defaults for required-positional
    parameters.

The legacy multipart path remains functional alongside the new one for
soak-test purposes; per-platform feature flags can flip between them at
any time. After zero multipart traffic in production for 7 consecutive
days, the legacy paths can be dropped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:42:41 -07:00
admin 49e2397e85 Merge pull request 'rc/android-ios-parity' (#1) from rc/android-ios-parity into master
Android UI Tests / ui-tests (push) Has been cancelled
Reviewed-on: #1
2026-04-20 19:43:32 -05:00
Trey T 170a6d0e40 Parity gallery markdown: emit <img> tags with fixed width/height instead of markdown image syntax so every screenshot renders at identical size in Gitea's markdown view. Gitea strips inline styles but keeps width/height attributes.
Android UI Tests / ui-tests (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:34:34 -05:00
Trey T 16096f4b70 Parity gallery: force uniform aspect ratio + object-fit so Android and iOS screenshots render at identical display size regardless of native capture dimensions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:24:02 -05:00
Trey T 9fa58352c0 Parity gallery: unify around canonical manifest, fix populated-state rendering
Single source of truth: `com.tt.honeyDue.testing.GalleryScreens` lists
every user-reachable screen with its category (DataCarrying / DataFree)
and per-platform reachability. Both platforms' test harnesses are
CI-gated against it — `GalleryManifestParityTest` on each side fails
if the surface list drifts from the manifest.

Variant matrix by category: DataCarrying captures 4 PNGs
(empty/populated × light/dark), DataFree captures 2 (light/dark only).
Empty variants for DataCarrying use `FixtureDataManager.empty(seedLookups = false)`
so form screens that only read DM lookups can diff against populated.

Detail-screen rendering fixed on both platforms. Root cause: VM
`stateIn(Eagerly, initialValue = …)` closures evaluated
`_selectedX.value` before screen-side `LaunchedEffect` / `.onAppear`
could set the id, leaving populated captures byte-identical to empty.

  Kotlin: `ContractorViewModel` + `DocumentViewModel` accept
  `initialSelectedX: Int? = null` so the id is set in the primary
  constructor before `stateIn` computes its seed.

  Swift: `ContractorViewModel`, `DocumentViewModelWrapper`,
  `ResidenceViewModel`, `OnboardingTasksViewModel` gained pre-seed
  init params. `ContractorDetailView`, `DocumentDetailView`,
  `ResidenceDetailView`, `OnboardingFirstTaskContent` gained
  test/preview init overloads that accept the pre-seeded VM.
  Corresponding view bodies prefer cached success state over
  loading/error — avoids a spinner flashing over already-visible
  content during background refreshes (production benefit too).

Real production bug fixed along the way: `DataManager.clear()` was
missing `_contractorDetail`, `_documentDetail`, `_contractorsByResidence`,
`_taskCompletions`, `_notificationPreferences`. On logout these maps
leaked across user sessions; in the gallery they leaked the previous
surface's populated state into the next surface's empty capture.

`ImagePicker.android.kt` guards `rememberCameraPicker` with
`LocalInspectionMode` — `FileProvider.getUriForFile` can't resolve the
Robolectric test-cache path, so `add_document` / `edit_document`
previously failed the entire capture.

Honest reclassifications: `complete_task`, `manage_users`, and
`task_suggestions` moved to DataFree. Their first-paint visible state
is driven by static props or APILayer calls, not by anything on
`IDataManager` — populated would be byte-identical to empty without
a significant production rewire. The manifest comments call this out.

Manifest counts after all moves: 43 screens = 12 DataCarrying + 31
DataFree, 37 on both platforms + 3 Android-only (home, documents,
biometric_lock) + 3 iOS-only (documents_warranties, add_task,
profile_edit).

Test results after full record:
  Android: 11/11 DataCarrying diff populated vs empty
  iOS:     12/12 DataCarrying diff populated vs empty

Also in this change:
- `scripts/build_parity_gallery.py` parses the Kotlin manifest
  directly, renders rows in product-flow order, shows explicit
  `[missing — <platform>]` placeholders for expected-but-absent
  captures and muted `not on <platform>` placeholders for
  platform-specific screens. Docs regenerated.
- `scripts/cleanup_orphan_goldens.sh` safely removes PNGs from prior
  test configurations (theme-named, compare artifacts, legacy
  empty/populated pairs for what is now DataFree). Dry-run by default.
- `docs/parity-gallery.md` rewritten: canonical-manifest workflow,
  adding-a-screen guide, variant matrix explained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:10:32 -05:00
Trey T 316b1f709d P3: NoIndependentViewModelStateFileScanTest — architecture regression gate
Fails CI if any future VM regresses to the pre-migration pattern of
owning independent MutableStateFlow read-state. Two assertions:

1. every_read_state_vm_accepts_iDataManager_ctor_param
   Scans composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ and
   requires every VM to either declare `dataManager: IDataManager` as a
   constructor param or be in WORKFLOW_ONLY_VMS allowlist (currently
   TaskCompletion, Onboarding, PasswordReset).

2. read_state_flows_should_be_derived_not_independent
   Flags any `private val _xxxState = MutableStateFlow(...)` whose
   field-name prefix isn't on the mutation-feedback allowlist (create/
   update/delete/toggle/…). Read-state MUST derive from DataManager via
   .map + .stateIn pattern. AuthViewModel file-level allowlisted
   (every one of its 11 states is legitimate one-shot mutation feedback).

Paired stub in commonTest documents the rule cross-platform; real scan
lives in androidUnitTest where java.io.File works. Runs with
./gradlew :composeApp:testDebugUnitTest --tests "*architecture*".

See docs/parity-gallery.md "Known limitations" for the history of the
Dec 3 2025 partial migration this gate prevents regressing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 18:51:36 -05:00
Trey T 42ccbdcbd6 P2: iOS Full DI — all 11 VMs accept dataManager init param
Adds the DI seam to the 5 previously singleton-coupled VMs:
- VerifyEmailViewModel
- RegisterViewModel
- PasswordResetViewModel
- AppleSignInViewModel
- OnboardingTasksViewModel

All now accept init(dataManager: DataManagerObservable = .shared).

iOSApp.swift injects DataManagerObservable.shared at the root via
.environmentObject so descendant views can reach it via @EnvironmentObject
without implicit singleton reads.

Dependencies.swift factories updated to pass DataManager.shared explicitly
into Kotlin VM constructors — SKIE doesn't surface Kotlin default init
parameters as Swift defaults, so every Kotlin VM call-site needs the
explicit argument. Affects makeAuthViewModel, makeResidenceViewModel,
makeTaskViewModel, makeContractorViewModel, makeDocumentViewModel.

Full iOS build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 18:47:58 -05:00
Trey T f0f8dfb68b P1: All Kotlin VMs align with DataManager single-source-of-truth
Four broken VMs refactored to derive read-state from IDataManager, three
gaps closed:

1. TaskViewModel: tasksState / tasksByResidenceState / taskCompletionsState
   now derived via .map + .stateIn / combine. isLoading / loadError separated.
2. ResidenceViewModel: residencesState / myResidencesState / summaryState /
   residenceTasksState / residenceContractorsState all derived. 8 mutation
   states retained as independent (legit one-shot feedback).
3. ContractorViewModel: contractorsState / contractorDetailState derived.
   4 mutation states retained.
4. DocumentViewModel: documentsState / documentDetailState derived. 6
   mutation states retained.
5. AuthViewModel: currentUserState now derived from dataManager.currentUser.
   10 other states stay independent (one-shot mutation feedback by design).
6. LookupsViewModel: accepts IDataManager ctor param for test injection
   consistency. Direct-exposure pattern preserved. Legacy ApiResult-wrapped
   states now derived from DataManager instead of manual _xxxState.value =.
7. NotificationPreferencesViewModel: preferencesState derived from new
   IDataManager.notificationPreferences. APILayer writes through on both
   getNotificationPreferences and updateNotificationPreferences.

IDataManager also grew notificationPreferences: StateFlow<NotificationPreference?>.
DataManager, InMemoryDataManager updated. No screen edits needed — screens
consume viewModel.xxxState the same way; the source just switched.

Architecture enforcement test comes in P3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 18:42:40 -05:00
Trey T 2230cde071 P0: IDataManager coverage gaps — contractorDetail/documentDetail/taskCompletions/contractorsByResidence
Adds 4 new StateFlow members to IDataManager + DataManager + InMemoryDataManager + FixtureDataManager:
- contractorDetail: Map<Int, Contractor> — cached detail fetches
- documentDetail: Map<Int, Document>
- taskCompletions: Map<Int, List<TaskCompletionResponse>>
- contractorsByResidence: Map<Int, List<ContractorSummary>>

APILayer now writes to these on successful detail/per-residence fetches:
- getTaskCompletions -> setTaskCompletions
- getDocument -> setDocumentDetail
- getContractor -> setContractorDetail
- getContractorsByResidence -> setContractorsForResidence

Fixture populated() seeds contractorDetail + contractorsByResidence.
Populated taskCompletions is empty (Fixtures doesn't define any completions yet).

Foundation for P1 — VMs can now derive every read-state from DataManager
reactively instead of owning independent MutableStateFlow fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 18:31:06 -05:00
633 changed files with 96264 additions and 28652 deletions
+2 -1
View File
@@ -18,7 +18,8 @@
"Bash(ps:*)",
"Bash(stdbuf:*)",
"Bash(sysctl:*)",
"Bash(tee:*)"
"Bash(tee:*)",
"Bash(codesign -d --entitlements :- /Users/treyt/Library/Developer/Xcode/DerivedData/honeyDue-buvczbpttcfkxxcmxbnqkqrmujyh/Build/Products/Debug-iphonesimulator/honeyDue.app)"
]
}
}
@@ -121,6 +121,19 @@
</intent-filter>
</receiver>
<!-- Per-widget residence picker (gitea#6). Each widget provider
XML declares `android:configure` pointing at this activity,
so the system launches it whenever the user pins a new
tile or hits "Edit Widget" on an existing one. -->
<activity
android:name=".widget.WidgetConfigActivity"
android:exported="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<!-- Small Widget Receiver (2x1) -->
<receiver
android:name=".widget.HoneyDueSmallWidgetReceiver"
@@ -314,9 +314,10 @@ class MainActivity : FragmentActivity(), SingletonImageLoader.Factory {
return ImageLoader.Builder(context)
.components {
// Auth interceptor runs before the network fetcher so every
// image request carries the current Authorization header, with
// 401 -> refresh-token -> retry handled transparently. Mirrors
// iOS AuthenticatedImage.swift (Stream U).
// image request carries the current X-Session-Token header
// (Kratos session token), with 401 -> session re-check ->
// retry handled transparently. Mirrors iOS
// AuthenticatedImage.swift.
add(
CoilAuthInterceptor(
tokenProvider = { TokenStorage.getToken() },
@@ -324,7 +325,6 @@ class MainActivity : FragmentActivity(), SingletonImageLoader.Factory {
val r = APILayer.refreshToken()
if (r is ApiResult.Success) r.data else null
},
authScheme = "Token",
)
)
add(KtorNetworkFetcherFactory())
@@ -91,7 +91,6 @@ class NotificationActionReceiver : BroadcastReceiver() {
notes = null,
actualCost = null,
rating = null,
imageUrls = null
)
when (val result = APILayer.createTaskCompletion(request)) {
@@ -0,0 +1,101 @@
package com.tt.honeyDue.media
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import java.io.ByteArrayOutputStream
import java.io.InputStream
/**
* Memory-efficient image resizer for upload preprocessing on Android.
*
* Why not just decode + Bitmap.createScaledBitmap? createScaledBitmap
* decodes the full source bitmap first — a 12 MP photo materializes ~50 MB
* in RAM regardless of how big the JPEG is. That OOMs older devices.
*
* BitmapFactory.Options.inSampleSize, paired with inJustDecodeBounds=true
* for a metadata-only first pass, lets us decode at a power-of-two
* subsample. Combined with a final scaled-down draw, peak memory is
* roughly proportional to the *output* bitmap's pixel count — not the
* source's.
*
* Quality tuning matches WhatsApp-class apps: 2048 px max edge, JPEG 85.
*/
object ImageDownsampler {
data class Profile(
val maxPixelEdge: Int,
/** JPEG quality 0-100. */
val jpegQuality: Int,
) {
companion object {
val Completion = Profile(maxPixelEdge = 2048, jpegQuality = 85)
val DocumentImage = Profile(maxPixelEdge = 2560, jpegQuality = 90)
}
}
/** Downsample raw image bytes into JPEG bytes ready for upload. */
fun downsample(bytes: ByteArray, profile: Profile): ByteArray? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val sampleSize = computeSampleSize(bounds.outWidth, bounds.outHeight, profile.maxPixelEdge)
val decodeOpts = BitmapFactory.Options().apply {
inSampleSize = sampleSize
// ARGB_8888 keeps quality; on memory-constrained devices we
// could drop to RGB_565 here, but for upload prep the extra
// ~2x peak memory isn't worth the visible quality loss.
inPreferredConfig = Bitmap.Config.ARGB_8888
}
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOpts)
?: return null
// Subsample is power-of-two only; the result may still be larger
// than maxPixelEdge by up to 2x. One more proportional scale gets
// us to the exact target.
val scaled = scaleProportional(decoded, profile.maxPixelEdge)
val out = ByteArrayOutputStream(64 * 1024)
val ok = scaled.compress(Bitmap.CompressFormat.JPEG, profile.jpegQuality, out)
// Only recycle if scaled is a different bitmap; createScaledBitmap
// sometimes returns the input unchanged, and recycling that would
// double-recycle below.
if (scaled !== decoded) decoded.recycle()
scaled.recycle()
return if (ok) out.toByteArray() else null
}
/** Same, from a stream (for content:// URIs etc.). */
fun downsample(input: InputStream, profile: Profile): ByteArray? {
val bytes = input.use { it.readBytes() }
return downsample(bytes, profile)
}
/**
* Pick the largest power-of-two sub-sample factor that still yields
* an image at least as large as maxPixelEdge on both axes. Mirrors
* the canonical Android docs example.
*/
private fun computeSampleSize(srcW: Int, srcH: Int, maxEdge: Int): Int {
var sample = 1
var halfW = srcW / 2
var halfH = srcH / 2
while (halfW >= maxEdge && halfH >= maxEdge) {
sample *= 2
halfW /= 2
halfH /= 2
}
return sample
}
private fun scaleProportional(src: Bitmap, maxEdge: Int): Bitmap {
val w = src.width
val h = src.height
val longest = maxOf(w, h)
if (longest <= maxEdge) return src
val ratio = maxEdge.toFloat() / longest.toFloat()
val newW = (w * ratio).toInt().coerceAtLeast(1)
val newH = (h * ratio).toInt().coerceAtLeast(1)
return Bitmap.createScaledBitmap(src, newW, newH, true)
}
}
@@ -105,7 +105,6 @@ class NotificationActionReceiver : BroadcastReceiver() {
notes = "Completed from notification",
actualCost = null,
rating = null,
imageUrls = null
)
when (val result = APILayer.createTaskCompletion(request)) {
is ApiResult.Success -> {
@@ -8,6 +8,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.core.content.FileProvider
import java.io.File
@@ -58,6 +59,15 @@ actual fun rememberImagePicker(
actual fun rememberCameraPicker(
onImageCaptured: (ImageData) -> Unit
): () -> Unit {
// Compose previews and Roborazzi snapshot tests run without a
// `FileProvider` resolvable cache path — `getUriForFile` throws
// `Failed to find configured root...` because the test cache dir
// isn't registered in the manifest's `file_paths.xml`. The launch
// callback is never invoked in a preview/snapshot anyway, so
// returning a no-op keeps the composition clean.
if (LocalInspectionMode.current) {
return {}
}
val context = LocalContext.current
// Create a temp file URI for the camera to save to
@@ -45,8 +45,14 @@ class HoneyDueLargeWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val repo = WidgetDataRepository.get(context)
val tasks = repo.loadTasks()
val stats = repo.computeStats()
// Per-instance residence scoping (gitea#6). Stats are computed
// off the same filtered list so the bottom-tile counters
// ("Overdue / 7 days / 30 days") match the visible tasks
// instead of aggregating across every residence.
val appWidgetId =
androidx.glance.appwidget.GlanceAppWidgetManager(context).getAppWidgetId(id)
val tasks = repo.loadTasksForWidget(appWidgetId)
val stats = repo.computeStatsFromTasks(tasks)
val tier = repo.loadTierState()
val isPremium = tier.equals("premium", ignoreCase = true)
@@ -135,4 +141,9 @@ class HoneyDueLargeWidget : GlanceAppWidget() {
/** AppWidget receiver for the large widget. */
class HoneyDueLargeWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = HoneyDueLargeWidget()
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
WidgetReceiverHelpers.purgeResidenceScopes(context, appWidgetIds)
}
}
@@ -36,7 +36,10 @@ class HoneyDueMediumWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val repo = WidgetDataRepository.get(context)
val tasks = repo.loadTasks()
// Per-instance residence scoping (gitea#6). See small widget for rationale.
val appWidgetId =
androidx.glance.appwidget.GlanceAppWidgetManager(context).getAppWidgetId(id)
val tasks = repo.loadTasksForWidget(appWidgetId)
val tier = repo.loadTierState()
val isPremium = tier.equals("premium", ignoreCase = true)
@@ -122,4 +125,9 @@ class HoneyDueMediumWidget : GlanceAppWidget() {
/** AppWidget receiver for the medium widget. */
class HoneyDueMediumWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = HoneyDueMediumWidget()
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
WidgetReceiverHelpers.purgeResidenceScopes(context, appWidgetIds)
}
}
@@ -2,6 +2,7 @@ package com.tt.honeyDue.widget
import android.content.Context
import android.content.Intent
import kotlinx.coroutines.launch
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceId
@@ -43,7 +44,13 @@ class HoneyDueSmallWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val repo = WidgetDataRepository.get(context)
val tasks = repo.loadTasks()
// Resolve which residence this widget instance is scoped to
// (gitea#6). `loadTasksForWidget` falls back to "All residences"
// when no scope is saved, matching pre-#6 behaviour for tiles
// that haven't been configured yet.
val appWidgetId =
androidx.glance.appwidget.GlanceAppWidgetManager(context).getAppWidgetId(id)
val tasks = repo.loadTasksForWidget(appWidgetId)
val tier = repo.loadTierState()
val isPremium = tier.equals("premium", ignoreCase = true)
@@ -125,4 +132,35 @@ class OpenAppAction : ActionCallback {
/** AppWidget receiver for the small widget. */
class HoneyDueSmallWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = HoneyDueSmallWidget()
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
// Clean per-instance residence scope when the user removes a tile
// so dangling `widget_residence_id_<n>` keys don't accumulate in
// the DataStore (gitea#6).
WidgetReceiverHelpers.purgeResidenceScopes(context, appWidgetIds)
}
}
/**
* Shared helpers for honeyDue Glance widget receivers. Kept in a
* top-level utility so every receiver size (Small / Medium / Large)
* uses identical cleanup logic.
*/
internal object WidgetReceiverHelpers {
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
fun purgeResidenceScopes(context: Context, appWidgetIds: IntArray) {
if (appWidgetIds.isEmpty()) return
val repo = WidgetDataRepository.get(context)
// Fire-and-forget on a background dispatcher — `onDeleted` runs
// on the broadcast thread which doesn't permit suspend calls.
// GlobalScope is correct here: the IO is short-lived (one
// DataStore edit per removed appWidgetId) and there's no
// coroutine-scope tied to a long-lived receiver to attach to.
kotlinx.coroutines.GlobalScope.launch(kotlinx.coroutines.Dispatchers.IO) {
for (id in appWidgetIds) {
repo.clearResidenceIdFor(id)
}
}
}
}
@@ -0,0 +1,270 @@
package com.tt.honeyDue.widget
import android.app.Activity
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Home
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.updateAll
import com.tt.honeyDue.ui.theme.AppSpacing
import com.tt.honeyDue.ui.theme.HoneyDueTheme
import com.tt.honeyDue.ui.theme.ThemeManager
import kotlinx.coroutines.launch
import androidx.lifecycle.lifecycleScope
/**
* Per-widget residence selector. Launched by the system when the user
* pins a new honeyDue widget (because each widget provider XML now
* declares `android:configure`) and again when they hit "Edit Widget".
*
* Saves the chosen residence id under
* `widget_residence_id_<appWidgetId>` in [WidgetDataStore] so each
* widget instance can independently scope its task list (gitea#6).
* Selecting "All residences" clears the key and the widget reverts to
* the legacy unscoped behaviour.
*/
class WidgetConfigActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// The system passes the just-created widget id in the extras.
// Without it we don't know which widget to configure — bail
// with CANCELED so the system removes the placeholder tile.
val appWidgetId = intent?.extras?.getInt(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID
) ?: AppWidgetManager.INVALID_APPWIDGET_ID
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
setResult(Activity.RESULT_CANCELED)
finish()
return
}
// Default a CANCEL result so the widget is removed if the user
// dismisses without saving (matches the Android convention).
setResult(
Activity.RESULT_CANCELED,
Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
)
setContent {
val theme = ThemeManager.currentTheme
HoneyDueTheme(themeColors = theme) {
WidgetConfigScreen(
appWidgetId = appWidgetId,
onCommit = { residenceId ->
lifecycleScope.launch {
val repo = WidgetDataRepository.get(this@WidgetConfigActivity)
repo.saveResidenceIdFor(appWidgetId, residenceId)
// Repaint every widget tile so this one
// picks up the new scope on the next frame
// (Glance handles which `appWidgetId` we
// belong to via the per-instance state).
HoneyDueSmallWidget().updateAll(this@WidgetConfigActivity)
HoneyDueMediumWidget().updateAll(this@WidgetConfigActivity)
HoneyDueLargeWidget().updateAll(this@WidgetConfigActivity)
setResult(
Activity.RESULT_OK,
Intent().putExtra(
AppWidgetManager.EXTRA_APPWIDGET_ID,
appWidgetId
)
)
finish()
}
}
)
}
}
}
}
/**
* The actual picker UI. Loads residences from [WidgetDataRepository]
* and offers an "All residences" option above them. Empty state shows
* a helper message instead of an empty list (user hasn't created any
* residences yet, or the main app hasn't synced).
*/
@Composable
private fun WidgetConfigScreen(
appWidgetId: Int,
onCommit: (Long?) -> Unit
) {
var residences by remember { mutableStateOf<List<WidgetResidenceDto>?>(null) }
var selectedId by remember { mutableStateOf<Long?>(null) }
val context = androidx.compose.ui.platform.LocalContext.current
LaunchedEffect(appWidgetId) {
val repo = WidgetDataRepository.get(context)
// Pre-select whatever the user picked last time they configured
// this same widget; falls back to "All residences" on first run.
selectedId = repo.loadResidenceIdFor(appWidgetId)
residences = repo.loadResidences()
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(AppSpacing.lg)
) {
Text(
text = "Choose a residence",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(Modifier.height(AppSpacing.sm))
Text(
text = "This widget will only show tasks for the residence you pick. " +
"Choose \"All residences\" to keep showing every home.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(AppSpacing.lg))
val items = residences
if (items == null) {
// Loading state — DataStore reads off the IO dispatcher.
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return@Column
}
LazyColumn(
modifier = Modifier.weight(1f, fill = true),
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm)
) {
// "All residences" — selecting clears the per-widget key.
item {
ResidenceRow(
title = "All residences",
isSelected = selectedId == null,
onClick = { selectedId = null }
)
}
if (items.isEmpty()) {
item {
EmptyResidencesNote()
}
} else {
items(items, key = { it.id }) { residence ->
ResidenceRow(
title = residence.name,
isSelected = selectedId == residence.id,
onClick = { selectedId = residence.id }
)
}
}
}
Spacer(Modifier.height(AppSpacing.lg))
Button(
onClick = { onCommit(selectedId) },
modifier = Modifier.fillMaxWidth()
) {
Text("Save")
}
}
}
@Composable
private fun ResidenceRow(
title: String,
isSelected: Boolean,
onClick: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(AppSpacing.md))
.background(
if (isSelected) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant
)
.clickable(onClick = onClick)
.padding(AppSpacing.lg)
) {
androidx.compose.foundation.layout.Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.Home,
contentDescription = null,
tint = if (isSelected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.fillMaxWidth(0.04f))
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.weight(1f)
)
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
}
@Composable
private fun EmptyResidencesNote() {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(AppSpacing.md))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(AppSpacing.lg)
) {
Text(
text = "No residences yet — open honeyDue and add a property first, " +
"then come back to configure this widget.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@@ -115,6 +115,66 @@ class WidgetDataRepository internal constructor(private val context: Context) {
return all.filterNot { it.id in pending }
}
/**
* Load the cached task list filtered by [residenceId]. Pass `null` to
* return every residence's tasks (the "All residences" widget option,
* matching pre-gitea#6 behaviour).
*
* Pending completions are excluded — same contract as [loadTasks].
*/
suspend fun loadTasksForResidence(residenceId: Long?): List<WidgetTaskDto> {
val all = loadTasks()
return filterTasksForResidence(all, residenceId)
}
/**
* Resolve the residence scope for [appWidgetId] and return only its
* tasks. The widget [GlanceAppWidget.provideGlance] looks up its
* `appWidgetId` and calls this; configuration changes take effect on
* the next `updateAll` invocation.
*/
suspend fun loadTasksForWidget(appWidgetId: Int): List<WidgetTaskDto> {
val residenceId = store.readResidenceIdFor(appWidgetId)
return loadTasksForResidence(residenceId)
}
// =========================================================================
// Residence sidecar (gitea#6)
// =========================================================================
/**
* Persist the user's residences so [WidgetConfigActivity] can offer
* them in its picker. Called from the main app whenever
* `DataManager.myResidences` updates.
*/
suspend fun saveResidences(residences: List<WidgetResidenceDto>) {
store.writeResidencesJson(json.encodeToString(residences))
}
/** Read the persisted residence list (empty when never written or after logout). */
suspend fun loadResidences(): List<WidgetResidenceDto> {
val raw = store.readResidencesJson()
return try {
json.decodeFromString<List<WidgetResidenceDto>>(raw)
} catch (e: Exception) {
emptyList()
}
}
/** Read which residence this widget instance is currently scoped to (null = All). */
suspend fun loadResidenceIdFor(appWidgetId: Int): Long? =
store.readResidenceIdFor(appWidgetId)
/** Persist the chosen residence for this widget instance. */
suspend fun saveResidenceIdFor(appWidgetId: Int, residenceId: Long?) {
store.writeResidenceIdFor(appWidgetId, residenceId)
}
/** Drop the per-widget residence selection when the widget is removed. */
suspend fun clearResidenceIdFor(appWidgetId: Int) {
store.clearResidenceIdFor(appWidgetId)
}
/** Queue a task id for optimistic completion. See [loadTasks]. */
suspend fun markPendingCompletion(taskId: Long) {
val current = store.readPendingCompletionIds().toMutableSet()
@@ -141,8 +201,15 @@ class WidgetDataRepository internal constructor(private val context: Context) {
*
* Pending-completion tasks are excluded (via [loadTasks]).
*/
suspend fun computeStats(): WidgetStats {
val tasks = loadTasks()
suspend fun computeStats(): WidgetStats = computeStatsFromTasks(loadTasks())
/**
* Compute the same stats off a pre-filtered task list. Used by
* [HoneyDueLargeWidget] after applying the per-widget residence
* scope (gitea#6) so the stat tiles reflect only the residence the
* user picked.
*/
fun computeStatsFromTasks(tasks: List<WidgetTaskDto>): WidgetStats {
var overdue = 0
var within7 = 0
var within8To30 = 0
@@ -257,5 +324,18 @@ class WidgetDataRepository internal constructor(private val context: Context) {
/** Legacy accessor — delegates to [get]. */
fun getInstance(context: Context): WidgetDataRepository = get(context)
/**
* Pure filter — exposed for unit-test coverage. Mirrors iOS'
* `WidgetDataManager.filterTasks(_:forResidenceId:)` semantics
* (gitea#6).
*/
fun filterTasksForResidence(
tasks: List<WidgetTaskDto>,
residenceId: Long?
): List<WidgetTaskDto> {
if (residenceId == null) return tasks
return tasks.filter { it.residenceId == residenceId }
}
}
}
@@ -32,6 +32,15 @@ internal object WidgetDataStoreKeys {
val PENDING_COMPLETION_IDS = stringPreferencesKey("pending_completion_ids")
val LAST_REFRESH_TIME = longPreferencesKey("last_refresh_time")
val USER_TIER = stringPreferencesKey("user_tier")
/** JSON-serialized List<WidgetResidenceDto> for the configuration picker (gitea#6). */
val WIDGET_RESIDENCES_JSON = stringPreferencesKey("widget_residences_json")
/**
* Returns a key for the `Long` residence id this `appWidgetId` is
* scoped to. Missing key = "All residences" (legacy behaviour).
*/
fun residenceIdKeyFor(appWidgetId: Int) =
longPreferencesKey("widget_residence_id_$appWidgetId")
}
/**
@@ -90,6 +99,56 @@ class WidgetDataStore(private val context: Context) {
prefs.remove(WidgetDataStoreKeys.PENDING_COMPLETION_IDS)
prefs.remove(WidgetDataStoreKeys.LAST_REFRESH_TIME)
prefs.remove(WidgetDataStoreKeys.USER_TIER)
prefs.remove(WidgetDataStoreKeys.WIDGET_RESIDENCES_JSON)
// Per-widget residence ids are added dynamically as
// `widget_residence_id_<appWidgetId>` keys; sweep them by
// prefix so logout doesn't leave dangling per-instance
// scoping behind.
prefs.asMap().keys
.filter { it.name.startsWith("widget_residence_id_") }
.forEach { prefs.remove(it) }
}
}
// =========================================================================
// Per-residence widget configuration (gitea#6)
// =========================================================================
/**
* Read the user's residences (id + name) as persisted by the main
* app. Used by [WidgetConfigActivity] to populate its picker.
*/
suspend fun readResidencesJson(): String =
store.data.first()[WidgetDataStoreKeys.WIDGET_RESIDENCES_JSON] ?: "[]"
suspend fun writeResidencesJson(json: String) {
store.edit { prefs ->
prefs[WidgetDataStoreKeys.WIDGET_RESIDENCES_JSON] = json
}
}
/**
* Read the residence id this widget instance is scoped to, or `null`
* for "All residences" (no scoping — the legacy default).
*/
suspend fun readResidenceIdFor(appWidgetId: Int): Long? =
store.data.first()[WidgetDataStoreKeys.residenceIdKeyFor(appWidgetId)]
suspend fun writeResidenceIdFor(appWidgetId: Int, residenceId: Long?) {
store.edit { prefs ->
val key = WidgetDataStoreKeys.residenceIdKeyFor(appWidgetId)
if (residenceId == null) {
prefs.remove(key)
} else {
prefs[key] = residenceId
}
}
}
/** Clear scoping for a removed widget instance (called from `onDeleted`). */
suspend fun clearResidenceIdFor(appWidgetId: Int) {
store.edit { prefs ->
prefs.remove(WidgetDataStoreKeys.residenceIdKeyFor(appWidgetId))
}
}
}
@@ -18,6 +18,13 @@ interface WidgetRefreshDataSource {
suspend fun fetchTasks(): ApiResult<List<WidgetTaskDto>>
/** Fetch the current user's subscription tier ("free" | "premium"). */
suspend fun fetchTier(): String
/**
* Fetch the user's residences for the widget configuration picker
* (gitea#6). Returning an empty list is non-fatal — the worker will
* just skip the residence sidecar update and pre-existing scopes
* keep working until next refresh.
*/
suspend fun fetchResidences(): List<WidgetResidenceDto> = emptyList()
}
/**
@@ -48,6 +55,16 @@ internal object DefaultWidgetRefreshDataSource : WidgetRefreshDataSource {
}
}
override suspend fun fetchResidences(): List<WidgetResidenceDto> {
val result = APILayer.getMyResidences(forceRefresh = false)
return when (result) {
is ApiResult.Success -> result.data.residences.map { r ->
WidgetResidenceDto(id = r.id.toLong(), name = r.name)
}
else -> emptyList()
}
}
private fun mapToWidgetTasks(response: TaskColumnsResponse): List<WidgetTaskDto> {
val out = mutableListOf<WidgetTaskDto>()
for (column in response.columns) {
@@ -112,6 +129,16 @@ class WidgetRefreshWorker(
val repo = WidgetDataRepository.get(ctx)
repo.saveTasks(tasksResult.data)
repo.saveTierState(tier)
// Best-effort residence sidecar update — failure is
// non-fatal because pre-existing scopes (and the
// "All residences" fallback) keep working with stale
// data until the next refresh succeeds (gitea#6).
runCatching {
val residences = dataSource.fetchResidences()
if (residences.isNotEmpty()) {
repo.saveResidences(residences)
}
}
refreshGlanceWidgets(ctx)
// Chain the next scheduled refresh so cadence keeps ticking
// even if the OS evicts our periodic request. Wrapped in
@@ -34,6 +34,20 @@ data class WidgetTaskDto(
val completed: Boolean
)
/**
* Lightweight residence identifier persisted to the widget DataStore.
*
* Written by the main app whenever [com.tt.honeyDue.data.DataManager.myResidences]
* updates so the widget configuration activity can offer the current
* residence list (gitea#6 — per-residence widget selection). Mirrors
* iOS' `WidgetDataManager.WidgetResidence` shape.
*/
@Serializable
data class WidgetResidenceDto(
val id: Long,
val name: String
)
/**
* Summary metrics computed from the cached task list.
*
@@ -14,4 +14,5 @@
android:previewLayout="@layout/widget_large_preview"
android:description="@string/widget_large_description"
android:updatePeriodMillis="1800000"
android:configure="com.tt.honeyDue.widget.WidgetConfigActivity"
android:widgetFeatures="reconfigurable" />
@@ -14,4 +14,5 @@
android:previewLayout="@layout/widget_medium_preview"
android:description="@string/widget_medium_description"
android:updatePeriodMillis="1800000"
android:configure="com.tt.honeyDue.widget.WidgetConfigActivity"
android:widgetFeatures="reconfigurable" />
@@ -14,4 +14,5 @@
android:previewLayout="@layout/widget_small_preview"
android:description="@string/widget_small_description"
android:updatePeriodMillis="1800000"
android:configure="com.tt.honeyDue.widget.WidgetConfigActivity"
android:widgetFeatures="reconfigurable" />
@@ -0,0 +1,130 @@
package com.tt.honeyDue.architecture
import java.io.File
import kotlin.test.Test
import kotlin.test.fail
/**
* Architecture regression gate.
*
* Scans `composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/` and
* asserts every ViewModel either:
* a) accepts `dataManager: IDataManager` as a constructor parameter, or
* b) is explicitly allowlisted in [WORKFLOW_ONLY_VMS] as a
* workflow/mutation-only VM.
*
* Prevents the Dec 3 2025 regression (4 VMs holding independent
* `MutableStateFlow` read-state instead of deriving from DataManager).
* See `docs/parity-gallery.md` "Known limitations" for the history.
*
* Workflow / write-only (no read-state to mirror):
* * `TaskCompletionViewModel` — single-shot create mutation
* * `OnboardingViewModel` — wizard form + per-step ApiResult
* * `PasswordResetViewModel` — wizard form + per-step ApiResult
*
* Everyone else must accept the `dataManager` ctor param.
*/
class NoIndependentViewModelStateFileScanTest {
@Test
fun every_read_state_vm_accepts_iDataManager_ctor_param() {
val vmSources = findViewModelSources()
val violations = mutableListOf<String>()
vmSources.forEach { file ->
val name = file.name
if (name in WORKFLOW_ONLY_VMS) return@forEach
val body = file.readText()
val hasCtorParam = body.contains(Regex("""dataManager:\s*IDataManager"""))
if (!hasCtorParam) {
violations.add(
"$name — expected `dataManager: IDataManager = DataManager` " +
"constructor parameter. Without this, read-state can't derive " +
"from the DataManager single source of truth and snapshot " +
"tests can't substitute a fixture. " +
"If this VM genuinely has no read-state (workflow / mutation only), " +
"add its filename to WORKFLOW_ONLY_VMS with justification.",
)
}
}
if (violations.isNotEmpty()) {
fail(
"ViewModel architecture regression (see docs/parity-gallery.md):\n" +
violations.joinToString(separator = "\n") { " - $it" },
)
}
}
@Test
fun read_state_flows_should_be_derived_not_independent() {
val vmSources = findViewModelSources()
val violations = mutableListOf<String>()
// Names of fields that track one-shot mutation/workflow feedback —
// exempt from the "must be derived" rule.
val mutationFieldPrefixes = listOf(
"create", "update", "delete", "toggle", "download",
"upload", "archive", "unarchive", "cancel", "uncancel",
"mark", "generate", "request", "submit", "login", "register",
"reset", "forgot", "verify", "apple", "google",
"join", "addNew", "addTask", "taskAddNew",
"action", "currentStep", "resetToken", "email", "selected",
// Local-only state not cached by DataManager:
"category", // NotificationPreferencesViewModel per-channel local toggles
)
vmSources.forEach { file ->
val name = file.name
if (name in WORKFLOW_ONLY_VMS) return@forEach
if (name == "AuthViewModel.kt") return@forEach // 11 one-shot states, all mutation-feedback; allowlisted as a file
val body = file.readText()
val mutableReads = Regex("""private val (_[a-zA-Z]+State)\s*=\s*MutableStateFlow""")
.findAll(body).map { it.groupValues[1] }.toList()
mutableReads.forEach { fieldName ->
val root = fieldName.removePrefix("_").removeSuffix("State")
val isMutationFeedback = mutationFieldPrefixes.any {
root.lowercase().startsWith(it.lowercase())
}
if (!isMutationFeedback) {
violations.add(
"$name — field `$fieldName` looks like cached read-state " +
"(not matching any mutation-feedback prefix). Derive it from " +
"DataManager via `dataManager.xxx.map { ... }.stateIn(...)` " +
"instead of owning a MutableStateFlow. If this field really " +
"is mutation feedback, add its name prefix to " +
"mutationFieldPrefixes in this test.",
)
}
}
}
if (violations.isNotEmpty()) {
fail(
"ViewModel state-ownership regression (see docs/parity-gallery.md):\n" +
violations.joinToString(separator = "\n") { " - $it" },
)
}
}
private fun findViewModelSources(): List<File> {
// Test cwd is `composeApp/` — resolve from the project dir.
val vmDir = File("src/commonMain/kotlin/com/tt/honeyDue/viewmodel")
check(vmDir.exists()) {
"expected VM source directory not found: ${vmDir.absolutePath} (cwd=${File(".").absolutePath})"
}
return vmDir.listFiles { f -> f.extension == "kt" }?.toList().orEmpty()
}
companion object {
/** VMs that legitimately don't need DataManager injection. */
val WORKFLOW_ONLY_VMS: Set<String> = setOf(
"TaskCompletionViewModel.kt",
"OnboardingViewModel.kt",
"PasswordResetViewModel.kt",
)
}
}
@@ -25,10 +25,14 @@ import kotlin.test.assertTrue
/**
* Unit tests for [CoilAuthInterceptor].
*
* Identity is owned by Ory Kratos. Authenticated honeyDue media is gated on
* the Kratos session token, carried on the `X-Session-Token` header (the old
* `Authorization: Token …` scheme is gone).
*
* The interceptor is responsible for:
* 1. Attaching `Authorization: <scheme> <token>` to image requests.
* 2. On HTTP 401, calling the refresh callback once and retrying the
* request with the new token.
* 1. Attaching `X-Session-Token: <token>` to image requests.
* 2. On HTTP 401, calling the re-validation callback once and retrying the
* request with the returned token.
* 3. Not looping: if the retry also returns 401, the error is returned.
* 4. When no token is available, the request proceeds unauthenticated.
*
@@ -96,7 +100,7 @@ class CoilAuthInterceptorTest {
}
@Test
fun interceptor_attaches_authorization_header_when_token_present() = runTest {
fun interceptor_attaches_session_token_header_when_token_present() = runTest {
val request = makeRequest()
val chain = FakeChain(
initialRequest = request,
@@ -105,7 +109,6 @@ class CoilAuthInterceptorTest {
val interceptor = CoilAuthInterceptor(
tokenProvider = { "abc123" },
refreshToken = { null },
authScheme = "Token",
)
val result = interceptor.intercept(chain)
@@ -113,7 +116,9 @@ class CoilAuthInterceptorTest {
assertTrue(result is SuccessResult, "Expected success result")
assertEquals(1, chain.capturedRequests.size)
val sent = chain.capturedRequests.first()
assertEquals("Token abc123", sent.httpHeaders["Authorization"])
// Token is sent bare (no scheme prefix) on the X-Session-Token header.
assertEquals("abc123", sent.httpHeaders["X-Session-Token"])
assertNull(sent.httpHeaders["Authorization"], "Legacy Authorization header must not be set")
}
@Test
@@ -126,7 +131,6 @@ class CoilAuthInterceptorTest {
val interceptor = CoilAuthInterceptor(
tokenProvider = { null },
refreshToken = { null },
authScheme = "Token",
)
val result = interceptor.intercept(chain)
@@ -134,12 +138,12 @@ class CoilAuthInterceptorTest {
assertTrue(result is SuccessResult)
assertEquals(1, chain.capturedRequests.size)
val sent = chain.capturedRequests.first()
// No Authorization header should have been added
assertNull(sent.httpHeaders["Authorization"])
// No session-token header should have been added.
assertNull(sent.httpHeaders["X-Session-Token"])
}
@Test
fun interceptor_refreshes_and_retries_on_401() = runTest {
fun interceptor_revalidates_and_retries_on_401() = runTest {
val request = makeRequest()
var refreshCallCount = 0
val chain = FakeChain(
@@ -150,25 +154,25 @@ class CoilAuthInterceptorTest {
)
)
val interceptor = CoilAuthInterceptor(
tokenProvider = { "old-token" },
tokenProvider = { "session-tok" },
refreshToken = {
refreshCallCount++
"new-token"
// Kratos session tokens are not rotated — same token echoed back.
"session-tok"
},
authScheme = "Token",
)
val result = interceptor.intercept(chain)
assertTrue(result is SuccessResult, "Expected retry to succeed")
assertEquals(1, refreshCallCount, "refreshToken should be invoked exactly once")
assertEquals(1, refreshCallCount, "session re-check should be invoked exactly once")
assertEquals(2, chain.capturedRequests.size, "Expected original + 1 retry")
assertEquals("Token old-token", chain.capturedRequests[0].httpHeaders["Authorization"])
assertEquals("Token new-token", chain.capturedRequests[1].httpHeaders["Authorization"])
assertEquals("session-tok", chain.capturedRequests[0].httpHeaders["X-Session-Token"])
assertEquals("session-tok", chain.capturedRequests[1].httpHeaders["X-Session-Token"])
}
@Test
fun interceptor_returns_error_when_refresh_returns_null() = runTest {
fun interceptor_returns_error_when_revalidation_returns_null() = runTest {
val request = makeRequest()
var refreshCallCount = 0
val chain = FakeChain(
@@ -176,19 +180,18 @@ class CoilAuthInterceptorTest {
responses = mutableListOf({ req -> make401Error(req) })
)
val interceptor = CoilAuthInterceptor(
tokenProvider = { "old-token" },
tokenProvider = { "session-tok" },
refreshToken = {
refreshCallCount++
null
},
authScheme = "Token",
)
val result = interceptor.intercept(chain)
assertTrue(result is ErrorResult, "Expected error result when refresh fails")
assertEquals(1, refreshCallCount, "refreshToken should be attempted once")
// Only the first attempt should have gone through
assertTrue(result is ErrorResult, "Expected error result when session is gone")
assertEquals(1, refreshCallCount, "session re-check should be attempted once")
// Only the first attempt should have gone through.
assertEquals(1, chain.capturedRequests.size)
}
@@ -204,23 +207,22 @@ class CoilAuthInterceptorTest {
)
)
val interceptor = CoilAuthInterceptor(
tokenProvider = { "old-token" },
tokenProvider = { "session-tok" },
refreshToken = {
refreshCallCount++
"new-token"
"session-tok"
},
authScheme = "Token",
)
val result = interceptor.intercept(chain)
assertTrue(result is ErrorResult, "Second 401 should surface as ErrorResult")
assertEquals(1, refreshCallCount, "refreshToken should be called exactly once — no infinite loop")
assertEquals(1, refreshCallCount, "session re-check should be called exactly once — no infinite loop")
assertEquals(2, chain.capturedRequests.size, "Expected original + exactly one retry")
}
@Test
fun interceptor_passes_through_non_401_errors_without_refresh() = runTest {
fun interceptor_passes_through_non_401_errors_without_revalidation() = runTest {
val request = makeRequest()
var refreshCallCount = 0
val chain = FakeChain(
@@ -241,33 +243,32 @@ class CoilAuthInterceptorTest {
refreshCallCount++
"should-not-be-called"
},
authScheme = "Token",
)
val result = interceptor.intercept(chain)
assertTrue(result is ErrorResult)
assertEquals(0, refreshCallCount, "refreshToken should not be invoked on non-401 errors")
assertEquals(0, refreshCallCount, "session re-check should not be invoked on non-401 errors")
assertEquals(1, chain.capturedRequests.size)
}
@Test
fun interceptor_supports_bearer_scheme() = runTest {
fun interceptor_supports_custom_header_name() = runTest {
val request = makeRequest()
val chain = FakeChain(
initialRequest = request,
responses = mutableListOf({ req -> makeSuccess(req) })
)
val interceptor = CoilAuthInterceptor(
tokenProvider = { "jwt.payload.sig" },
tokenProvider = { "tok-value" },
refreshToken = { null },
authScheme = "Bearer",
headerName = "X-Custom-Auth",
)
val result = interceptor.intercept(chain)
assertTrue(result is SuccessResult)
val sent = chain.capturedRequests.first()
assertEquals("Bearer jwt.payload.sig", sent.httpHeaders["Authorization"])
assertEquals("tok-value", sent.httpHeaders["X-Custom-Auth"])
}
}
@@ -0,0 +1,68 @@
package com.tt.honeyDue.network
import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.models.RegisterRequest
import com.tt.honeyDue.models.VerifyEmailRequest
import kotlinx.coroutines.runBlocking
import org.junit.Assume.assumeTrue
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* LIVE end-to-end integration test: drives the REAL [AuthApi] (the exact client
* code the app runs, OkHttp engine, production URLs from ApiConfig) against the
* live honeyDue API + Ory Kratos. No fixtures, no hand-written JSON — every
* response is decoded by the real models over the wire, so a contract mismatch
* between the API and the KMP client fails here.
*
* Skipped unless RUN_LIVE_IT=1 (it creates a real throwaway account and needs
* network), so it never runs on a normal build/CI. Run explicitly:
* RUN_LIVE_IT=1 ./gradlew :composeApp:testDebugUnitTest \
* --tests "com.tt.honeyDue.network.LiveAuthIntegrationTest"
*/
class LiveAuthIntegrationTest {
private fun liveEnabled() = System.getenv("RUN_LIVE_IT") == "1"
@Test
fun passwordSignupFlowAgainstProd() = runBlocking {
assumeTrue("set RUN_LIVE_IT=1 to run the live integration test", liveEnabled())
val api = AuthApi() // ApiClient.httpClient (OkHttp) + PROD URLs
val email = "kmpqa-" + System.currentTimeMillis() + "@example.com"
val password = "KmpQa1!Test99"
// 1. Register: admin-create via /api/auth/register/ then immediate login.
// Exercises the createAccount HTTP call AND the login decode that
// crashed on "continue_with": null.
val reg = api.register(
RegisterRequest(username = "kmpqa", email = email, password = password, firstName = "Kmp", lastName = "Qa"),
)
assertTrue(reg is ApiResult.Success, "register should succeed, got: $reg")
val auth = (reg as ApiResult.Success).data
assertTrue(auth.token.isNotBlank(), "session token must be present")
assertEquals(email, auth.user.email)
assertFalse(auth.user.verified, "a fresh password account must start unverified")
// 2. /auth/me decodes through the ApiClient json config (User model).
val me = api.getCurrentUser(auth.token)
assertTrue(me is ApiResult.Success, "auth/me should succeed, got: $me")
assertEquals(email, (me as ApiResult.Success).data.email)
// 3. Start the client-owned verification flow — decodes the flow body and
// stores the flow id; sends the single code.
val started = api.startEmailVerification(email)
assertTrue(started is ApiResult.Success, "startEmailVerification should succeed, got: $started")
assertNotNull(DataManager.pendingVerificationFlowId.value, "a verification flow id must be stored")
// 4. Submit a deliberately wrong code: must decode the re-rendered flow
// and return a clean Error (not a parse crash).
val wrong = api.verifyEmail(auth.token, VerifyEmailRequest(code = "000000"))
assertTrue(wrong is ApiResult.Error, "wrong code must return Error, got: $wrong")
DataManager.setPendingVerificationFlowId(null)
}
}
@@ -0,0 +1,68 @@
package com.tt.honeyDue.screenshot
import com.tt.honeyDue.testing.GalleryScreens
import com.tt.honeyDue.testing.Platform
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Parity gate — asserts [gallerySurfaces] is exactly the set of screens
* declared in [GalleryScreens] with [Platform.ANDROID] in their platforms.
*
* If this fails, either:
* - A new screen was added to [gallerySurfaces] but missing from the
* canonical manifest — update [GalleryScreens.all].
* - A new screen was added to the manifest but not wired into
* [gallerySurfaces] — add the corresponding `GallerySurface(...)`
* entry.
* - A rename landed on only one side — reconcile.
*
* This keeps Android and iOS from silently drifting apart in coverage.
* The iOS equivalent (`GalleryManifestParityTest.swift`) enforces the
* same invariant on the Swift test file.
*/
class GalleryManifestParityTest {
@Test
fun android_surfaces_match_manifest_exactly() {
val actual = gallerySurfaces.map { it.name }.toSet()
val expected = GalleryScreens.forAndroid.keys
val missing = expected - actual
val extra = actual - expected
if (missing.isNotEmpty() || extra.isNotEmpty()) {
val message = buildString {
appendLine("Android GallerySurfaces drifted from canonical manifest.")
if (missing.isNotEmpty()) {
appendLine()
appendLine("Screens in manifest but missing from GallerySurfaces.kt:")
missing.sorted().forEach { appendLine(" - $it") }
}
if (extra.isNotEmpty()) {
appendLine()
appendLine("Screens in GallerySurfaces.kt but missing from manifest:")
extra.sorted().forEach { appendLine(" - $it") }
}
appendLine()
appendLine("Reconcile by editing com.tt.honeyDue.testing.GalleryScreens and/or")
appendLine("com.tt.honeyDue.screenshot.GallerySurfaces so both agree.")
}
kotlin.test.fail(message)
}
assertEquals(expected, actual)
}
@Test
fun no_duplicate_surface_names() {
val duplicates = gallerySurfaces.map { it.name }
.groupingBy { it }
.eachCount()
.filterValues { it > 1 }
assertTrue(
duplicates.isEmpty(),
"Duplicate surface names in GallerySurfaces.kt: $duplicates",
)
}
}
@@ -2,6 +2,7 @@
package com.tt.honeyDue.screenshot
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.tt.honeyDue.testing.Fixtures
import com.tt.honeyDue.ui.screens.AddDocumentScreen
import com.tt.honeyDue.ui.screens.AddResidenceScreen
@@ -25,11 +26,10 @@ import com.tt.honeyDue.ui.screens.RegisterScreen
import com.tt.honeyDue.ui.screens.ResetPasswordScreen
import com.tt.honeyDue.ui.screens.ResidenceDetailScreen
import com.tt.honeyDue.ui.screens.ResidencesScreen
import com.tt.honeyDue.ui.screens.TasksScreen
import com.tt.honeyDue.ui.screens.VerifyEmailScreen
import com.tt.honeyDue.ui.screens.VerifyResetCodeScreen
import com.tt.honeyDue.ui.screens.dev.AnimationTestingScreen
import com.tt.honeyDue.ui.screens.onboarding.OnboardingCreateAccountContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingFirstTaskContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingHomeProfileContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingJoinResidenceContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingLocationContent
@@ -40,34 +40,44 @@ import com.tt.honeyDue.ui.screens.onboarding.OnboardingVerifyEmailContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingWelcomeContent
import com.tt.honeyDue.ui.screens.residence.JoinResidenceScreen
import com.tt.honeyDue.ui.screens.subscription.FeatureComparisonScreen
import com.tt.honeyDue.ui.screens.task.AddTaskWithResidenceScreen
import com.tt.honeyDue.ui.screens.task.TaskSuggestionsScreen
import com.tt.honeyDue.ui.screens.task.TaskTemplatesBrowserScreen
import com.tt.honeyDue.ui.screens.theme.ThemeSelectionScreen
import com.tt.honeyDue.viewmodel.ContractorViewModel
import com.tt.honeyDue.viewmodel.DocumentViewModel
import com.tt.honeyDue.viewmodel.OnboardingViewModel
import com.tt.honeyDue.viewmodel.PasswordResetViewModel
/**
* Declarative manifest of every primary screen in the app that the parity
* gallery captures. Each entry renders the production composable directly
* the screen reads its data from [com.tt.honeyDue.data.LocalDataManager],
* which the capture driver overrides with a [com.tt.honeyDue.testing.FixtureDataManager]
* (empty or populated) per variant.
* Declarative manifest of every Android gallery surface. Must stay in sync
* with the canonical [com.tt.honeyDue.testing.GalleryScreens] manifest
* [GalleryManifestParityTest] fails CI if the two drift.
*
* Scope: the screens users land on. We deliberately skip:
* - dialogs that live inside a host screen (already captured on the host),
* - animation sub-views / decorative components in AnimationTesting/,
* - widget views (Android Glance / iOS WidgetKit — separate surface),
* - shared helper composables listed under `category: shared` in
* docs/ios-parity/screens.json (loaders, error rows, thumbnails — they
* - shared helper composables (loaders, error rows, thumbnails — they
* only appear as part of a parent screen).
*
* Screens that require a construction-time ViewModel (`OnboardingViewModel`,
* `PasswordResetViewModel`) instantiate it inline here. The production code
* paths start the viewmodel's own `launch { APILayer.xxx() }` on first
* composition — those calls fail fast in the hermetic Robolectric
* environment, but the composition itself renders the surface from the
* injected [com.tt.honeyDue.data.LocalDataManager] before any network
* result arrives, which is exactly what we want to compare against iOS.
* Detail-VM pattern (contractor_detail, document_detail, edit_document):
* the VM is created with the fixture id already pre-selected, so
* `stateIn(SharingStarted.Eagerly, initialValue = dataManager.x[id])`
* emits `Success(entity)` on first composition. Without this pre-select,
* the screens' own `LaunchedEffect(id) { vm.loadX(id) }` dispatches the id
* assignment to a coroutine that runs *after* Roborazzi captures the
* frame — so both empty and populated captures would render the `Idle`
* state and be byte-identical.
*
* Screens that require a construction-time ViewModel
* ([OnboardingViewModel], [PasswordResetViewModel]) instantiate it inline
* here. The production code paths start the viewmodel's own
* `launch { APILayer.xxx() }` on first composition — those calls fail fast
* in the hermetic Robolectric environment, but the composition itself
* renders the surface from the injected
* [com.tt.honeyDue.data.LocalDataManager] before any network result
* arrives, which is exactly what we want to compare against iOS.
*/
data class GallerySurface(
/** Snake-case identifier; used as the golden file-name prefix. */
@@ -177,6 +187,12 @@ val gallerySurfaces: List<GallerySurface> = listOf(
onJoined = {},
)
},
GallerySurface("onboarding_first_task") {
OnboardingFirstTaskContent(
viewModel = OnboardingViewModel(),
onTasksAdded = {},
)
},
GallerySurface("onboarding_subscription") {
OnboardingSubscriptionContent(
onSubscribe = {},
@@ -184,7 +200,7 @@ val gallerySurfaces: List<GallerySurface> = listOf(
)
},
// ---------- Home / main navigation ----------
// ---------- Home (Android-only dashboard) ----------
GallerySurface("home") {
HomeScreen(
onNavigateToResidences = {},
@@ -240,12 +256,16 @@ val gallerySurfaces: List<GallerySurface> = listOf(
},
// ---------- Tasks ----------
GallerySurface("tasks") {
TasksScreen(onNavigateBack = {})
},
GallerySurface("all_tasks") {
AllTasksScreen(onNavigateToEditTask = {})
},
GallerySurface("add_task_with_residence") {
AddTaskWithResidenceScreen(
residenceId = Fixtures.primaryHome.id,
onNavigateBack = {},
onCreated = {},
)
},
GallerySurface("edit_task") {
EditTaskScreen(
task = Fixtures.tasks.first(),
@@ -285,9 +305,20 @@ val gallerySurfaces: List<GallerySurface> = listOf(
)
},
GallerySurface("contractor_detail") {
val id = Fixtures.contractors.first().id
// Pass `initialSelectedContractorId` at VM construction so the
// synchronous `stateIn` initial-value closure observes both the
// id AND the fixture-seeded `dataManager.contractorDetail[id]`,
// emitting `Success(contractor)` on first composition. Without
// this the screen's own `LaunchedEffect(id) { vm.loadContractorDetail(id) }`
// dispatches the id assignment to a coroutine that runs after
// the frame is captured, leaving both empty and populated
// captures byte-identical on the `Idle` branch.
val vm = remember { ContractorViewModel(initialSelectedContractorId = id) }
ContractorDetailScreen(
contractorId = Fixtures.contractors.first().id,
contractorId = id,
onNavigateBack = {},
viewModel = vm,
)
},
@@ -299,10 +330,13 @@ val gallerySurfaces: List<GallerySurface> = listOf(
)
},
GallerySurface("document_detail") {
val id = Fixtures.documents.first().id ?: 0
val vm = remember { DocumentViewModel(initialSelectedDocumentId = id) }
DocumentDetailScreen(
documentId = Fixtures.documents.first().id ?: 0,
documentId = id,
onNavigateBack = {},
onNavigateToEdit = {},
onNavigateToEdit = { _ -> },
documentViewModel = vm,
)
},
GallerySurface("add_document") {
@@ -313,9 +347,12 @@ val gallerySurfaces: List<GallerySurface> = listOf(
)
},
GallerySurface("edit_document") {
val id = Fixtures.documents.first().id ?: 0
val vm = remember { DocumentViewModel(initialSelectedDocumentId = id) }
EditDocumentScreen(
documentId = Fixtures.documents.first().id ?: 0,
documentId = id,
onNavigateBack = {},
documentViewModel = vm,
)
},
@@ -326,14 +363,11 @@ val gallerySurfaces: List<GallerySurface> = listOf(
onLogout = {},
)
},
GallerySurface("theme_selection") {
ThemeSelectionScreen(onNavigateBack = {})
},
GallerySurface("notification_preferences") {
NotificationPreferencesScreen(onNavigateBack = {})
},
GallerySurface("animation_testing") {
AnimationTestingScreen(onNavigateBack = {})
GallerySurface("theme_selection") {
ThemeSelectionScreen(onNavigateBack = {})
},
GallerySurface("biometric_lock") {
BiometricLockScreen(onUnlocked = {})
@@ -6,54 +6,60 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.test.core.app.ApplicationProvider
import com.github.takahirom.roborazzi.captureRoboImage
import com.tt.honeyDue.data.IDataManager
import com.tt.honeyDue.data.LocalDataManager
import com.tt.honeyDue.testing.FixtureDataManager
import com.tt.honeyDue.testing.GalleryCategory
import com.tt.honeyDue.testing.GalleryScreens
import com.tt.honeyDue.ui.theme.AppThemes
import com.tt.honeyDue.ui.theme.HoneyDueTheme
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Parity-gallery Roborazzi snapshot tests (P2).
* Parity-gallery Roborazzi snapshot tests.
*
* For every entry in [gallerySurfaces] we capture four variants:
* empty × light, empty × dark, populated × light, populated × dark
* Variant matrix (driven by [GalleryCategory] in the canonical
* [GalleryScreens] manifest):
*
* Per surface that's 4 PNGs × ~40 surfaces ≈ 160 goldens. Paired with the
* iOS swift-snapshot-testing gallery (P3) that captures the same set of
* (screen, data, theme) tuples, any visual divergence between the two
* platforms surfaces here as a golden diff rather than silently shipping.
* DataCarrying surfaces — capture 4 variants:
* surface_empty_light.png (empty fixture, no lookups, light)
* surface_empty_dark.png (empty fixture, no lookups, dark)
* surface_populated_light.png (populated fixture, light)
* surface_populated_dark.png (populated fixture, dark)
*
* How this differs from the showcase tests that lived here before:
* - Showcases rendered hand-crafted theme-agnostic surfaces; now we
* render the actual production composables (`LoginScreen(…)`, etc.)
* through the fixture-backed [LocalDataManager].
* - Surfaces are declared in [GallerySurfaces.kt] instead of being
* inlined, so adding a new screen is a one-line change.
* - Previously 6 surfaces × 3 themes × 2 modes; now the matrix is
* N surfaces × {empty, populated} × {light, dark} — themes beyond
* the default are intentionally out of scope (theme variation is
* covered by the dedicated theme_selection surface).
* DataFree surfaces — capture 2 variants:
* surface_light.png (empty fixture, lookups seeded, light)
* surface_dark.png (empty fixture, lookups seeded, dark)
*
* One parameterized test per surface gives granular CI failures — the
* The `empty` fixture for DataCarrying variants passes
* `seedLookups = false` so form dropdowns render their empty state
* (yielding a visible populated-vs-empty diff for forms that read
* lookups from `DataManager`). The `empty` fixture for DataFree
* variants passes `seedLookups = true` because those screens expect
* realistic production lookups even when the user has no entities yet.
*
* DataFree surfaces omit the populated variant entirely — the screens
* render no entity data, so `populated` would be byte-identical to
* `empty` and add zero signal.
*
* Granular CI failures: one parameterized test per surface means the
* report shows `ScreenshotTests[login]`, `ScreenshotTests[tasks]`, etc.
* rather than one monolithic failure when any surface drifts.
*
* Why the goldens land directly under `src/androidUnitTest/roborazzi/`:
* Roborazzi resolves `captureRoboImage(filePath = …)` relative to the
* Gradle test task's working directory (the module root). Writing to
* the same directory where goldens are committed means record and verify
* round-trip through one canonical location; we never have to copy
* between a transient `build/outputs/roborazzi/` and the committed
* fixture directory (which was the source of the pre-existing
* "original file was not found" failure).
* the same directory where goldens are committed means record and
* verify round-trip through one canonical location.
*/
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@@ -64,24 +70,15 @@ class ScreenshotTests(
/**
* Compose Multiplatform's `stringResource()` loads text via a
* JVM-static context held by `AndroidContextProvider`. In a real APK
* that ContentProvider is registered in the manifest and populated at
* app start; under Robolectric unit tests it never runs, so every
* `stringResource(...)` call throws "Android context is not
* initialized."
*
* `PreviewContextConfigurationEffect()` is the documented fix — but
* it only fires inside `LocalInspectionMode = true`, and even then
* the first composition frame renders before the effect lands, so
* `stringResource()` calls race the context set.
* JVM-static context held by `AndroidContextProvider`. Under
* Robolectric unit tests the `ContentProvider` that normally
* populates it never runs, so every `stringResource(...)` call throws
* "Android context is not initialized."
*
* Install the context eagerly via reflection before each test.
* `AndroidContextProvider` is `internal` in Kotlin, so we can't
* touch its class directly — but its static slot is writable
* through the generated `Companion.setANDROID_CONTEXT` accessor.
* `@Before` runs inside the Robolectric sandbox (where
* `ApplicationProvider` is valid); `@BeforeClass` would run outside
* it and fail with "No instrumentation registered!".
* `AndroidContextProvider` is `internal`, but its static slot is
* writable through the generated `Companion.setANDROID_CONTEXT`
* accessor.
*/
@Before
fun bootstrapComposeResources() {
@@ -95,17 +92,67 @@ class ScreenshotTests(
@Test
fun captureAllVariants() {
Variant.all().forEach { variant ->
val fileName = "${surface.name}_${variant.state}_${variant.mode}.png"
val screen = GalleryScreens.forAndroid[surface.name]
?: error(
"Surface '${surface.name}' is in GallerySurfaces.kt but not in " +
"GalleryScreens.all (canonical manifest). " +
"GalleryManifestParityTest should have caught this.",
)
val variants = when (screen.category) {
GalleryCategory.DataCarrying -> Variant.dataCarrying
GalleryCategory.DataFree -> Variant.dataFree
}
variants.forEach { variant ->
val fileName = "${surface.name}${variant.fileSuffix}.png"
val fixture = variant.dataManager()
// Seed the global DataManager singleton from the fixture. Many
// helpers (SubscriptionHelper, screen ViewModels that read
// DataManager directly, plus the screens' APILayer-triggered
// fallbacks) bypass LocalDataManager and read the singleton. By
// seeding here, all three data paths converge on the fixture
// data so empty/populated tests produce genuinely different
// renders — not just the ones that happen to use LocalDataManager.
seedSingleton(fixture)
// Flush the main-thread Looper so any `stateIn(... Eagerly)`
// collectors on VMs reused across captures have processed the
// DataManager update before we snapshot. Without this, VMs
// might see the previous variant's data because coroutine
// emissions race the capture call.
shadowOf(android.os.Looper.getMainLooper()).idle()
captureRoboImage(filePath = "src/androidUnitTest/roborazzi/$fileName") {
HoneyDueTheme(
themeColors = AppThemes.Default,
darkTheme = variant.darkTheme,
) {
// `LocalInspectionMode = true` signals to production
// composables that they're rendering in a hermetic
// preview/test environment. Camera pickers, gated push
// registrations, and animation callbacks use this flag
// to short-circuit calls that require real Android
// subsystems (e.g. `FileProvider` paths that aren't
// resolvable under Robolectric's test data dir).
CompositionLocalProvider(
LocalDataManager provides fixture,
LocalInspectionMode provides true,
) {
Box(Modifier.fillMaxSize()) {
surface.content()
}
}
}
}
}
// Reset after suite so other tests don't inherit state.
com.tt.honeyDue.data.DataManager.setSubscription(null)
}
/**
* Mirror every StateFlow on `fixture` onto the `DataManager` singleton
* so code paths that bypass `LocalDataManager` (screens that call
* `DataManager.x` directly, VMs whose default-arg resolves to the
* singleton, `SubscriptionHelper` free-tier gate) see the same data.
*
* Critical: clear the singleton first so the previous variant's
* writes don't leak into this variant's `empty` render.
*/
private fun seedSingleton(fixture: IDataManager) {
val dm = com.tt.honeyDue.data.DataManager
dm.clear()
dm.setSubscription(fixture.subscription.value)
dm.setCurrentUser(fixture.currentUser.value)
fixture.myResidences.value?.let { dm.setMyResidences(it) }
@@ -119,21 +166,18 @@ class ScreenshotTests(
dm.setTaskCategories(fixture.taskCategories.value)
dm.setTaskPriorities(fixture.taskPriorities.value)
dm.setTaskFrequencies(fixture.taskFrequencies.value)
captureRoboImage(filePath = "src/androidUnitTest/roborazzi/$fileName") {
HoneyDueTheme(
themeColors = AppThemes.Default,
darkTheme = variant.darkTheme,
) {
CompositionLocalProvider(LocalDataManager provides fixture) {
Box(Modifier.fillMaxSize()) {
surface.content()
fixture.contractorsByResidence.value.forEach { (rid, list) ->
dm.setContractorsForResidence(rid, list)
}
fixture.contractorDetail.value.values.forEach { dm.setContractorDetail(it) }
fixture.documentDetail.value.values.forEach { dm.setDocumentDetail(it) }
fixture.taskCompletions.value.forEach { (taskId, completions) ->
dm.setTaskCompletions(taskId, completions)
}
fixture.tasksByResidence.value.forEach { (rid, cols) ->
dm.setTasksForResidence(rid, cols)
}
}
}
// Reset after suite so other tests don't inherit state.
com.tt.honeyDue.data.DataManager.setSubscription(null)
fixture.notificationPreferences.value?.let { dm.setNotificationPreferences(it) }
}
companion object {
@@ -145,23 +189,48 @@ class ScreenshotTests(
}
/**
* One of the four render-variants captured per surface. The
* `dataManager` factory is invoked lazily so each capture gets its own
* pristine fixture (avoiding cross-test StateFlow mutation).
* One render-variant captured per surface. The `dataManager` factory is
* invoked lazily so each capture gets a pristine fixture (avoiding
* cross-test StateFlow mutation).
*
* @property fileSuffix Appended to the surface name to form the PNG
* filename. Includes a leading `_`. Examples: `_empty_light`,
* `_populated_dark`, `_light`, `_dark`.
*/
private data class Variant(
val state: String,
val mode: String,
val fileSuffix: String,
val darkTheme: Boolean,
val dataManager: () -> IDataManager,
) {
companion object {
fun all(): List<Variant> = listOf(
Variant("empty", "light", darkTheme = false) { FixtureDataManager.empty() },
Variant("empty", "dark", darkTheme = true) { FixtureDataManager.empty() },
Variant("populated", "light", darkTheme = false) { FixtureDataManager.populated() },
Variant("populated", "dark", darkTheme = true) { FixtureDataManager.populated() },
/**
* DataCarrying surfaces: 4 variants. `empty` captures pass
* `seedLookups = false` so form dropdowns render empty in the
* empty-variant PNGs — letting screens that read lookups produce
* a visible diff against the populated variant.
*/
val dataCarrying: List<Variant> = listOf(
Variant("_empty_light", darkTheme = false) {
FixtureDataManager.empty(seedLookups = false)
},
Variant("_empty_dark", darkTheme = true) {
FixtureDataManager.empty(seedLookups = false)
},
Variant("_populated_light", darkTheme = false) { FixtureDataManager.populated() },
Variant("_populated_dark", darkTheme = true) { FixtureDataManager.populated() },
)
/**
* DataFree surfaces: 2 variants (light/dark only). Lookups are
* seeded because forms expect them to be present in production
* (a user with zero entities still sees the priority picker).
* The populated variant is deliberately omitted — DataFree
* surfaces render no entity data, so `populated` would be
* byte-identical to `empty`.
*/
val dataFree: List<Variant> = listOf(
Variant("_light", darkTheme = false) { FixtureDataManager.empty(seedLookups = true) },
Variant("_dark", darkTheme = true) { FixtureDataManager.empty(seedLookups = true) },
)
}
}
@@ -0,0 +1,140 @@
package com.tt.honeyDue.widget
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
/**
* Coverage for the per-residence widget filter added in gitea#6.
*
* Two surfaces under test:
*
* 1. `WidgetDataRepository.filterTasksForResidence` — the pure filter
* used by `loadTasksForResidence` and (transitively) by the
* timeline provider. Mirrors iOS' `WidgetDataManager.filterTasks`.
* 2. The per-`appWidgetId` DataStore key — verifies round-tripping
* a saved residence id and clearing it for a removed widget.
*/
@RunWith(RobolectricTestRunner::class)
class WidgetResidenceFilterTest {
private lateinit var context: Context
private lateinit var repo: WidgetDataRepository
@Before
fun setUp() = runTest {
context = ApplicationProvider.getApplicationContext()
repo = WidgetDataRepository.get(context)
repo.clearAll()
}
@After
fun tearDown() = runTest {
repo.clearAll()
}
private fun task(id: Long, residenceId: Long): WidgetTaskDto =
WidgetTaskDto(
id = id,
title = "Task $id",
priority = 0,
dueDate = null,
isOverdue = false,
daysUntilDue = 0,
residenceId = residenceId,
residenceName = "",
categoryIcon = "",
completed = false
)
// --- pure filter -------------------------------------------------------
@Test
fun filter_nullResidenceReturnsAllTasks() {
val tasks = listOf(task(1, 10), task(2, 20), task(3, 30))
val result = WidgetDataRepository.filterTasksForResidence(tasks, residenceId = null)
assertEquals(listOf(1L, 2L, 3L), result.map { it.id })
}
@Test
fun filter_matchingResidenceKeepsOnlyMatchingTasks() {
val tasks = listOf(task(1, 10), task(2, 20), task(3, 10), task(4, 30))
val result = WidgetDataRepository.filterTasksForResidence(tasks, residenceId = 10)
assertEquals(listOf(1L, 3L), result.map { it.id })
}
@Test
fun filter_unknownResidenceReturnsEmpty() {
val tasks = listOf(task(1, 10), task(2, 20))
val result = WidgetDataRepository.filterTasksForResidence(tasks, residenceId = 999)
assertTrue(result.isEmpty())
}
@Test
fun filter_preservesInputOrder() {
// Subset only — the timeline provider relies on this so its
// own sort step ("overdue first, then by due date") operates
// on already-filtered tasks.
val tasks = listOf(task(5, 1), task(3, 1), task(7, 1), task(1, 2))
val result = WidgetDataRepository.filterTasksForResidence(tasks, residenceId = 1)
assertEquals(listOf(5L, 3L, 7L), result.map { it.id })
}
// --- DataStore round-trip ---------------------------------------------
@Test
fun perWidgetResidenceId_roundTripsThroughDataStore() = runTest {
// Initial state: no scope persisted → returns null ("All residences").
assertNull(repo.loadResidenceIdFor(appWidgetId = 42))
repo.saveResidenceIdFor(appWidgetId = 42, residenceId = 7L)
assertEquals(7L, repo.loadResidenceIdFor(appWidgetId = 42))
// Different widget id stays unscoped — keys are per-instance.
assertNull(repo.loadResidenceIdFor(appWidgetId = 99))
// Save null clears the scope ("All residences" selected after a
// previously residence-scoped tile).
repo.saveResidenceIdFor(appWidgetId = 42, residenceId = null)
assertNull(repo.loadResidenceIdFor(appWidgetId = 42))
}
@Test
fun loadTasksForWidget_appliesPerInstanceScope() = runTest {
repo.saveTasks(listOf(task(1, 10), task(2, 20), task(3, 10)))
repo.saveResidenceIdFor(appWidgetId = 1, residenceId = 10L)
repo.saveResidenceIdFor(appWidgetId = 2, residenceId = 20L)
assertEquals(listOf(1L, 3L), repo.loadTasksForWidget(1).map { it.id })
assertEquals(listOf(2L), repo.loadTasksForWidget(2).map { it.id })
// Unconfigured tile defaults to "All residences" — every task.
assertEquals(listOf(1L, 2L, 3L), repo.loadTasksForWidget(3).map { it.id })
}
@Test
fun clearResidenceIdFor_dropsScope() = runTest {
repo.saveResidenceIdFor(appWidgetId = 5, residenceId = 11L)
assertEquals(11L, repo.loadResidenceIdFor(5))
repo.clearResidenceIdFor(5)
assertNull(repo.loadResidenceIdFor(5))
}
@Test
fun saveResidences_roundTripsResidenceList() = runTest {
val payload = listOf(
WidgetResidenceDto(id = 1, name = "Home"),
WidgetResidenceDto(id = 2, name = "Cabin")
)
repo.saveResidences(payload)
assertEquals(payload, repo.loadResidences())
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

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