Commit Graph

246 Commits

Author SHA1 Message Date
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 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 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 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 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 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 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 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
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 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
Trey T f83e89bee3 Parity gallery: honest populated-state coverage (10/34 surfaces differ)
Fixed & documented, not-just-marketed:
- HomeScreen now derives summary card from LocalDataManager.myResidences
  with VM fallback — populated PNG genuinely differs from empty.
- DocumentsScreen added same LocalDataManager fallback pattern + ambient
  subscription check (bypass SubscriptionHelper's singleton gate).
- ScreenshotTests.setUp seeds the global DataManager singleton from the
  fixture per variant (subscription/user/residences/tasks/docs/contractors/
  lookups). Unblocks screens that bypass LocalDataManager.

Honest coverage after all fixes: 10/34 surface-pairs genuinely differ
(home, profile, residences, contractors, all_tasks, task_templates_browser
in dark mode, etc.). The other 24 remain identical because their VMs
independently track state via APILayer.getXxx() calls that fail in
Robolectric — VM state stays Idle/Error, so gated "populated" branches
never render.

Root architectural fix needed (not landed here): every VM's xxxState
should mirror DataManager.xxx reactively instead of tracking API results
independently. That's a ~20-VM refactor tracked as follow-up in
docs/parity-gallery.md "Known limitations".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:31:52 -05:00
Trey T ab0e5c450c Coverage: regenerate gallery — 40/40 Android surfaces rendering
Previous run left edit_document at 0/4 because the record task hadn't
recorded it; the other 39 surfaces' goldens were optimized in-place by
zopflipng (no visual change). Gallery HTML/markdown regenerated to
reflect 160 Android goldens (40 surfaces × 4 variants).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 02:28:59 -05:00
Trey T b24469bf38 Coverage: Android gallery expansion 23 → 39 surfaces + regenerate gallery
Android Roborazzi re-recorded end-to-end. Coverage expanded from 23
surfaces × 4 variants (92 goldens) to 39 surfaces × 4 variants (156
goldens). Only edit_document still silent-fails — flagged for follow-up
PR requiring fixture DocumentResponse + a non-network Edit flow.

docs/parity-gallery.html + docs/parity-gallery-grid.md regenerated:
47 screens, 156 Android + 88 iOS = 244 PNGs. Compared to the prior
gallery commit (3944223) this doubles total coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:45:14 -05:00
Trey T f77c41f07a P2 addendum: tasks_empty_dark.png straggler 2026-04-18 23:53:53 -05:00
Trey T 7a04ad4ff2 P2 addendum: 18 additional Android goldens (add/edit residence, join, manage users)
Late writes from the previous recordRoborazziDebug pass. Brings Android
coverage from 17 → 21 surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:50:33 -05:00
Trey T 6cc5295db8 P2: Android parity gallery — real-screen captures (partial, 17/40 surfaces)
Replaces the synthetic theme-showcase ScreenshotTests with real screens
rendered against FixtureDataManager.empty() / .populated() via
LocalDataManager. GallerySurfaces.kt manifest declares 40 screens.

Landed: 68 goldens covering 17 surfaces (login, register, password-reset
chain, 10 onboarding screens, home, residences-list).

Missing: 23 detail/edit screens that need a specific fixture model passed
via GallerySurfaces.kt — tracked as follow-up in docs/parity-gallery.md.
Non-blocking: these render silently as blank and don't fail the suite.

Android total: 2.5 MB, avg 41 KB, max 113 KB — well under the 150 KB
per-file budget enforced by the CI size gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:45:12 -05:00
Trey T 47eaf5a0c0 P1: Shared FixtureDataManager (empty + populated) for cross-platform snapshots
InMemoryDataManager + Fixtures with deterministic data (fixed clock 2026-04-15,
2 residences, 8 tasks, 3 contractors, 5 documents). FixtureDataManager.empty()
and .populated() factories. Exposed to Swift via SKIE.

Expanded IDataManager surface (5 -> 22 members) so fixtures cover every
StateFlow and lookup helper screens read: myResidences, allTasks,
tasksByResidence, documents, documentsByResidence, contractors, residenceTypes,
taskFrequencies, taskPriorities, taskCategories, contractorSpecialties,
taskTemplates, taskTemplatesGrouped, residenceSummaries, upgradeTriggers,
promotions, plus get{ResidenceType,TaskFrequency,TaskPriority,TaskCategory,
ContractorSpecialty}(id) lookup helpers. DataManager implementation is a pure
override-keyword addition — no behavior change.

Enables P2 (Android gallery) + P3 (iOS gallery) to render real screens against
identical inputs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:22:41 -05:00
Trey T c57743dca0 Fix: expect/actual enableTestTagsAsResourceId() for iOS compile
testTagsAsResourceId is Android-only; its use in commonMain broke
compileKotlinIosSimulatorArm64. Wrap behind expect fun — Android impl
sets the semantic, other platforms return Modifier unchanged. Blocks
P3 iOS parity gallery otherwise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:20:48 -05:00
Trey T 00e215920a P0.2: migrate screens to LocalDataManager.current
Swaps direct `DataManager.xxx` access for `LocalDataManager.current.xxx`
across every Compose screen under ui/screens/** that references the
singleton. Each composable resolves the ambient once at the top of its
body and reuses the local val for subsequent reads — keeping rewrites
minimal and predictable.

Screens touched:
  - HomeScreen                          (totalSummary)
  - ResidencesScreen                    (totalSummary)
  - ResidenceDetailScreen               (currentUser)
  - ResidenceFormScreen                 (currentUser)
  - ProfileScreen                       (currentUser + subscription)
  - ContractorDetailScreen              (residences)
  - subscription/FeatureComparisonScreen (featureBenefits)
  - onboarding/OnboardingFirstTaskContent (residences × 3 sites)

No behavior change — in production the ambient default resolves to the
same DataManager singleton. The change is purely so tests, previews, and
the parity-gallery can `CompositionLocalProvider(LocalDataManager provides fake)`
to substitute a fake without tearing screens apart.

Files under ui/subscription/** and ui/components/AddTaskDialog.kt also
reference DataManager but live outside ui/screens/** (plan's scope) —
flagged for a follow-up pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:08:58 -05:00
Trey T 98b775d335 P0.1: extract IDataManager interface + LocalDataManager ambient
Adds a narrow IDataManager contract covering the 5 DataManager members
referenced from ui/screens/** (currentUser, residences, totalSummary,
featureBenefits, subscription) and a staticCompositionLocalOf ambient
(LocalDataManager) that defaults to the DataManager singleton.

No screen call-sites change in this commit — screens migrate in P0.2.
ViewModels, APILayer, and PersistenceManager continue to depend on the
concrete DataManager singleton directly; the interface is deliberately
scoped to the screen surface the parity-gallery needs to substitute.

Includes IDataManagerTest (DataManager is IDataManager) and
LocalDataManagerTest (ambient val is exposed + default type-checks to the
real singleton). runComposeUiTest intentionally avoided — consistent with
ThemeSelectionScreenTest's convention, since commonTest composition
runtime is flaky on iosSimulator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:06:16 -05:00
Trey T bb4cbd58c3 Audit: form-error TalkBack + focus management + navigateUp polish
(a) liveRegion + error semantics on form error surfaces so TalkBack
    announces them when they appear:
    - Shared ErrorCard (used by LoginScreen, RegisterScreen,
      VerifyEmail/ResetCode, ForgotPassword, ResetPassword)
    - OnboardingCreateAccountContent inline error row
    - JoinResidenceScreen inline error row

(b) focusRequester + ImeAction.Next on multi-field forms:
    - LoginScreen: auto-focus username, Next→password, Done→submit
    - RegisterScreen: auto-focus username, Next chain through
      email/password/confirm, Done on last

(c) navigateUp() replaces navController.popBackStack() for simple back
    actions in App.kt (6 screens) and MainScreen.kt (3 screens), where
    the back behavior is purely navigation-controlled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:16:22 -05:00
Trey T a1f366cb30 Audit: hardcoded Color.* → MaterialTheme.colorScheme (dark-mode parity)
Replaces ~31 literal Color.White / Color.Red / Color.Gray / Color(0x…)
usages in production screens with theme-aware colorScheme roles. Fixes
dark-mode regressions on those surfaces.

Covers: DocumentCard, DocumentStates, DocumentDetailScreen,
ContractorDetailScreen (delete/favorite tints), AddContractorDialog
(FilterChip, Switch, dialog container).

Overlay-specific Color.Black/Color.White (e.g., photo-grid darkening
overlay) and theme-defined brand tokens (OrganicDesign, ThemeColors)
are left untouched per spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:11:59 -05:00
Trey T d49bc719b2 Audit: .dp → AppSpacing/AppRadius (tokenization, partial sweep)
Replaces ~163 raw .dp values with design-system tokens per CLAUDE.md rule.
Covers most visible screens (Tasks, Residences, Profile, Documents,
dialogs, kanban, forms). Adds AppSpacing/AppRadius imports where missing.

Remaining sites are geometric/canvas values (stroke widths, icon sizes,
non-standard values like 6.dp/14.dp/20.dp) or don't map to existing
tokens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:08:23 -05:00
Trey T 0c554cce6a P8: Roborazzi golden image pipeline live
Records initial golden set + wires verifyRoborazziDebug into CI. Diffs
uploaded as artifact on failure. ScreenshotTests @Ignore removed.

Root cause of the prior RoboMonitoringInstrumentation:102 failure:
createComposeRule() launches ActivityScenarioRule<ComponentActivity>
which fires a MAIN/LAUNCHER intent, but the merged unit-test manifest
declares androidx.activity.ComponentActivity without a LAUNCHER filter,
so Robolectric's PM returns "Unable to resolve activity for Intent".
Fix: switch to the standalone captureRoboImage(path) { composable }
helper from roborazzi-compose, which registers
RoborazziTransparentActivity with Robolectric's shadow PackageManager
at runtime and bypasses ActivityScenario entirely.

Also pin roborazzi outputDir to src/androidUnitTest/roborazzi so
goldens live in git (not build/) and survive gradle clean.

36 goldens, 540KB total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:57:35 -05:00
Trey T 77f32befb8 Audit: meaningful contentDescription for actionable icons (sweep)
Upgrades ~31 sites previously annotated `// decorative` to meaningful
strings where the icon is actionable. Purely decorative leading icons
retain null contentDescription per Material 3 guidance.

Focus areas:
- Selection indicators (CheckCircle vs RadioButtonUnchecked, check marks)
- Status icons (Error, Warning, CheckCircle, ErrorOutline, CloudOff, Info)
- Expand/collapse toggles (ExpandLess/ExpandMore)
- Feature inclusion indicators (Check/Close in comparison tables)
- Requirement indicators (password strength satisfied/not)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:57:08 -05:00
Trey T d8569c7aed Audit: PullToRefreshBox on remaining list screens (iOS parity)
HomeScreen + AllTasksScreen + TasksScreen now support pull-to-refresh.
forceRefresh=true per CLAUDE.md mutation pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:50:27 -05:00
Trey T 95f7318ee6 Audit 9a.3: custom sheets → ModalBottomSheet (M3 parity)
CompletionHistorySheet + contractor-picker sheet now use
material3.ModalBottomSheet. Standard Material 3 dim-behind + swipe-down
dismiss + sheet state. Inner content unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:48:14 -05:00
Trey T 40d2607da8 Suite6 + P8: Comprehensive task tests + Roborazzi scaffolding
Suite6_ComprehensiveTaskTests ports iOS tests not covered by Suite5/10
(priority/frequency picker variants, custom intervals, completion history,
edge cases).

Roborazzi screenshot-regression scaffolding in place but gated with @Ignore
until pipeline is wired — first `recordRoborazziDebug` run needs manual
golden-image review. See docs/screenshot-tests.md for enablement steps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:39:39 -05:00
Trey T 0015a5810f Audit 9a: Long-press context menus + swipe-to-dismiss on cards
Partial implementation (wave 9a agent was rate-limited mid-flight).
Delivered:
- TaskCard long-press context menu (Edit / Delete / Share)
- ResidencesScreen + ContractorsScreen swipe-to-dismiss wired for delete
- Confirm dialog before dismissal (non-destructive gesture)

Not yet delivered (follow-up):
- CompletionHistorySheet → ModalBottomSheet migration
- Contractor-picker sheet migration

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:39:29 -05:00
Trey T ba1ec2a69b Audit 9b: Dynamic color (Material You) + 48dp min touch target helpers
Dynamic color opt-in on Android 12+ via expect/actual DynamicColor:
- commonMain: expect isDynamicColorSupported() + rememberDynamicColorScheme()
- androidMain: SDK>=31 gate, dynamicLight/DarkColorScheme(context)
- iOS/JVM/JS/WASM: no-op actuals
- ThemeManager gains useDynamicColor StateFlow, persisted via ThemeStorage
- App.kt wires both currentTheme + useDynamicColor into HoneyDueTheme
- ThemeSelectionScreen exposes the "Use system colors" toggle

Touch target helpers:
- Modifier.minTouchTarget(48.dp) + Modifier.clickableWithRipple
- Applied at audit-flagged sites in CompleteTaskScreen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:39:22 -05:00
Trey T 1946fb9e6a Maestro: 10 golden-path flows for critical user journeys
Cross-platform YAML flows (iOS + Android share the AccessibilityIds
test-tag namespace). Covers login, register, residence/task CRUD,
completion, join, document upload, theme, deeplink, widget.

Run: maestro test .maestro/flows/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:59:54 -05:00
Trey T 95a5338abd CI: Gradle Managed Devices + GitHub Actions workflow
pixel7Api34 managed device runs instrumented tests headlessly on CI.
Three test-filter profiles (ci/parallel/full) mirror iOS xctestplan
variants. run_ui_tests.sh convenience wrapper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:57:42 -05:00
Trey T 227c0a9240 UI Test Suite8: Document/Warranty tests (iOS parity)
Ports representative subset of Suite8_DocumentWarrantyTests.swift
(22 of 25 iOS tests). testTags on document screens via
AccessibilityIds.Document.*. Documented deliberate skips in the
class header (5/7/8/10/11/12/16) — each either relies on iOS-only
pickers/menus or is subsumed by another ported test.

No new AccessibilityIds added — Document group already has parity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:54:24 -05:00
Trey T 840c35a7af P7 Stream Y: empty/error/loading state audit (iOS parity)
Audits every list + detail screen (non-document) for empty/error/loading
state parity with iOS. Reuses StandardEmptyState / StandardErrorState where
possible; adds missing states where screens currently show blank on error.

- Add StandardErrorState and CompactErrorState components under
  ui/components/common/ (mirrors iOS ErrorView pattern: icon + title +
  message + Retry).
- ManageUsersScreen: error state previously had no retry button; now uses
  StandardErrorState with a Retry CTA matching iOS ManageUsersView.
- ResidenceDetailScreen: task and contractor sub-section error cards now
  use CompactErrorState with inline retry (previously plain error text).

Other audited screens (ResidencesScreen, TasksScreen, AllTasksScreen,
ContractorsScreen, ContractorDetailScreen, EditTaskScreen,
CompleteTaskScreen, TaskTemplatesBrowserScreen, TaskSuggestionsScreen,
OnboardingFirstTaskContent) already had loading + error + empty parity
via ApiResultHandler / HandleErrors / inline state machines; no changes
needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:50:07 -05:00
Trey T d42406cbec UI Test SuiteZZ: Cleanup tests (iOS parity)
Port SuiteZZ_CleanupTests.swift. Deletes test-prefixed residences/tasks/
contractors/documents via authenticated APILayer + TaskApi calls. Runs
alphabetically last via the SuiteZZ_ prefix. Each step is idempotent —
logs failures but never blocks the next run. Preserves one seed "Test
House" residence so AAA_SeedTests has a deterministic starting point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:47:41 -05:00
Trey T 6980ed772b UI Test Suite4: Comprehensive residence tests (iOS parity)
Ports Suite4_ComprehensiveResidenceTests.swift. testTags on residence
screens via AccessibilityIds.Residence.*. CRUD + join + manage users +
multi-residence switch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:43:08 -05:00