26 Commits

Author SHA1 Message Date
Trey t e350467975 refactor(uploads): drop legacy multipart helpers; route Android UI through presigned flow
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:19:46 -07:00
Trey t 3cd115a436 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 14:39:26 -07:00
Trey t 418ffc7772 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-04-25 11:35:24 -05:00
Trey t cec521b3e3 refactor: delete _tasksByResidence and per-residence task cache plumbing
All readers and writers gone after the previous commits. Single source
of truth = DataManager._allTasks; residence views derive via
getTasksForResidence(id). Net deletion across DataManager, APILayer,
DataManagerObservable, Kotlin TaskViewModel, and the now-unused
TaskViewModel test.

Closes gitea#2
2026-04-25 10:48:38 -05:00
Trey t 1b001323e4 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-04-25 10:44:53 -05:00
Trey t ce25c80783 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-04-25 10:43:11 -05:00
Trey t 4181b6005d 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-04-25 10:41:06 -05:00
Trey t 2bd3bd85b6 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-04-25 10:40:20 -05:00
Trey t 60ae14c79e 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-04-25 10:39:53 -05:00
Trey t dc6d3525fa 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-04-25 10:39:01 -05:00
Trey t 5d0c3597fa 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-04-25 10:38:41 -05:00
Trey t c9d5c048b7 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-04-25 10:33:48 -05:00
Trey t f5f02145a2 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-04-25 09:33:47 -05:00
Trey t cb4806b423 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-04-25 09:25:04 -05:00
Trey t 6bfe058050 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-04-25 09:24:40 -05:00
Trey t 9ececfa48a Wire onboarding task suggestions to backend, delete hardcoded catalog
Both "For You" and "Browse All" tabs are now fully server-driven on
iOS and Android. No on-device task list, no client-side scoring rules.
When the API fails the screen shows error + Retry + Skip so onboarding
can still complete on a flaky network.

Shared (KMM)
- TaskCreateRequest + TaskResponse carry templateId
- New BulkCreateTasksRequest/Response, TaskApi.bulkCreateTasks,
  APILayer.bulkCreateTasks (updates DataManager + TotalSummary)
- OnboardingViewModel: templatesGroupedState + loadTemplatesGrouped;
  createTasks(residenceId, requests) posts once via the bulk path
- Deleted regional-template plumbing: APILayer.getRegionalTemplates,
  OnboardingViewModel.loadRegionalTemplates, TaskTemplateApi.
  getTemplatesByRegion, TaskTemplate.regionId/regionName
- 5 new AnalyticsEvents constants for the onboarding funnel

Android (Compose)
- OnboardingFirstTaskContent rewritten against the server catalog;
  ~70 lines of hardcoded taskCategories gone. Loading / Error / Empty
  panes with Retry + Skip buttons. Category icons derived from name
  keywords, colours from a 5-value palette keyed by category id
- Browse selection carries template.id into the bulk request so
  task_template_id is populated server-side

iOS (SwiftUI)
- New OnboardingTasksViewModel (@MainActor ObservableObject) wrapping
  APILayer.shared for suggestions / grouped / bulk-submit with
  loading + error state (mirrors the TaskViewModel.swift pattern)
- OnboardingFirstTaskView rewritten: buildForYouSuggestions (130 lines)
  and fallbackCategories (68 lines) deleted; both tabs show the same
  error+skip UX as Android; ForYouSuggestion/SuggestionRelevance gone
- 5 new AnalyticsEvent cases with identical PostHog event names to
  the Kotlin constants so cross-platform funnels join cleanly
- Existing TaskCreateRequest / TaskResponse call sites in TaskCard,
  TasksSection, TaskFormView updated for the new templateId parameter

Docs
- CLAUDE.md gains an "Onboarding task suggestions (server-driven)"
  subsection covering the data flow, key files on both platforms,
  and the KotlinInt(int: template.id) wrapping requirement

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:25:01 -05:00
Trey T d545fd463c Fix 10 failing UI tests: kanban scroll, menu-based edit, form submit reliability
- Screens.swift: findTask() now scrolls through kanban columns (swipe left/right)
  to locate tasks rendered off-screen in LazyHGrid
- Suite5: test06/07 use refreshTasks() instead of pullToRefresh() (kanban is
  horizontal), add API call before navigate for server processing delay
- Suite6: test09 opens "Task actions" menu before tapping edit (no detail screen)
- Suite8: submitForm() uses coordinate-based keyboard dismiss, retry tap, and
  longer timeout; test22/23 re-navigate after creation and use waitForExistence

Test results: 141/143 passed (was 131/143). Remaining 2 failures are pre-existing
(Suite1 test11) and flaky/unrelated (Suite3 testR307).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 17:21:31 -05:00
Trey T 5bb27034aa Fix UI test failures: registration dismiss cascade, onboarding reset, test stability
- Fix registration flow dismiss cascade: chain fullScreenCover → sheet onDismiss
  so auth state is set only after all UIKit presentations are removed, preventing
  RootView from swapping LoginView→MainTabView behind a stale sheet
- Fix onboarding reset: set hasCompletedOnboarding directly instead of calling
  completeOnboarding() which has an auth guard that fails after DataManager.clear()
- Stabilize Suite1 registration tests, Suite6 task tests, Suite7 contractor tests
- Add clean-slate-per-suite via AuthenticatedUITestCase reset state
- Improve test account seeding and screen object reliability

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 16:11:47 -05:00
Trey T 00e9ed0a96 Add localization strings and iOS test infrastructure
- Expand Localizable.xcstrings with 426 new localization entries
- Add xctestplan files (CI, Cleanup, Parallel, Seed) for structured test runs
- Add run_ui_tests.sh script for UI test execution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 20:32:09 -05:00
Trey T 05ee8e0a79 Fix picker capsule clipping and replace custom tab bar with native segmented control
Picker: moved .fixedSize(), .padding, .background, .clipShape outside the Menu
label so the capsule sizing is stable and never clips rounded corners during
menu open/close animation.

Tab bar: replaced custom HStack+underline tab bar with native SwiftUI
Picker(.segmented) for "For You" / "Browse All" tabs.
2026-03-30 11:32:08 -05:00
Trey T 266d540d28 Remove ZIP code step from onboarding, use home profile instead
ZIP code was US-only and redundant now that the suggestion engine
uses home profile features (heating, pool, etc.) for personalization.

Onboarding flow: Welcome → Value Props → Name → Account → Verify →
Home Profile → Task Selection (was: ...Verify → ZIP → Home Profile...)

Removed regionalTemplates references from task selection view.
Both iOS and Compose flows updated.
2026-03-30 11:15:06 -05:00
Trey T 4609d5a953 Smart onboarding: home profile, tabbed tasks, free app
New onboarding step: "Tell us about your home" with chip-based pickers
for systems (heating/cooling/water heater), features (pool, fireplace,
garage, etc.), exterior (roof, siding), interior (flooring, landscaping).
All optional, skippable.

Tabbed task selection: "For You" tab shows personalized suggestions
based on home profile, "Browse All" has existing category browser.
Removed 5-task limit — users can add unlimited tasks.

Removed subscription upsell from onboarding flow — app is free.
Fixed picker capsule squishing bug with .fixedSize() modifier.

Both iOS and Compose implementations updated.
2026-03-30 09:02:27 -05:00
Trey T 8f86fa2cd0 Fix 12 iOS issues: race conditions, data flow, UX
Critical bugs:
- RootView: auth check deferred to .task{} modifier (after DataManager init)
- DataManagerObservable: map conversion failures now logged with key details
- ContractorViewModel: replace stuck boolean flag with time-based suppression
- DocumentViewModel: guard full success.data before image upload

Logic fixes:
- AllTasksView: 300ms delay before animation flag release
- ResidenceViewModel: trigger initializeLookups() if not ready
- TaskFormView: hasDueDate toggle prevents defaulting to today
- OnboardingState: guard isAuthenticated before completing onboarding

UX fixes:
- ResidencesListView: 10-second refresh timeout
- AllTasksView: add button disabled while sheet presented
- TaskViewModel: actionState auto-resets after 3s, explicit reset on consume
2026-03-26 18:01:49 -05:00
Trey T 4d363ca44e Fix API contract mismatches with Go backend
- Document.purchasePrice: String? → Double? (matches Go decimal.Decimal)
- TaskTemplate: add regionId/regionName (Go returns these, KMM was ignoring)
- TaskResponse.completions: add comment explaining separate fetch pattern
- Document: add comments clarifying fileUrl vs mediaUrl usage
2026-03-26 17:06:36 -05:00
Trey T e4dc3ac30b Add PostHog exception capture for crash reporting
Android: uncaught exception handler sends $exception events with stack
trace to PostHog, flushes before delegating to default handler.
iOS: NSSetUncaughtExceptionHandler captures crashes via PostHogSDK,
avoids @MainActor deadlock by calling SDK directly.
Common: captureException() available for non-fatal catches app-wide.
Platform stubs for jvm/js/wasmJs.
2026-03-26 16:49:30 -05:00
Trey T af73f8861b iOS VoiceOver accessibility overhaul — 67 files
New framework:
- AccessibilityLabels.swift: centralized A11y struct with VoiceOver strings
- AccessibilityModifiers.swift: reusable .a11yHeader, .a11yDecorative,
  .a11yButton, .a11yCard, .a11yStatValue View extensions

Shared components: decorative elements hidden, stat views combined,
status/priority badges labeled, error views announced, empty states grouped

Cards: ResidenceCard, TaskCard, DynamicTaskCard, ContractorCard,
DocumentCard, WarrantyCard — all grouped with combined labels,
chevrons hidden, action buttons labeled

Main screens: Login, Register, Residences, Tasks, Contractors, Documents —
toolbar buttons labeled, section headers marked, form field hints added

Onboarding: all 10 views — header traits, button hints, task selection
state, progress indicator, decorative backgrounds hidden

Profile/Subscription: toggle hints, theme selection state, feature
comparison table accessibility, subscription button labels

iOS build verified: BUILD SUCCEEDED
2026-03-26 14:51:29 -05:00
153 changed files with 7637 additions and 1594 deletions
+1 -4
View File
@@ -1,8 +1,5 @@
{
"permissions": {
"ask": [
"Bash(git commit:*)",
"Bash(git push:*)"
]
"ask": []
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"permissions": {
"allow": [
"Bash(head:*)",
"Bash(ls:*)",
"Bash(tail:*)",
"Bash(xcodebuild:*)",
"Bash(find:*)",
"Bash(curl:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(git fetch:*)",
"Bash(git checkout:*)",
"Bash(git:*)",
"Bash(python3:*)",
"Bash(grep:*)",
"Bash(ps:*)",
"Bash(stdbuf:*)",
"Bash(sysctl:*)",
"Bash(tee:*)",
"Bash(codesign -d --entitlements :- /Users/treyt/Library/Developer/Xcode/DerivedData/honeyDue-buvczbpttcfkxxcmxbnqkqrmujyh/Build/Products/Debug-iphonesimulator/honeyDue.app)"
]
}
}
+417
View File
@@ -0,0 +1,417 @@
# HoneyDue UI Test Results - March 14, 2026
**Branch:** `rename/honeydue`
**Device:** iPhone 16 Simulator (iOS 26.2)
**Parallel:** No (sequential execution)
**Machine:** Apple M1, 8GB RAM
## Summary
| Status | Count |
|--------|-------|
| Passed | 71 |
| Failed | 168 |
| Skipped | 15 |
| **Total** | **254** |
**Pass Rate: 28.0%**
---
## Results by Suite
| Suite | Passed | Failed | Skipped | Total | Pass Rate |
|-------|--------|--------|---------|-------|-----------|
| AccessibilityTests | 6 | 0 | 0 | 6 | 100% |
| AppLaunchTests | 2 | 0 | 0 | 2 | 100% |
| StabilityTests | 6 | 0 | 0 | 6 | 100% |
| SimpleLoginTest | 2 | 0 | 0 | 2 | 100% |
| Suite3_ResidenceRebuildTests | 9 | 0 | 0 | 9 | 100% |
| Suite0_OnboardingRebuildTests | 2 | 0 | 3 | 5 | 100%* |
| OnboardingTests | 10 | 3 | 0 | 13 | 77% |
| Suite2_AuthenticationRebuildTests | 4 | 2 | 0 | 6 | 67% |
| Suite2_AuthenticationTests | 4 | 2 | 0 | 6 | 67% |
| AuthCriticalPathTests | 3 | 2 | 0 | 5 | 60% |
| Suite1_RegistrationTests | 6 | 5 | 0 | 11 | 55% |
| Suite9_IntegrationE2ETests | 2 | 5 | 0 | 7 | 29% |
| SmokeTests | 1 | 4 | 0 | 5 | 20% |
| Suite4_ComprehensiveResidenceTests | 2 | 16 | 0 | 18 | 11% |
| Suite6_ComprehensiveTaskTests | 2 | 13 | 0 | 15 | 13% |
| Suite10_ComprehensiveE2ETests | 1 | 8 | 0 | 9 | 11% |
| Suite5_TaskTests | 1 | 9 | 0 | 10 | 10% |
| Suite7_ContractorTests | 2 | 18 | 0 | 20 | 10% |
| AuthenticationTests | 10 | 6 | 0 | 16 | 63% |
| Suite1_RegistrationRebuildTests | 0 | 2 | 12 | 14 | 0%* |
| ContractorIntegrationTests | 0 | 5 | 0 | 5 | 0% |
| DataLayerTests | 0 | 10 | 0 | 10 | 0% |
| DocumentIntegrationTests | 0 | 4 | 0 | 4 | 0% |
| NavigationCriticalPathTests | 0 | 10 | 0 | 10 | 0% |
| PasswordResetTests | 0 | 5 | 0 | 5 | 0% |
| ResidenceIntegrationTests | 0 | 5 | 0 | 5 | 0% |
| Suite0_OnboardingTests | 0 | 1 | 0 | 1 | 0% |
| Suite3_ResidenceTests | 0 | 6 | 0 | 6 | 0% |
| Suite8_DocumentWarrantyTests | 0 | 25 | 0 | 25 | 0% |
| TaskIntegrationTests | 0 | 5 | 0 | 5 | 0% |
*Pass rate excludes skipped tests
---
## Fully Passing Suites (6)
- **AccessibilityTests** (6/6)
- **AppLaunchTests** (2/2)
- **StabilityTests** (6/6)
- **SimpleLoginTest** (2/2)
- **Suite3_ResidenceRebuildTests** (9/9)
- **Suite0_OnboardingRebuildTests** (2/2 run, 3 skipped)
## Fully Failing Suites (10)
- **ContractorIntegrationTests** (0/5)
- **DataLayerTests** (0/10)
- **DocumentIntegrationTests** (0/4)
- **NavigationCriticalPathTests** (0/10)
- **PasswordResetTests** (0/5)
- **ResidenceIntegrationTests** (0/5)
- **Suite0_OnboardingTests** (0/1)
- **Suite3_ResidenceTests** (0/6)
- **Suite8_DocumentWarrantyTests** (0/25)
- **TaskIntegrationTests** (0/5)
---
## Detailed Results
### AccessibilityTests (6 passed, 0 failed)
| Test | Result | Time |
|------|--------|------|
| testA001_OnboardingPrimaryControlsAreReachable | PASSED | 16.8s |
| testA002_LoginControlsRemainOperable | PASSED | 26.9s |
| testA003_CoreControlsExposeIdentifiers | PASSED | 15.8s |
| testA004_ValuePropsScreenControlsAreReachable | PASSED | 14.8s |
| testA005_NameResidenceScreenControlsAreReachable | PASSED | 20.0s |
| testA006_CreateAccountScreenControlsAreReachable | PASSED | 23.0s |
### AppLaunchTests (2 passed, 0 failed)
| Test | Result | Time |
|------|--------|------|
| testF001_ColdLaunchShowsOnboardingWelcome | PASSED | 12.3s |
| testF002_ColdLaunchShowsPrimaryOnboardingActions | PASSED | 9.7s |
### AuthCriticalPathTests (3 passed, 2 failed)
| Test | Result | Time |
|------|--------|------|
| testForgotPasswordButtonExists | PASSED | 21.6s |
| testLoginWithInvalidCredentials | PASSED | 21.0s |
| testLoginWithValidCredentials | FAILED | 31.4s |
| testLogoutFlow | FAILED | 36.0s |
| testSignUpButtonNavigatesToRegistration | PASSED | 21.0s |
### AuthenticationTests (10 passed, 6 failed)
| Test | Result | Time |
|------|--------|------|
| test08_invalidatedTokenRedirectsToLogin | FAILED | 54.5s |
| testF201_OnboardingLoginEntryShowsLoginScreen | PASSED | 15.8s |
| testF202_LoginScreenCanTogglePasswordVisibility | PASSED | 19.1s |
| testF203_RegisterSheetCanOpenAndDismiss | FAILED | 23.4s |
| testF204_RegisterFormAcceptsInput | FAILED | 23.7s |
| testF205_LoginButtonDisabledWhenCredentialsAreEmpty | PASSED | 16.1s |
| testF206_ForgotPasswordButtonIsAccessible | PASSED | 16.4s |
| testF207_LoginScreenShowsAllExpectedElements | PASSED | 15.2s |
| testF208_RegisterFormShowsAllRequiredFields | FAILED | 24.4s |
| testF209_ForgotPasswordNavigatesToResetFlow | PASSED | 18.2s |
### ContractorIntegrationTests (0 passed, 5 failed)
| Test | Result | Time |
|------|--------|------|
| test20_toggleContractorFavorite | FAILED | 0.4s |
| test21_contractorByResidenceFilter | FAILED | 0.3s |
| testCON002_CreateContractorMinimalFields | FAILED | 0.2s |
| testCON005_EditContractor | FAILED | 0.2s |
| testCON006_DeleteContractor | FAILED | 0.2s |
### DataLayerTests (0 passed, 10 failed)
| Test | Result | Time |
|------|--------|------|
| test08_diskPersistencePreservesLookupsAfterRestart | FAILED | 0.2s |
| test09_themePersistsAcrossRestart | FAILED | 0.2s |
| test10_completionHistoryLoadsAndIsSorted | FAILED | 0.2s |
| testDATA001_LookupsInitializeAfterLogin | FAILED | 0.2s |
| testDATA002_ETagRefreshHandles304 | FAILED | 0.2s |
| testDATA003_LegacyFallbackStillLoadsCoreLookups | FAILED | 0.2s |
| testDATA004_CacheTimeoutAndForceRefresh | FAILED | 0.2s |
| testDATA005_LogoutClearsUserDataButRetainsTheme | FAILED | 0.2s |
| testDATA006_LookupsPersistAfterAppRestart | FAILED | 0.2s |
| testDATA007_LookupMapListConsistency | FAILED | 0.2s |
### DocumentIntegrationTests (0 passed, 4 failed)
| Test | Result | Time |
|------|--------|------|
| test22_documentImageSectionExists | FAILED | 0.2s |
| testDOC002_CreateDocumentWithRequiredFields | FAILED | 0.2s |
| testDOC004_EditDocument | FAILED | 0.2s |
| testDOC005_DeleteDocument | FAILED | 0.2s |
### NavigationCriticalPathTests (0 passed, 10 failed)
| Test | Result | Time |
|------|--------|------|
| testAllTabsExist | FAILED | 45.9s |
| testContractorAddButtonExists | FAILED | 45.9s |
| testDocumentAddButtonExists | FAILED | 45.9s |
| testNavigateBackToResidencesTab | FAILED | 45.8s |
| testNavigateToContractorsTab | FAILED | 46.2s |
| testNavigateToDocumentsTab | FAILED | 45.6s |
| testNavigateToTasksTab | FAILED | 45.8s |
| testResidenceAddButtonExists | FAILED | 45.8s |
| testSettingsButtonExists | FAILED | 45.5s |
| testTaskAddButtonExists | FAILED | 45.8s |
### OnboardingTests (10 passed, 3 failed)
| Test | Result | Time |
|------|--------|------|
| testF101_StartFreshFlowReachesCreateAccount | PASSED | 20.2s |
| testF102_JoinExistingFlowGoesToCreateAccount | PASSED | 11.6s |
| testF103_BackNavigationFromNameResidenceReturnsToValueProps | PASSED | 16.2s |
| testF104_SkipOnValuePropsMovesToNameResidence | PASSED | 13.9s |
| testF105_JoinExistingFlowSkipsValuePropsAndNameResidence | PASSED | 11.2s |
| testF106_NameResidenceFieldAcceptsInput | PASSED | 15.7s |
| testF107_ProgressIndicatorVisibleDuringOnboarding | PASSED | 12.8s |
| testF108_BackFromCreateAccountNavigatesToPreviousStep | PASSED | 22.5s |
| testF110_startFreshCreatesResidenceAfterVerification | FAILED | 29.1s |
| testF111_completedOnboardingBypassedOnRelaunch | FAILED | 52.9s |
### PasswordResetTests (0 passed, 5 failed)
| Test | Result | Time |
|------|--------|------|
| test03_verifyResetCodeSuccess | FAILED | 35.4s |
| test04_resetPasswordSuccessAndLogin | FAILED | 35.4s |
| testAUTH015_VerifyResetCodeSuccessPath | FAILED | 35.3s |
| testAUTH016_ResetPasswordSuccess | FAILED | 36.1s |
| testAUTH017_MismatchedPasswordBlocked | FAILED | 35.3s |
### ResidenceIntegrationTests (0 passed, 5 failed)
| Test | Result | Time |
|------|--------|------|
| test18_setPrimaryResidence | FAILED | 0.4s |
| test19_doubleSubmitProtection | FAILED | 0.3s |
| testRES_CreateResidenceAppearsInList | FAILED | 0.2s |
| testRES_DeleteResidenceRemovesFromList | FAILED | 0.2s |
| testRES_EditResidenceUpdatesInList | FAILED | 0.2s |
### SimpleLoginTest (2 passed, 0 failed)
| Test | Result | Time |
|------|--------|------|
| testAppLaunchesAndShowsLoginScreen | PASSED | 24.3s |
| testCanTypeInLoginFields | PASSED | 27.9s |
### SmokeTests (1 passed, 4 failed)
| Test | Result | Time |
|------|--------|------|
| testAppLaunches | FAILED | 25.8s |
| testLoginScreenElements | PASSED | 20.7s |
| testLoginWithExistingCredentials | FAILED | 30.7s |
| testMainTabsExistAfterLogin | FAILED | 35.6s |
| testTabNavigation | FAILED | 35.4s |
### StabilityTests (6 passed, 0 failed)
| Test | Result | Time |
|------|--------|------|
| testP001_RapidOnboardingNavigationDoesNotCrash | PASSED | 31.6s |
| testP002_RepeatedForwardNavigationRemainsResponsive | PASSED | 53.1s |
| testP003_RapidDoubleTapOnValuePropsContinueLandsOnNameResidence | PASSED | 13.7s |
| testP004_StartFreshThenBackToWelcomeThenJoinExistingDoesNotCorruptState | PASSED | 17.0s |
| testP005_RepeatedLoginNavigationRemainsStable | PASSED | 41.4s |
| testP010_retryButtonExistsOnErrorState | PASSED | 21.1s |
### Suite0_OnboardingRebuildTests (2 passed, 0 failed, 3 skipped)
| Test | Result | Time |
|------|--------|------|
| testR001_onboardingWelcomeLoadsAndCanNavigateToLoginEntry | PASSED | 12.5s |
| testR002_startFreshFlowReachesCreateAccount | PASSED | 19.0s |
| testR003_createAccountExpandedFormFieldsAreInteractable | SKIPPED | 6.5s |
| testR004_emailFieldCanFocusAndAcceptTyping | SKIPPED | 6.2s |
| testR005_createAccountContinueOnlyAfterValidInputs | SKIPPED | 6.3s |
### Suite0_OnboardingTests (0 passed, 1 failed)
| Test | Result | Time |
|------|--------|------|
| test_onboarding | FAILED | 33.1s |
### Suite1_RegistrationRebuildTests (0 passed, 2 failed, 12 skipped)
| Test | Result | Time |
|------|--------|------|
| testR101_registerFormCanOpenFromLogin | FAILED | 29.9s |
| testR102_registerFormAcceptsValidInput | FAILED | 29.1s |
| testR103-R114 (12 tests) | SKIPPED | ~5s each |
### Suite1_RegistrationTests (6 passed, 5 failed)
| Test | Result | Time |
|------|--------|------|
| test01_registrationScreenElements | PASSED | 40.8s |
| test02_cancelRegistration | PASSED | 46.7s |
| test03_registrationWithEmptyFields | PASSED | 45.0s |
| test04_registrationWithInvalidEmail | PASSED | 54.8s |
| test05_registrationWithMismatchedPasswords | PASSED | 55.9s |
| test06_registrationWithWeakPassword | PASSED | 55.4s |
| test07_successfulRegistrationAndVerification | FAILED | 64.5s |
| test09_registrationWithInvalidVerificationCode | FAILED | 95.4s |
| test10_verificationCodeFieldValidation | FAILED | 95.6s |
| test11_appRelaunchWithUnverifiedUser | FAILED | 96.5s |
| test12_logoutFromVerificationScreen | FAILED | 96.6s |
### Suite2_AuthenticationRebuildTests (4 passed, 2 failed)
| Test | Result | Time |
|------|--------|------|
| testR201_loginScreenLoadsFromOnboardingEntry | PASSED | 29.9s |
| testR202_validCredentialsSubmitFromLogin | PASSED | 33.4s |
| testR203_validLoginTransitionsToMainAppRoot | PASSED | 38.9s |
| testR204_mainAppHasExpectedPrimaryTabsAfterLogin | PASSED | 24.2s |
| testR205_logoutFromMainAppReturnsToLoginRoot | FAILED | 46.4s |
| testR206_postLogoutMainAppIsNoLongerAccessible | FAILED | 43.8s |
### Suite2_AuthenticationTests (4 passed, 2 failed)
| Test | Result | Time |
|------|--------|------|
| test01_loginWithInvalidCredentials | PASSED | 22.8s |
| test02_loginWithValidCredentials | FAILED | 30.8s |
| test03_passwordVisibilityToggle | PASSED | 15.6s |
| test04_navigationToSignUp | PASSED | 14.1s |
| test05_forgotPasswordNavigation | PASSED | 14.0s |
| test06_logout | FAILED | 29.7s |
### Suite3_ResidenceRebuildTests (9 passed, 0 failed)
| Test | Result | Time |
|------|--------|------|
| testR301_authenticatedPreconditionCanReachMainApp | PASSED | 24.2s |
| testR302_residencesTabIsPresentAndNavigable | PASSED | 21.9s |
| testR303_residencesListLoadsAfterTabSelection | PASSED | 22.8s |
| testR304_openAddResidenceFormFromResidencesList | PASSED | 25.1s |
| testR305_cancelAddResidenceReturnsToResidenceList | PASSED | 26.9s |
| testR306_createResidenceMinimalDataSubmitsSuccessfully | PASSED | 31.3s |
| testR307_newResidenceAppearsInResidenceList | PASSED | 31.1s |
| testR308_openResidenceDetailsFromResidenceList | PASSED | 32.6s |
| testR309_navigationAcrossPrimaryTabsAndBackToResidences | PASSED | 25.5s |
### Suite3_ResidenceTests (0 passed, 6 failed)
| Test | Result | Time |
|------|--------|------|
| test01_viewResidencesList | FAILED | 31.1s |
| test02_navigateToAddResidence | FAILED | 31.0s |
| test03_navigationBetweenTabs | FAILED | 31.5s |
| test04_cancelResidenceCreation | FAILED | 31.9s |
| test05_createResidenceWithMinimalData | FAILED | 31.1s |
| test06_viewResidenceDetails | FAILED | 31.4s |
### Suite4_ComprehensiveResidenceTests (2 passed, 16 failed)
| Test | Result | Time |
|------|--------|------|
| test01-16 (16 tests) | FAILED | ~36s each |
| test17_residenceListPerformance | PASSED | 78.1s |
| test18_residenceCreationPerformance | PASSED | 47.5s |
### Suite5_TaskTests (1 passed, 9 failed)
| Test | Result | Time |
|------|--------|------|
| test01_cancelTaskCreation | FAILED | 50.8s |
| test02_tasksTabExists | FAILED | 39.3s |
| test03_viewTasksList | FAILED | 53.2s |
| test04_addTaskButtonExists | FAILED | 50.4s |
| test05_navigateToAddTask | FAILED | 50.5s |
| test06_createBasicTask | FAILED | 50.7s |
| test07_viewTaskDetails | PASSED | 52.6s |
| test08_navigateToContractors | FAILED | 50.7s |
| test09_navigateToDocuments | FAILED | 51.0s |
| test10_navigateBetweenTabs | FAILED | 45.7s |
### Suite6_ComprehensiveTaskTests (2 passed, 13 failed)
| Test | Result | Time |
|------|--------|------|
| test01-13 (13 tests) | FAILED | ~43-56s each |
| test14_taskListPerformance | PASSED | 82.7s |
| test15_taskCreationPerformance | PASSED | 52.7s |
### Suite7_ContractorTests (2 passed, 18 failed)
| Test | Result | Time |
|------|--------|------|
| test01-18 (18 tests) | FAILED | ~36s each |
| test19_contractorListPerformance | PASSED | 78.6s |
| test20_contractorCreationPerformance | PASSED | 48.3s |
### Suite8_DocumentWarrantyTests (0 passed, 25 failed)
| Test | Result | Time |
|------|--------|------|
| test01-25 (all 25 tests) | FAILED | ~46-50s each |
### Suite9_IntegrationE2ETests (2 passed, 5 failed)
| Test | Result | Time |
|------|--------|------|
| test01_authenticationFlow | FAILED | 42.8s |
| test02_residenceCRUDFlow | FAILED | 40.9s |
| test03_taskLifecycleFlow | FAILED | 57.5s |
| test04_kanbanColumnDistribution | FAILED | 38.0s |
| test05_crossUserAccessControl | FAILED | 37.7s |
| test06_lookupDataAvailable | PASSED | 41.8s |
| test07_residenceSharingUIElements | PASSED | 42.0s |
### TaskIntegrationTests (0 passed, 5 failed)
| Test | Result | Time |
|------|--------|------|
| test15_uncancelRestorescancelledTask | FAILED | 0.4s |
| test16_createTaskFromTemplate | FAILED | 0.2s |
| testTASK_CreateTaskAppearsInList | FAILED | 0.2s |
| testTASK010_UncancelTaskFlow | FAILED | 0.2s |
| testTASK012_DeleteTaskUpdatesViews | FAILED | 0.2s |
---
## Observations
### Patterns in Failures
1. **Integration tests fail instantly (~0.2s):** ContractorIntegrationTests, DataLayerTests, DocumentIntegrationTests, ResidenceIntegrationTests, TaskIntegrationTests all fail in < 0.5s, suggesting they crash on setup or have missing preconditions.
2. **NavigationCriticalPathTests all timeout at ~45s:** These require authenticated login but consistently fail at the same timeout, likely unable to complete login flow.
3. **Suite3-8 (authenticated CRUD tests) fail at ~36-50s:** All authenticated tests that use the older test patterns fail with similar timeouts, suggesting the login/auth flow in these older suites is broken.
4. **Rebuild suites pass where old suites fail:** `Suite3_ResidenceRebuildTests` passes 9/9 while `Suite3_ResidenceTests` fails 6/6. The rebuild suites use the updated `AuthenticatedTestCase` framework.
5. **Pre-auth tests pass reliably:** Onboarding, accessibility, stability, app launch, and simple login tests all pass because they don't require authentication.
6. **Logout tests consistently fail:** Both `testR205_logoutFromMainAppReturnsToLoginRoot` and `test06_logout` fail across multiple suites.
### Build Fix Applied
Fixed `TEST_HOST` casing mismatch in `project.pbxproj`: changed `HoneyDue.app/HoneyDue` to `honeyDue.app/honeyDue` to match the renamed product.
Fixed `SubscriptionGatingTests` compile error: added missing `tier`, `isActive`, `trialStart`, `trialEnd`, `trialActive`, `subscriptionSource` parameters to `SubscriptionStatus` constructor.
+42
View File
@@ -1219,6 +1219,48 @@ val CURRENT_ENV = Environment.DEV // or Environment.LOCAL
3. Add method to relevant ViewModel that calls APILayer
4. Update UI to observe the new StateFlow
### Onboarding task suggestions (server-driven)
The First-Task onboarding screen is **fully server-driven** on both
platforms. There is no hardcoded catalog or client-side suggestion rules;
when the API fails the screen shows error + Retry + Skip.
**Data flow:**
```
"For You" tab → APILayer.getTaskSuggestions(residenceId)
→ GET /api/tasks/suggestions/?residence_id=X
→ scored against 15 home-profile fields (incl. climate zone)
"Browse All" tab → APILayer.getTaskTemplatesGrouped()
→ GET /api/tasks/templates/grouped/
→ cached on DataManager.taskTemplatesGrouped (24h TTL)
Submit → APILayer.bulkCreateTasks(BulkCreateTasksRequest)
→ POST /api/tasks/bulk/
→ single DB transaction, all-or-nothing
```
**Key files:**
- Shared ViewModel: `composeApp/.../viewmodel/OnboardingViewModel.kt`
(`suggestionsState`, `templatesGroupedState`, `createTasks`)
- Android screen: `composeApp/.../ui/screens/onboarding/OnboardingFirstTaskContent.kt`
- iOS Swift wrapper: `iosApp/iosApp/Onboarding/OnboardingTasksViewModel.swift`
(mirrors the Kotlin ViewModel but calls `APILayer.shared` directly in
Swift rather than observing Kotlin StateFlows — matches the convention in
`iosApp/iosApp/Task/TaskViewModel.swift`)
- iOS view: `iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift`
- Analytics: 5 shared event names in `AnalyticsEvents` (Kotlin) +
`AnalyticsEvent` (Swift) — `onboarding_suggestions_loaded`,
`onboarding_suggestion_accepted`, `onboarding_browse_template_accepted`,
`onboarding_tasks_created`, `onboarding_task_step_skipped`.
**When selecting a template from either tab**, always populate
`TaskCreateRequest.templateId` with the backend `TaskTemplate.id` so the
created task carries the template backlink for reporting. Swift wraps the
id as `KotlinInt(int: template.id)`.
### Handling Platform-Specific Code
Use `expect/actual` pattern:
@@ -80,6 +80,9 @@ class MainActivity : FragmentActivity(), SingletonImageLoader.Factory {
// Initialize PostHog Analytics
PostHogAnalytics.initialize(application, debug = true) // Set debug=false for release
// Install uncaught exception handler to capture crashes to PostHog
PostHogAnalytics.setupExceptionHandler()
// Handle deep link, notification navigation, and file import from intent
handleDeepLink(intent)
handleNotificationNavigation(intent)
@@ -1,6 +1,7 @@
package com.tt.honeyDue.analytics
import android.app.Application
import android.util.Log
import com.posthog.PostHog
import com.posthog.android.PostHogAndroid
import com.posthog.android.PostHogAndroidConfig
@@ -13,6 +14,8 @@ actual object PostHogAnalytics {
private const val API_KEY = "YOUR_POSTHOG_API_KEY"
private const val HOST = "https://us.i.posthog.com"
private const val TAG = "PostHogAnalytics"
private var isInitialized = false
private var application: Application? = null
@@ -57,6 +60,28 @@ actual object PostHogAnalytics {
PostHog.capture(event, properties = properties)
}
/**
* Capture an exception/crash as a PostHog `$exception` event.
* Uses PostHog's standard exception property names so exceptions
* appear correctly in the PostHog Errors dashboard.
*/
actual fun captureException(throwable: Throwable, properties: Map<String, Any>?) {
if (!isInitialized) return
try {
val exceptionProps = mutableMapOf<String, Any>(
"\$exception_type" to (throwable::class.simpleName ?: "Unknown"),
"\$exception_message" to (throwable.message ?: "No message"),
"\$exception_stack_trace_raw" to throwable.stackTraceToString()
)
if (properties != null) {
exceptionProps.putAll(properties)
}
PostHog.capture("\$exception", properties = exceptionProps)
} catch (e: Exception) {
Log.e(TAG, "Failed to capture exception to PostHog", e)
}
}
actual fun screen(screenName: String, properties: Map<String, Any>?) {
if (!isInitialized) return
PostHog.screen(screenName, properties = properties)
@@ -71,4 +96,36 @@ actual object PostHogAnalytics {
if (!isInitialized) return
PostHog.flush()
}
/**
* Install an uncaught exception handler that captures crashes to PostHog
* before delegating to the default handler (which shows the crash dialog).
* Call this after initialize() in MainActivity.onCreate().
*/
actual fun setupExceptionHandler() {
if (!isInitialized) return
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
PostHog.capture(
event = "\$exception",
properties = mapOf(
"\$exception_type" to (throwable::class.simpleName ?: "Unknown"),
"\$exception_message" to (throwable.message ?: "No message"),
"\$exception_stack_trace_raw" to throwable.stackTraceToString(),
"\$exception_thread" to thread.name,
"\$exception_is_fatal" to true
)
)
// Flush to ensure the event is sent before the process dies
PostHog.flush()
} catch (_: Exception) {
// Don't let our crash handler crash
}
// Call the default handler so the system crash dialog still appears
defaultHandler?.uncaughtException(thread, throwable)
}
Log.d(TAG, "Uncaught exception handler installed")
}
}
@@ -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)
}
}
@@ -813,6 +813,18 @@
<string name="onboarding_subscription_continue_free">Continue with Free</string>
<string name="onboarding_subscription_trial_terms">7-day free trial, then %1$s. Cancel anytime.</string>
<!-- Onboarding - Home Profile -->
<string name="onboarding_home_profile_title">Tell us about your home</string>
<string name="onboarding_home_profile_subtitle">All optional — helps us personalize your maintenance plan</string>
<string name="onboarding_home_profile_systems">Systems</string>
<string name="onboarding_home_profile_features">Features</string>
<string name="onboarding_home_profile_exterior">Exterior</string>
<string name="onboarding_home_profile_interior">Interior</string>
<!-- Onboarding - Task Selection Tabs -->
<string name="for_you_tab">For You</string>
<string name="browse_tab">Browse</string>
<!-- Biometric Lock -->
<string name="biometric_lock_title">App Locked</string>
<string name="biometric_lock_description">Authenticate to unlock honeyDue</string>
@@ -8,9 +8,11 @@ expect object PostHogAnalytics {
fun initialize()
fun identify(userId: String, properties: Map<String, Any>? = null)
fun capture(event: String, properties: Map<String, Any>? = null)
fun captureException(throwable: Throwable, properties: Map<String, Any>? = null)
fun screen(screenName: String, properties: Map<String, Any>? = null)
fun reset()
fun flush()
fun setupExceptionHandler()
}
/**
@@ -34,6 +36,20 @@ object AnalyticsEvents {
const val NEW_TASK_SCREEN_SHOWN = "new_task_screen_shown"
const val TASK_CREATED = "task_created"
// Onboarding — First Task screen funnel
// Fired by both iOS and Android with identical event names so the
// PostHog funnel is cross-platform. Properties documented next to each.
// ONBOARDING_SUGGESTIONS_LOADED: {"count": Int, "profile_completeness": Double}
// ONBOARDING_SUGGESTION_ACCEPTED: {"template_id": Int, "relevance_score": Double}
// ONBOARDING_BROWSE_TEMPLATE_ACCEPTED: {"template_id": Int, "category": String?}
// ONBOARDING_TASKS_CREATED: {"count": Int}
// ONBOARDING_TASK_STEP_SKIPPED: {"reason": "network_error" | "user_skip"}
const val ONBOARDING_SUGGESTIONS_LOADED = "onboarding_suggestions_loaded"
const val ONBOARDING_SUGGESTION_ACCEPTED = "onboarding_suggestion_accepted"
const val ONBOARDING_BROWSE_TEMPLATE_ACCEPTED = "onboarding_browse_template_accepted"
const val ONBOARDING_TASKS_CREATED = "onboarding_tasks_created"
const val ONBOARDING_TASK_STEP_SKIPPED = "onboarding_task_step_skipped"
// Contractor
const val CONTRACTOR_SCREEN_SHOWN = "contractor_screen_shown"
const val NEW_CONTRACTOR_SCREEN_SHOWN = "new_contractor_screen_shown"
@@ -62,8 +62,6 @@ object DataManager {
private set
var tasksCacheTime: Long = 0L
private set
var tasksByResidenceCacheTime: MutableMap<Int, Long> = mutableMapOf()
private set
var contractorsCacheTime: Long = 0L
private set
var documentsCacheTime: Long = 0L
@@ -138,8 +136,6 @@ object DataManager {
private val _allTasks = MutableStateFlow<TaskColumnsResponse?>(null)
val allTasks: StateFlow<TaskColumnsResponse?> = _allTasks.asStateFlow()
private val _tasksByResidence = MutableStateFlow<Map<Int, TaskColumnsResponse>>(emptyMap())
val tasksByResidence: StateFlow<Map<Int, TaskColumnsResponse>> = _tasksByResidence.asStateFlow()
// ==================== DOCUMENTS ====================
@@ -414,7 +410,6 @@ object DataManager {
fun removeResidence(residenceId: Int) {
_residences.value = _residences.value.filter { it.id != residenceId }
_tasksByResidence.value = _tasksByResidence.value - residenceId
_documentsByResidence.value = _documentsByResidence.value - residenceId
_residenceSummaries.value = _residenceSummaries.value - residenceId
@@ -445,16 +440,10 @@ object DataManager {
persistToDisk()
}
fun setTasksForResidence(residenceId: Int, response: TaskColumnsResponse) {
_tasksByResidence.value = _tasksByResidence.value + (residenceId to response)
tasksByResidenceCacheTime[residenceId] = currentTimeMs()
persistToDisk()
}
/**
* Filter cached allTasks by residence ID to avoid separate API call.
* Returns null if allTasks not cached.
* This enables client-side filtering when we already have all tasks loaded.
* Filter cached allTasks by residence ID. Single source of truth for
* residence-scoped kanban data; returns null when _allTasks is null
* (caller must hit the API to populate).
*/
fun getTasksForResidence(residenceId: Int): TaskColumnsResponse? {
val allTasksData = _allTasks.value ?: return null
@@ -480,45 +469,60 @@ object DataManager {
* Also refreshes the summary from the updated kanban data.
*/
fun updateTask(task: TaskResponse) {
// Update in allTasks
_allTasks.value?.let { current ->
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
val newColumns = current.columns.map { column ->
// Remove task from this column if present
val filteredTasks = column.tasks.filter { it.id != task.id }
// Add task if this is the target column
val updatedTasks = if (column.name == targetColumn) {
filteredTasks + task
// Upsert into _allTasks. Crucially, when _allTasks is null (fresh
// launch, kanban never fetched — the gitea#2 bug scenario), seed
// an empty kanban shell so the new task isn't silently dropped.
// The Phase 2 force-refresh after bulkCreateTasks/createTask will
// replace this shell with authoritative server data shortly.
val current = _allTasks.value ?: emptyKanbanShell()
val columnsWithTarget = if (current.columns.any { it.name == targetColumn }) {
current.columns
} else {
filteredTasks
// Server returned a kanban_column the client doesn't know about
// yet — append it so the task is still reachable.
current.columns + emptyColumn(targetColumn)
}
val newColumns = columnsWithTarget.map { column ->
val filteredTasks = column.tasks.filter { it.id != task.id }
val updatedTasks = if (column.name == targetColumn) filteredTasks + task else filteredTasks
column.copy(tasks = updatedTasks, count = updatedTasks.size)
}
_allTasks.value = current.copy(columns = newColumns)
}
// Update in tasksByResidence if this task's residence is cached
task.residenceId?.let { residenceId ->
_tasksByResidence.value[residenceId]?.let { current ->
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
val newColumns = current.columns.map { column ->
val filteredTasks = column.tasks.filter { it.id != task.id }
val updatedTasks = if (column.name == targetColumn) {
filteredTasks + task
} else {
filteredTasks
}
column.copy(tasks = updatedTasks, count = updatedTasks.size)
}
_tasksByResidence.value = _tasksByResidence.value + (residenceId to current.copy(columns = newColumns))
}
}
// Refresh summary from updated kanban data (API no longer returns summaries for CRUD)
refreshSummaryFromKanban()
persistToDisk()
}
/// Default kanban skeleton used when `_allTasks` was never populated.
/// Display metadata is intentionally placeholder — the Phase 2 force-refresh
/// in `APILayer.bulkCreateTasks` / `createTask` replaces these shortly with
/// authoritative server values. The `name` field is the contract — every
/// observer keys off it.
private fun emptyKanbanShell(): TaskColumnsResponse = TaskColumnsResponse(
columns = listOf(
emptyColumn("overdue_tasks"),
emptyColumn("due_soon_tasks"),
emptyColumn("in_progress_tasks"),
emptyColumn("upcoming_tasks"),
emptyColumn("completed_tasks")
),
daysThreshold = 30,
residenceId = ""
)
private fun emptyColumn(name: String): TaskColumn = TaskColumn(
name = name,
displayName = "",
buttonTypes = emptyList(),
icons = emptyMap(),
color = "",
tasks = emptyList(),
count = 0
)
fun removeTask(taskId: Int) {
// Remove from allTasks
_allTasks.value?.let { current ->
@@ -529,15 +533,6 @@ object DataManager {
_allTasks.value = current.copy(columns = newColumns)
}
// Remove from all residence task caches
_tasksByResidence.value = _tasksByResidence.value.mapValues { (_, tasks) ->
val newColumns = tasks.columns.map { column ->
val filteredTasks = column.tasks.filter { it.id != taskId }
column.copy(tasks = filteredTasks, count = filteredTasks.size)
}
tasks.copy(columns = newColumns)
}
// Refresh summary from updated kanban data (API no longer returns summaries for CRUD)
refreshSummaryFromKanban()
persistToDisk()
@@ -780,7 +775,6 @@ object DataManager {
_totalSummary.value = null
_residenceSummaries.value = emptyMap()
_allTasks.value = null
_tasksByResidence.value = emptyMap()
_documents.value = emptyList()
_documentsByResidence.value = emptyMap()
_contractors.value = emptyList()
@@ -811,7 +805,6 @@ object DataManager {
residencesCacheTime = 0L
myResidencesCacheTime = 0L
tasksCacheTime = 0L
tasksByResidenceCacheTime.clear()
contractorsCacheTime = 0L
documentsCacheTime = 0L
summaryCacheTime = 0L
@@ -833,7 +826,6 @@ object DataManager {
_totalSummary.value = null
_residenceSummaries.value = emptyMap()
_allTasks.value = null
_tasksByResidence.value = emptyMap()
_documents.value = emptyList()
_documentsByResidence.value = emptyMap()
_contractors.value = emptyList()
@@ -846,7 +838,6 @@ object DataManager {
residencesCacheTime = 0L
myResidencesCacheTime = 0L
tasksCacheTime = 0L
tasksByResidenceCacheTime.clear()
contractorsCacheTime = 0L
documentsCacheTime = 0L
summaryCacheTime = 0L
@@ -53,8 +53,14 @@ data class TaskResponse(
@SerialName("is_cancelled") val isCancelled: Boolean = false,
@SerialName("is_archived") val isArchived: Boolean = false,
@SerialName("parent_task_id") val parentTaskId: Int? = null,
// Backlink to the TaskTemplate this task was spawned from (onboarding
// suggestion or browse catalog). Null for user-created custom tasks.
@SerialName("template_id") val templateId: Int? = null,
@SerialName("completion_count") val completionCount: Int = 0,
@SerialName("kanban_column") val kanbanColumn: String? = null, // Which kanban column this task belongs to
// Note: Go API does not return completions inline with TaskResponse.
// Completions are fetched separately via the completions endpoint.
// This field defaults to emptyList() and is only populated client-side after a separate fetch.
val completions: List<TaskCompletionResponse> = emptyList(),
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String
@@ -130,7 +136,33 @@ data class TaskCreateRequest(
@SerialName("assigned_to_id") val assignedToId: Int? = null,
@SerialName("due_date") val dueDate: String? = null,
@SerialName("estimated_cost") val estimatedCost: Double? = null,
@SerialName("contractor_id") val contractorId: Int? = null
@SerialName("contractor_id") val contractorId: Int? = null,
// Set when the task is spawned from a TaskTemplate (onboarding
// suggestion or browse catalog). Null for free-form custom tasks.
@SerialName("template_id") val templateId: Int? = null
)
/**
* Bulk create request matching Go API BulkCreateTasksRequest.
* Used by onboarding to insert 1-50 tasks atomically in a single
* transaction. The server forces every entry's residence_id to the
* top-level value, so any mismatch in the list is silently corrected.
*/
@Serializable
data class BulkCreateTasksRequest(
@SerialName("residence_id") val residenceId: Int,
val tasks: List<TaskCreateRequest>
)
/**
* Bulk create response matching Go API BulkCreateTasksResponse.
* All [tasks] are created-or-none — partial state never reaches the client.
*/
@Serializable
data class BulkCreateTasksResponse(
val tasks: List<TaskResponse>,
val summary: TotalSummary,
@SerialName("created_count") val createdCount: Int
)
/**
@@ -33,8 +33,12 @@ data class Document(
val title: String,
@SerialName("document_type") val documentType: String,
val description: String? = null,
@SerialName("file_url") val fileUrl: String? = null, // URL to the file
@SerialName("media_url") val mediaUrl: String? = null, // Authenticated endpoint: /api/media/document/{id}
// fileUrl: raw storage path (internal). Not included in Go DocumentResponse DTO —
// will always be null from the API. Kept for backward compatibility; prefer mediaUrl.
@SerialName("file_url") val fileUrl: String? = null,
// mediaUrl: authenticated endpoint clients should use (e.g. /api/media/document/{id}).
// This is the URL the Go API actually returns for document access.
@SerialName("media_url") val mediaUrl: String? = null,
@SerialName("file_name") val fileName: String? = null,
@SerialName("file_size") val fileSize: Int? = null,
@SerialName("mime_type") val mimeType: String? = null,
@@ -43,7 +47,7 @@ data class Document(
@SerialName("serial_number") val serialNumber: String? = null,
val vendor: String? = null,
@SerialName("purchase_date") val purchaseDate: String? = null,
@SerialName("purchase_price") val purchasePrice: String? = null,
@SerialName("purchase_price") val purchasePrice: Double? = null,
@SerialName("expiry_date") val expiryDate: String? = null,
// Relationships
@SerialName("residence_id") val residenceId: Int? = null,
@@ -87,7 +91,7 @@ data class DocumentCreateRequest(
@SerialName("serial_number") val serialNumber: String? = null,
val vendor: String? = null,
@SerialName("purchase_date") val purchaseDate: String? = null,
@SerialName("purchase_price") val purchasePrice: String? = null,
@SerialName("purchase_price") val purchasePrice: Double? = null,
@SerialName("expiry_date") val expiryDate: String? = null,
// Relationships
@SerialName("residence_id") val residenceId: Int,
@@ -106,7 +110,7 @@ data class DocumentUpdateRequest(
@SerialName("serial_number") val serialNumber: String? = null,
val vendor: String? = null,
@SerialName("purchase_date") val purchaseDate: String? = null,
@SerialName("purchase_price") val purchasePrice: String? = null,
@SerialName("purchase_price") val purchasePrice: Double? = null,
@SerialName("expiry_date") val expiryDate: String? = null,
// Relationships
@SerialName("task_id") val taskId: Int? = null
@@ -0,0 +1,59 @@
package com.tt.honeyDue.models
/**
* Static option lists for home profile pickers.
* Each entry is a (apiValue, displayLabel) pair.
*/
object HomeProfileOptions {
val heatingTypes = listOf(
"gas_furnace" to "Gas Furnace",
"electric" to "Electric",
"heat_pump" to "Heat Pump",
"boiler" to "Boiler",
"radiant" to "Radiant",
"wood_stove" to "Wood Stove",
"none" to "None"
)
val coolingTypes = listOf(
"central_ac" to "Central AC",
"window_unit" to "Window Unit",
"mini_split" to "Mini Split",
"evaporative" to "Evaporative",
"none" to "None"
)
val waterHeaterTypes = listOf(
"tank_gas" to "Tank (Gas)",
"tank_electric" to "Tank (Electric)",
"tankless" to "Tankless",
"solar" to "Solar",
"heat_pump_wh" to "Heat Pump"
)
val roofTypes = listOf(
"asphalt_shingle" to "Asphalt Shingle",
"metal" to "Metal",
"tile" to "Tile",
"flat_tpo" to "Flat/TPO",
"slate" to "Slate",
"wood_shake" to "Wood Shake"
)
val exteriorTypes = listOf(
"vinyl_siding" to "Vinyl Siding",
"brick" to "Brick",
"stucco" to "Stucco",
"wood" to "Wood",
"stone" to "Stone",
"fiber_cement" to "Fiber Cement"
)
val flooringTypes = listOf(
"hardwood" to "Hardwood",
"carpet" to "Carpet",
"tile" to "Tile",
"laminate" to "Laminate",
"vinyl" to "Vinyl"
)
val landscapingTypes = listOf(
"lawn" to "Lawn",
"xeriscaping" to "Xeriscaping",
"none" to "None"
)
}
@@ -53,6 +53,20 @@ data class ResidenceResponse(
@SerialName("is_active") val isActive: Boolean = true,
@SerialName("overdue_count") val overdueCount: Int = 0,
@SerialName("completion_summary") val completionSummary: CompletionSummary? = null,
@SerialName("heating_type") val heatingType: String? = null,
@SerialName("cooling_type") val coolingType: String? = null,
@SerialName("water_heater_type") val waterHeaterType: String? = null,
@SerialName("roof_type") val roofType: String? = null,
@SerialName("has_pool") val hasPool: Boolean = false,
@SerialName("has_sprinkler_system") val hasSprinklerSystem: Boolean = false,
@SerialName("has_septic") val hasSeptic: Boolean = false,
@SerialName("has_fireplace") val hasFireplace: Boolean = false,
@SerialName("has_garage") val hasGarage: Boolean = false,
@SerialName("has_basement") val hasBasement: Boolean = false,
@SerialName("has_attic") val hasAttic: Boolean = false,
@SerialName("exterior_type") val exteriorType: String? = null,
@SerialName("flooring_primary") val flooringPrimary: String? = null,
@SerialName("landscaping_type") val landscapingType: String? = null,
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String
) {
@@ -94,7 +108,21 @@ data class ResidenceCreateRequest(
val description: String? = null,
@SerialName("purchase_date") val purchaseDate: String? = null,
@SerialName("purchase_price") val purchasePrice: Double? = null,
@SerialName("is_primary") val isPrimary: Boolean? = null
@SerialName("is_primary") val isPrimary: Boolean? = null,
@SerialName("heating_type") val heatingType: String? = null,
@SerialName("cooling_type") val coolingType: String? = null,
@SerialName("water_heater_type") val waterHeaterType: String? = null,
@SerialName("roof_type") val roofType: String? = null,
@SerialName("has_pool") val hasPool: Boolean? = null,
@SerialName("has_sprinkler_system") val hasSprinklerSystem: Boolean? = null,
@SerialName("has_septic") val hasSeptic: Boolean? = null,
@SerialName("has_fireplace") val hasFireplace: Boolean? = null,
@SerialName("has_garage") val hasGarage: Boolean? = null,
@SerialName("has_basement") val hasBasement: Boolean? = null,
@SerialName("has_attic") val hasAttic: Boolean? = null,
@SerialName("exterior_type") val exteriorType: String? = null,
@SerialName("flooring_primary") val flooringPrimary: String? = null,
@SerialName("landscaping_type") val landscapingType: String? = null
)
/**
@@ -118,7 +146,21 @@ data class ResidenceUpdateRequest(
val description: String? = null,
@SerialName("purchase_date") val purchaseDate: String? = null,
@SerialName("purchase_price") val purchasePrice: Double? = null,
@SerialName("is_primary") val isPrimary: Boolean? = null
@SerialName("is_primary") val isPrimary: Boolean? = null,
@SerialName("heating_type") val heatingType: String? = null,
@SerialName("cooling_type") val coolingType: String? = null,
@SerialName("water_heater_type") val waterHeaterType: String? = null,
@SerialName("roof_type") val roofType: String? = null,
@SerialName("has_pool") val hasPool: Boolean? = null,
@SerialName("has_sprinkler_system") val hasSprinklerSystem: Boolean? = null,
@SerialName("has_septic") val hasSeptic: Boolean? = null,
@SerialName("has_fireplace") val hasFireplace: Boolean? = null,
@SerialName("has_garage") val hasGarage: Boolean? = null,
@SerialName("has_basement") val hasBasement: Boolean? = null,
@SerialName("has_attic") val hasAttic: Boolean? = null,
@SerialName("exterior_type") val exteriorType: String? = null,
@SerialName("flooring_primary") val flooringPrimary: String? = null,
@SerialName("landscaping_type") val landscapingType: String? = null
)
/**
@@ -13,6 +13,37 @@ data class TaskCompletionCreateRequest(
val notes: String? = null,
@SerialName("actual_cost") val actualCost: Double? = null,
val rating: Int? = null, // 1-5 star rating
@SerialName("image_urls") val imageUrls: List<String>? = null // Multiple image URLs
@SerialName("upload_ids") val uploadIds: List<Int>? = null // pending_uploads.id values from /api/uploads/presign + direct B2 POST
)
/**
* Presigned upload session — request body for POST /api/uploads/presign.
*
* Category: "completion" | "document_image" | "document_file"
* ContentType: the MIME type the client will upload (must match the policy
* exactly when POSTing to B2).
* ContentLength: byte count of the upload (server permits ±256 bytes slack).
*/
@Serializable
data class PresignUploadRequest(
val category: String,
@SerialName("content_type") val contentType: String,
@SerialName("content_length") val contentLength: Long
)
/**
* Presigned upload session — response from POST /api/uploads/presign.
*
* The client uses [uploadUrl] + [fields] to perform a multipart/form-data
* POST directly to B2, then passes [id] back in the upload_ids[] field of
* the next /api/task-completions/ or /api/documents/ create call.
*/
@Serializable
data class PresignUploadResponse(
val id: Int,
@SerialName("upload_url") val uploadUrl: String,
val fields: Map<String, String>,
val key: String,
@SerialName("expires_at") val expiresAt: String
)
@@ -0,0 +1,24 @@
package com.tt.honeyDue.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* A single task suggestion with relevance scoring from the backend.
*/
@Serializable
data class TaskSuggestionResponse(
val template: TaskTemplate,
@SerialName("relevance_score") val relevanceScore: Double,
@SerialName("match_reasons") val matchReasons: List<String>
)
/**
* Response wrapper for task suggestions endpoint.
*/
@Serializable
data class TaskSuggestionsResponse(
val suggestions: List<TaskSuggestionResponse>,
@SerialName("total_count") val totalCount: Int,
@SerialName("profile_completeness") val profileCompleteness: Double
)
@@ -27,6 +27,7 @@ object APILayer {
private val notificationApi = NotificationApi()
private val subscriptionApi = SubscriptionApi()
private val taskTemplateApi = TaskTemplateApi()
private val uploadApi = UploadApi()
// ==================== Initialization Guards ====================
@@ -588,37 +589,23 @@ object APILayer {
return result
}
/**
* Returns kanban data for a single residence. Single source of truth
* is `_allTasks`; this function ensures it's fresh, then filters.
*
* Replaces the previous 3-path implementation (per-residence cache →
* filter from allTasks → API) that produced inconsistent results
* when the per-residence cache slot was empty but `_allTasks` was
* stale. Phase 3 deletes the per-residence cache entirely.
*/
suspend fun getTasksByResidence(residenceId: Int, forceRefresh: Boolean = false): ApiResult<TaskColumnsResponse> {
// 1. Check residence-specific cache first
if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksByResidenceCacheTime[residenceId] ?: 0L)) {
val cached = DataManager.tasksByResidence.value[residenceId]
if (cached != null) {
return ApiResult.Success(cached)
}
}
val allTasksResult = getTasks(forceRefresh = forceRefresh)
if (allTasksResult is ApiResult.Error) return allTasksResult
// 2. Try filtering from allTasks cache before hitting API (optimization)
// This avoids a redundant API call when we already have all tasks loaded
if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksCacheTime)) {
val filtered = DataManager.getTasksForResidence(residenceId)
if (filtered != null) {
// Cache the filtered result for future use
DataManager.setTasksForResidence(residenceId, filtered)
?: return ApiResult.Error("Tasks unavailable", 0)
return ApiResult.Success(filtered)
}
}
// 3. Fallback: Fetch from API
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
val result = taskApi.getTasksByResidence(token, residenceId)
// Update DataManager on success
if (result is ApiResult.Success) {
DataManager.setTasksForResidence(residenceId, result.data)
}
return result
}
suspend fun createTask(request: TaskCreateRequest): ApiResult<TaskResponse> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
@@ -638,6 +625,31 @@ object APILayer {
}
}
/**
* Atomically creates 1-50 tasks via POST /api/tasks/bulk/. The whole
* batch succeeds or fails together on the server. On success, force-
* refreshes _allTasks from the server — the server is the
* authoritative kanban categorizer, and a single round-trip
* eliminates any drift between the per-task `kanbanColumn` hint and
* the global kanban view.
*
* This is the bug-class fix for gitea#2: the previous per-task
* updateTask loop was a no-op when _allTasks was null (fresh launch
* after onboarding), silently dropping the new tasks from cache.
*/
suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
val result = taskApi.bulkCreateTasks(token, request)
if (result is ApiResult.Success) {
DataManager.setTotalSummary(result.data.summary)
// Authoritative refresh — replaces any placeholder kanban
// shell from updateTask with proper server data.
getTasks(forceRefresh = true)
}
return result
}
suspend fun updateTask(id: Int, request: TaskCreateRequest): ApiResult<TaskResponse> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
val result = taskApi.updateTask(token, id, request)
@@ -772,30 +784,6 @@ object APILayer {
}
}
suspend fun createTaskCompletionWithImages(
request: TaskCompletionCreateRequest,
images: List<ByteArray>,
imageFileNames: List<String>
): ApiResult<TaskCompletionResponse> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
val result = taskCompletionApi.createCompletionWithImages(token, request, images, imageFileNames)
if (result is ApiResult.Success) {
// Update summary from response - eliminates need for separate getSummary call
DataManager.setTotalSummary(result.data.summary)
// The response includes the updated task, update it in DataManager
result.data.data.updatedTask?.let { updatedTask ->
DataManager.updateTask(updatedTask)
}
return ApiResult.Success(result.data.data)
}
return when (result) {
is ApiResult.Error -> result
else -> ApiResult.Error("Unknown error")
}
}
/**
* Get all completions for a specific task
*/
@@ -1201,12 +1189,11 @@ object APILayer {
}
/**
* Get task templates filtered by climate region.
* Accepts either a state abbreviation or ZIP code — backend resolves to climate zone.
* This calls the API directly since regional templates are not cached in seeded data.
* Get personalized task suggestions for a residence based on its home profile.
*/
suspend fun getRegionalTemplates(state: String? = null, zip: String? = null): ApiResult<List<TaskTemplate>> {
return taskTemplateApi.getTemplatesByRegion(state = state, zip = zip)
suspend fun getTaskSuggestions(residenceId: Int): ApiResult<TaskSuggestionsResponse> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
return taskTemplateApi.getTaskSuggestions(token, residenceId)
}
// ==================== Auth Operations ====================
@@ -1365,6 +1352,42 @@ object APILayer {
return result
}
// ==================== Upload Operations ====================
/**
* Direct-to-B2 image upload. The bytes are POSTed straight to Backblaze
* — they never touch our API server. Returns the pending_uploads.id
* which the caller passes back via `upload_ids[]` on the next entity-
* creation call (task completion, document, etc.).
*
* Caller responsibilities:
* - Pre-downsample to a sensible size before calling. Use the
* platform-specific ImageDownsampler (Android) or
* ImageDownsampler.swift (iOS).
* - Pass [contentType] matching the bytes (typically "image/jpeg").
* - Pass a [fileName] for B2's metadata. Need not be unique — the
* server picks the actual storage key.
*
* Errors at either step (presign or B2 POST) surface as ApiResult.Error.
* Partial state (presign succeeded but B2 POST failed) is reaped by
* the server-side cleanup cron within an hour.
*/
suspend fun uploadImage(
category: String,
contentType: String,
bytes: ByteArray,
fileName: String,
): ApiResult<Int> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
return uploadApi.uploadOne(
token = token,
category = category,
contentType = contentType,
data = bytes,
fileName = fileName,
)
}
// ==================== Notification Operations ====================
suspend fun registerDevice(request: DeviceRegistrationRequest): ApiResult<DeviceRegistrationResponse> {
@@ -66,6 +66,31 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
}
}
/**
* Atomically creates 1-50 tasks in a single transaction. Used by
* onboarding and anywhere else that needs "all or nothing" task
* creation. The server overrides every entry's residence_id with the
* top-level request.residenceId.
*/
suspend fun bulkCreateTasks(token: String, request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
return try {
val response = client.post("$baseUrl/tasks/bulk/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun updateTask(token: String, id: Int, request: TaskCreateRequest): ApiResult<WithSummaryResponse<TaskResponse>> {
return try {
val response = client.put("$baseUrl/tasks/$id/") {
@@ -94,47 +94,4 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
}
}
suspend fun createCompletionWithImages(
token: String,
request: TaskCompletionCreateRequest,
images: List<ByteArray> = emptyList(),
imageFileNames: List<String> = emptyList()
): ApiResult<WithSummaryResponse<TaskCompletionResponse>> {
return try {
val response = client.submitFormWithBinaryData(
url = "$baseUrl/task-completions/",
formData = formData {
// Add text fields
append("task_id", request.taskId.toString())
request.completedAt?.let { append("completed_at", it) }
request.actualCost?.let { append("actual_cost", it.toString()) }
request.notes?.let { append("notes", it) }
request.rating?.let { append("rating", it.toString()) }
// Add image files
images.forEachIndexed { index, imageBytes ->
val fileName = imageFileNames.getOrNull(index) ?: "image_$index.jpg"
append(
"images",
imageBytes,
Headers.build {
append(HttpHeaders.ContentType, "image/jpeg")
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"")
}
)
}
}
) {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to create completion with images", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
}
@@ -1,6 +1,7 @@
package com.tt.honeyDue.network
import com.tt.honeyDue.models.TaskTemplate
import com.tt.honeyDue.models.TaskSuggestionsResponse
import com.tt.honeyDue.models.TaskTemplatesGroupedResponse
import io.ktor.client.*
import io.ktor.client.call.*
@@ -85,20 +86,20 @@ class TaskTemplateApi(private val client: HttpClient = ApiClient.httpClient) {
}
/**
* Get templates filtered by climate region.
* Accepts either a state abbreviation or ZIP code — backend resolves to climate zone.
* Get personalized task suggestions for a residence based on its home profile.
* Requires authentication.
*/
suspend fun getTemplatesByRegion(state: String? = null, zip: String? = null): ApiResult<List<TaskTemplate>> {
suspend fun getTaskSuggestions(token: String, residenceId: Int): ApiResult<TaskSuggestionsResponse> {
return try {
val response = client.get("$baseUrl/tasks/templates/by-region/") {
state?.let { parameter("state", it) }
zip?.let { parameter("zip", it) }
val response = client.get("$baseUrl/tasks/suggestions/") {
header("Authorization", "Token $token")
parameter("residence_id", residenceId)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch regional templates", response.status.value)
ApiResult.Error("Failed to fetch task suggestions", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
@@ -0,0 +1,149 @@
package com.tt.honeyDue.network
import com.tt.honeyDue.models.PresignUploadRequest
import com.tt.honeyDue.models.PresignUploadResponse
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.utils.io.core.*
/**
* Three-step direct-to-B2 upload helper.
*
* Step 1: [presign] — call POST /api/uploads/presign on our API. Returns a
* B2 POST policy plus form fields the client needs to perform the
* direct upload.
* Step 2: [postToStorage] — multipart/form-data POST straight to B2.
* Bytes never traverse our API server.
* Step 3: caller invokes the relevant entity-creation endpoint
* (POST /api/task-completions/, POST /api/documents/) with the
* returned upload_id in the `upload_ids` field.
*
* iOS uses its own native equivalent (PresignedUploader.swift) for memory
* reasons — Swift can stream a multipart body without buffering. Android
* uses this Kotlin path which works fine for ≤10 MB images.
*/
class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
private val baseUrl = ApiClient.getBaseUrl()
/** Step 1 — request a signed POST policy. */
suspend fun presign(
token: String,
category: String,
contentType: String,
contentLength: Long,
): ApiResult<PresignUploadResponse> {
return try {
val response = client.post("$baseUrl/uploads/presign/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(PresignUploadRequest(category, contentType, contentLength))
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error(
when (response.status.value) {
413 -> "That photo is too large after resizing."
422 -> "That image format isn't supported."
429 -> "Too many uploads in flight; try again shortly."
else -> "Couldn't start upload (HTTP ${response.status.value})."
},
response.status.value,
)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Network error during presign")
}
}
/**
* Step 2 — POST `data` directly to B2 using the signed policy fields.
*
* The S3 POST policy spec requires every signed field to appear before
* the file part, and `key` + `Content-Type` must match the policy
* exactly. Ktor's MultiPartFormDataContent preserves insertion order
* for the appended parts.
*/
suspend fun postToStorage(
uploadUrl: String,
fields: Map<String, String>,
data: ByteArray,
contentType: String,
fileName: String,
): ApiResult<Unit> {
return try {
val parts = formData {
// Stable order: signed fields first, then file. We rely on
// Ktor preserving the order in which append() is called.
fields.forEach { (k, v) -> append(k, v) }
append(
key = "file",
value = data,
headers = Headers.build {
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"")
append(HttpHeaders.ContentType, contentType)
},
)
}
val response = client.submitFormWithBinaryData(
url = uploadUrl,
formData = parts,
)
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
val body = try {
response.bodyAsText()
} catch (_: Throwable) {
""
}
ApiResult.Error(
"Upload to storage failed (HTTP ${response.status.value}): $body",
response.status.value,
)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Network error during upload")
}
}
/**
* Step 1 + Step 2 in one call. Returns the upload_id the caller passes
* back via upload_ids[] on the entity-creation endpoint.
*
* Errors at either step short-circuit and surface up — the partial
* pending_uploads row created at presign time will be reaped by the
* server-side hourly cleanup cron.
*/
suspend fun uploadOne(
token: String,
category: String,
contentType: String,
data: ByteArray,
fileName: String,
): ApiResult<Int> {
val presignResult = presign(token, category, contentType, data.size.toLong())
val presigned = (presignResult as? ApiResult.Success)?.data
?: return ApiResult.Error(
(presignResult as? ApiResult.Error)?.message ?: "Presign failed",
(presignResult as? ApiResult.Error)?.code,
)
val postResult = postToStorage(
uploadUrl = presigned.uploadUrl,
fields = presigned.fields,
data = data,
contentType = contentType,
fileName = fileName,
)
return when (postResult) {
is ApiResult.Success -> ApiResult.Success(presigned.id)
is ApiResult.Error -> postResult
else -> ApiResult.Error("Upload failed in unknown state")
}
}
}
@@ -373,7 +373,8 @@ fun CompleteTaskDialog(
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
notes = notesWithContractor,
rating = rating,
imageUrls = null // Images uploaded separately and URLs added by handler
// upload_ids populated by the ViewModel after each
// image is uploaded directly to B2.
),
selectedImages
)
@@ -405,7 +405,8 @@ fun CompleteTaskScreen(
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
notes = notesWithContractor,
rating = rating,
imageUrls = null
// upload_ids populated by the ViewModel after each
// image is uploaded directly to B2.
),
selectedImages
)
@@ -0,0 +1,378 @@
package com.tt.honeyDue.ui.screens.onboarding
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.tt.honeyDue.models.HomeProfileOptions
import com.tt.honeyDue.ui.theme.*
import com.tt.honeyDue.viewmodel.OnboardingViewModel
import honeydue.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun OnboardingHomeProfileContent(
viewModel: OnboardingViewModel,
onContinue: () -> Unit,
onSkip: () -> Unit
) {
val heatingType by viewModel.heatingType.collectAsState()
val coolingType by viewModel.coolingType.collectAsState()
val waterHeaterType by viewModel.waterHeaterType.collectAsState()
val roofType by viewModel.roofType.collectAsState()
val hasPool by viewModel.hasPool.collectAsState()
val hasSprinklerSystem by viewModel.hasSprinklerSystem.collectAsState()
val hasSeptic by viewModel.hasSeptic.collectAsState()
val hasFireplace by viewModel.hasFireplace.collectAsState()
val hasGarage by viewModel.hasGarage.collectAsState()
val hasBasement by viewModel.hasBasement.collectAsState()
val hasAttic by viewModel.hasAttic.collectAsState()
val exteriorType by viewModel.exteriorType.collectAsState()
val flooringPrimary by viewModel.flooringPrimary.collectAsState()
val landscapingType by viewModel.landscapingType.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentPadding = PaddingValues(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md)
) {
// Header
item {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
OrganicIconContainer(
icon = Icons.Default.Tune,
size = 80.dp,
iconSize = 40.dp,
gradientColors = listOf(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.tertiary
),
contentDescription = null
)
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
Text(
text = stringResource(Res.string.onboarding_home_profile_title),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
Text(
text = stringResource(Res.string.onboarding_home_profile_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
}
}
// Systems section
item {
ProfileSectionHeader(
icon = Icons.Default.Settings,
title = stringResource(Res.string.onboarding_home_profile_systems)
)
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
}
item {
OptionDropdownChips(
label = "Heating",
options = HomeProfileOptions.heatingTypes,
selectedValue = heatingType,
onSelect = { viewModel.setHeatingType(it) }
)
Spacer(modifier = Modifier.height(OrganicSpacing.md))
}
item {
OptionDropdownChips(
label = "Cooling",
options = HomeProfileOptions.coolingTypes,
selectedValue = coolingType,
onSelect = { viewModel.setCoolingType(it) }
)
Spacer(modifier = Modifier.height(OrganicSpacing.md))
}
item {
OptionDropdownChips(
label = "Water Heater",
options = HomeProfileOptions.waterHeaterTypes,
selectedValue = waterHeaterType,
onSelect = { viewModel.setWaterHeaterType(it) }
)
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
}
// Features section
item {
ProfileSectionHeader(
icon = Icons.Default.Star,
title = stringResource(Res.string.onboarding_home_profile_features)
)
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
}
item {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
) {
ToggleChip(label = "Pool", selected = hasPool, onToggle = { viewModel.setHasPool(!hasPool) })
ToggleChip(label = "Sprinkler System", selected = hasSprinklerSystem, onToggle = { viewModel.setHasSprinklerSystem(!hasSprinklerSystem) })
ToggleChip(label = "Fireplace", selected = hasFireplace, onToggle = { viewModel.setHasFireplace(!hasFireplace) })
ToggleChip(label = "Garage", selected = hasGarage, onToggle = { viewModel.setHasGarage(!hasGarage) })
ToggleChip(label = "Basement", selected = hasBasement, onToggle = { viewModel.setHasBasement(!hasBasement) })
ToggleChip(label = "Attic", selected = hasAttic, onToggle = { viewModel.setHasAttic(!hasAttic) })
ToggleChip(label = "Septic", selected = hasSeptic, onToggle = { viewModel.setHasSeptic(!hasSeptic) })
}
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
}
// Exterior section
item {
ProfileSectionHeader(
icon = Icons.Default.Roofing,
title = stringResource(Res.string.onboarding_home_profile_exterior)
)
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
}
item {
OptionDropdownChips(
label = "Roof Type",
options = HomeProfileOptions.roofTypes,
selectedValue = roofType,
onSelect = { viewModel.setRoofType(it) }
)
Spacer(modifier = Modifier.height(OrganicSpacing.md))
}
item {
OptionDropdownChips(
label = "Exterior",
options = HomeProfileOptions.exteriorTypes,
selectedValue = exteriorType,
onSelect = { viewModel.setExteriorType(it) }
)
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
}
// Interior section
item {
ProfileSectionHeader(
icon = Icons.Default.Weekend,
title = stringResource(Res.string.onboarding_home_profile_interior)
)
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
}
item {
OptionDropdownChips(
label = "Flooring",
options = HomeProfileOptions.flooringTypes,
selectedValue = flooringPrimary,
onSelect = { viewModel.setFlooringPrimary(it) }
)
Spacer(modifier = Modifier.height(OrganicSpacing.md))
}
item {
OptionDropdownChips(
label = "Landscaping",
options = HomeProfileOptions.landscapingTypes,
selectedValue = landscapingType,
onSelect = { viewModel.setLandscapingType(it) }
)
Spacer(modifier = Modifier.height(100.dp)) // Space for bottom button
}
}
// Bottom action area
Surface(
modifier = Modifier.fillMaxWidth(),
shadowElevation = 8.dp
) {
Column(
modifier = Modifier.padding(OrganicSpacing.lg)
) {
OrganicPrimaryButton(
text = stringResource(Res.string.onboarding_continue),
onClick = onContinue,
modifier = Modifier.fillMaxWidth(),
icon = Icons.Default.ArrowForward
)
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
TextButton(
onClick = onSkip,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(Res.string.onboarding_skip),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@Composable
private fun ProfileSectionHeader(
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onBackground
)
}
}
@Composable
private fun OptionDropdownChips(
label: String,
options: List<Pair<String, String>>,
selectedValue: String?,
onSelect: (String?) -> Unit
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(OrganicSpacing.xs))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.xs),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.xs)
) {
options.forEach { (apiValue, displayLabel) ->
val isSelected = selectedValue == apiValue
FilterChip(
selected = isSelected,
onClick = {
onSelect(if (isSelected) null else apiValue)
},
label = {
Text(
text = displayLabel,
style = MaterialTheme.typography.bodySmall
)
},
shape = RoundedCornerShape(OrganicRadius.lg),
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
selectedLabelColor = MaterialTheme.colorScheme.primary
),
border = BorderStroke(
width = 1.dp,
color = if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
)
}
}
}
}
@Composable
private fun ToggleChip(
label: String,
selected: Boolean,
onToggle: () -> Unit
) {
val containerColor by animateColorAsState(
targetValue = if (selected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
},
label = "toggleChipColor"
)
val contentColor by animateColorAsState(
targetValue = if (selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
label = "toggleChipContentColor"
)
FilterChip(
selected = selected,
onClick = onToggle,
label = {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal
)
},
leadingIcon = if (selected) {
{
Icon(
Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
} else null,
shape = RoundedCornerShape(OrganicRadius.xl),
colors = FilterChipDefaults.filterChipColors(
containerColor = containerColor,
labelColor = contentColor,
iconColor = contentColor,
selectedContainerColor = containerColor,
selectedLabelColor = contentColor,
selectedLeadingIconColor = contentColor
),
border = FilterChipDefaults.filterChipBorder(
borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
selectedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
enabled = true,
selected = selected
)
)
}
@@ -103,8 +103,7 @@ fun OnboardingScreen(
if (userIntent == OnboardingIntent.JOIN_EXISTING) {
viewModel.goToStep(OnboardingStep.JOIN_RESIDENCE)
} else {
viewModel.createResidence()
viewModel.goToStep(OnboardingStep.RESIDENCE_LOCATION)
viewModel.goToStep(OnboardingStep.HOME_PROFILE)
}
} else {
viewModel.nextStep()
@@ -118,35 +117,42 @@ fun OnboardingScreen(
if (userIntent == OnboardingIntent.JOIN_EXISTING) {
viewModel.goToStep(OnboardingStep.JOIN_RESIDENCE)
} else {
viewModel.createResidence()
viewModel.goToStep(OnboardingStep.RESIDENCE_LOCATION)
viewModel.goToStep(OnboardingStep.HOME_PROFILE)
}
}
)
OnboardingStep.JOIN_RESIDENCE -> OnboardingJoinResidenceContent(
viewModel = viewModel,
onJoined = { viewModel.nextStep() }
onJoined = { viewModel.completeOnboarding() }
)
OnboardingStep.RESIDENCE_LOCATION -> OnboardingLocationContent(
OnboardingStep.RESIDENCE_LOCATION -> {
// Location step removed — skip to home profile if we land here
LaunchedEffect(Unit) { viewModel.goToStep(OnboardingStep.HOME_PROFILE) }
}
OnboardingStep.HOME_PROFILE -> OnboardingHomeProfileContent(
viewModel = viewModel,
onLocationDetected = { zip ->
viewModel.loadRegionalTemplates(zip)
onContinue = {
viewModel.createResidence()
viewModel.nextStep()
},
onSkip = { viewModel.nextStep() }
onSkip = {
viewModel.createResidence()
viewModel.skipStep()
}
)
OnboardingStep.FIRST_TASK -> OnboardingFirstTaskContent(
viewModel = viewModel,
onTasksAdded = { viewModel.nextStep() }
onTasksAdded = { viewModel.completeOnboarding() }
)
OnboardingStep.SUBSCRIPTION_UPSELL -> OnboardingSubscriptionContent(
onSubscribe = { viewModel.completeOnboarding() },
onSkip = { viewModel.completeOnboarding() }
)
OnboardingStep.SUBSCRIPTION_UPSELL -> {
// Subscription removed from onboarding — app is free
LaunchedEffect(Unit) { viewModel.completeOnboarding() }
}
}
}
}
@@ -164,6 +170,7 @@ private fun OnboardingNavigationBar(
OnboardingStep.WELCOME,
OnboardingStep.JOIN_RESIDENCE,
OnboardingStep.RESIDENCE_LOCATION,
OnboardingStep.HOME_PROFILE,
OnboardingStep.FIRST_TASK,
OnboardingStep.SUBSCRIPTION_UPSELL -> false
else -> true
@@ -172,7 +179,7 @@ private fun OnboardingNavigationBar(
val showSkipButton = when (currentStep) {
OnboardingStep.VALUE_PROPS,
OnboardingStep.JOIN_RESIDENCE,
OnboardingStep.RESIDENCE_LOCATION,
OnboardingStep.HOME_PROFILE,
OnboardingStep.FIRST_TASK,
OnboardingStep.SUBSCRIPTION_UPSELL -> true
else -> false
@@ -182,6 +189,7 @@ private fun OnboardingNavigationBar(
OnboardingStep.WELCOME,
OnboardingStep.JOIN_RESIDENCE,
OnboardingStep.RESIDENCE_LOCATION,
OnboardingStep.HOME_PROFILE,
OnboardingStep.FIRST_TASK,
OnboardingStep.SUBSCRIPTION_UPSELL -> false
else -> true
@@ -195,6 +203,7 @@ private fun OnboardingNavigationBar(
OnboardingStep.VERIFY_EMAIL -> 4
OnboardingStep.JOIN_RESIDENCE -> 4
OnboardingStep.RESIDENCE_LOCATION -> 4
OnboardingStep.HOME_PROFILE -> 4
OnboardingStep.FIRST_TASK -> 4
OnboardingStep.SUBSCRIPTION_UPSELL -> 4
}
@@ -4,11 +4,13 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.models.AuthResponse
import com.tt.honeyDue.models.BulkCreateTasksRequest
import com.tt.honeyDue.models.LoginRequest
import com.tt.honeyDue.models.RegisterRequest
import com.tt.honeyDue.models.ResidenceCreateRequest
import com.tt.honeyDue.models.TaskCreateRequest
import com.tt.honeyDue.models.TaskTemplate
import com.tt.honeyDue.models.TaskSuggestionsResponse
import com.tt.honeyDue.models.TaskTemplatesGroupedResponse
import com.tt.honeyDue.models.VerifyEmailRequest
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.network.APILayer
@@ -37,6 +39,7 @@ enum class OnboardingStep {
VERIFY_EMAIL,
JOIN_RESIDENCE,
RESIDENCE_LOCATION,
HOME_PROFILE,
FIRST_TASK,
SUBSCRIPTION_UPSELL
}
@@ -78,18 +81,67 @@ class OnboardingViewModel : ViewModel() {
private val _joinResidenceState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
val joinResidenceState: StateFlow<ApiResult<Unit>> = _joinResidenceState
// Task creation state
// Task creation state (bulk create)
private val _createTasksState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
val createTasksState: StateFlow<ApiResult<Unit>> = _createTasksState
// Regional templates state
private val _regionalTemplates = MutableStateFlow<ApiResult<List<TaskTemplate>>>(ApiResult.Idle)
val regionalTemplates: StateFlow<ApiResult<List<TaskTemplate>>> = _regionalTemplates
// Grouped templates for the Browse tab on the First-Task screen
private val _templatesGroupedState = MutableStateFlow<ApiResult<TaskTemplatesGroupedResponse>>(ApiResult.Idle)
val templatesGroupedState: StateFlow<ApiResult<TaskTemplatesGroupedResponse>> = _templatesGroupedState
// ZIP code entered during location step (persisted on residence)
// ZIP code entered during onboarding (persisted on residence). Still
// collected so the suggestion service can factor in the user's climate
// zone as its 15th scoring condition.
private val _postalCode = MutableStateFlow("")
val postalCode: StateFlow<String> = _postalCode
// Home profile fields
private val _heatingType = MutableStateFlow<String?>(null)
val heatingType: StateFlow<String?> = _heatingType
private val _coolingType = MutableStateFlow<String?>(null)
val coolingType: StateFlow<String?> = _coolingType
private val _waterHeaterType = MutableStateFlow<String?>(null)
val waterHeaterType: StateFlow<String?> = _waterHeaterType
private val _roofType = MutableStateFlow<String?>(null)
val roofType: StateFlow<String?> = _roofType
private val _hasPool = MutableStateFlow(false)
val hasPool: StateFlow<Boolean> = _hasPool
private val _hasSprinklerSystem = MutableStateFlow(false)
val hasSprinklerSystem: StateFlow<Boolean> = _hasSprinklerSystem
private val _hasSeptic = MutableStateFlow(false)
val hasSeptic: StateFlow<Boolean> = _hasSeptic
private val _hasFireplace = MutableStateFlow(false)
val hasFireplace: StateFlow<Boolean> = _hasFireplace
private val _hasGarage = MutableStateFlow(false)
val hasGarage: StateFlow<Boolean> = _hasGarage
private val _hasBasement = MutableStateFlow(false)
val hasBasement: StateFlow<Boolean> = _hasBasement
private val _hasAttic = MutableStateFlow(false)
val hasAttic: StateFlow<Boolean> = _hasAttic
private val _exteriorType = MutableStateFlow<String?>(null)
val exteriorType: StateFlow<String?> = _exteriorType
private val _flooringPrimary = MutableStateFlow<String?>(null)
val flooringPrimary: StateFlow<String?> = _flooringPrimary
private val _landscapingType = MutableStateFlow<String?>(null)
val landscapingType: StateFlow<String?> = _landscapingType
// Task suggestions state
private val _suggestionsState = MutableStateFlow<ApiResult<TaskSuggestionsResponse>>(ApiResult.Idle)
val suggestionsState: StateFlow<ApiResult<TaskSuggestionsResponse>> = _suggestionsState
// Whether onboarding is complete
private val _isComplete = MutableStateFlow(false)
val isComplete: StateFlow<Boolean> = _isComplete
@@ -106,6 +158,32 @@ class OnboardingViewModel : ViewModel() {
_shareCode.value = code
}
// Home profile setters
fun setHeatingType(value: String?) { _heatingType.value = value }
fun setCoolingType(value: String?) { _coolingType.value = value }
fun setWaterHeaterType(value: String?) { _waterHeaterType.value = value }
fun setRoofType(value: String?) { _roofType.value = value }
fun setHasPool(value: Boolean) { _hasPool.value = value }
fun setHasSprinklerSystem(value: Boolean) { _hasSprinklerSystem.value = value }
fun setHasSeptic(value: Boolean) { _hasSeptic.value = value }
fun setHasFireplace(value: Boolean) { _hasFireplace.value = value }
fun setHasGarage(value: Boolean) { _hasGarage.value = value }
fun setHasBasement(value: Boolean) { _hasBasement.value = value }
fun setHasAttic(value: Boolean) { _hasAttic.value = value }
fun setExteriorType(value: String?) { _exteriorType.value = value }
fun setFlooringPrimary(value: String?) { _flooringPrimary.value = value }
fun setLandscapingType(value: String?) { _landscapingType.value = value }
/**
* Load personalized task suggestions for the given residence.
*/
fun loadSuggestions(residenceId: Int) {
viewModelScope.launch {
_suggestionsState.value = ApiResult.Loading
_suggestionsState.value = APILayer.getTaskSuggestions(residenceId)
}
}
/**
* Move to the next step in the flow
* Flow: Welcome → Features → Name Residence → Create Account → Verify → Tasks → Upsell
@@ -126,12 +204,19 @@ class OnboardingViewModel : ViewModel() {
if (_userIntent.value == OnboardingIntent.JOIN_EXISTING) {
OnboardingStep.JOIN_RESIDENCE
} else {
OnboardingStep.RESIDENCE_LOCATION
OnboardingStep.HOME_PROFILE
}
}
OnboardingStep.JOIN_RESIDENCE -> OnboardingStep.SUBSCRIPTION_UPSELL
OnboardingStep.RESIDENCE_LOCATION -> OnboardingStep.FIRST_TASK
OnboardingStep.FIRST_TASK -> OnboardingStep.SUBSCRIPTION_UPSELL
OnboardingStep.JOIN_RESIDENCE -> {
completeOnboarding()
OnboardingStep.JOIN_RESIDENCE
}
OnboardingStep.RESIDENCE_LOCATION -> OnboardingStep.HOME_PROFILE // Skip past if somehow reached
OnboardingStep.HOME_PROFILE -> OnboardingStep.FIRST_TASK
OnboardingStep.FIRST_TASK -> {
completeOnboarding()
OnboardingStep.FIRST_TASK
}
OnboardingStep.SUBSCRIPTION_UPSELL -> {
completeOnboarding()
OnboardingStep.SUBSCRIPTION_UPSELL
@@ -171,9 +256,9 @@ class OnboardingViewModel : ViewModel() {
fun skipStep() {
when (_currentStep.value) {
OnboardingStep.VALUE_PROPS,
OnboardingStep.HOME_PROFILE -> nextStep()
OnboardingStep.JOIN_RESIDENCE,
OnboardingStep.RESIDENCE_LOCATION,
OnboardingStep.FIRST_TASK -> nextStep()
OnboardingStep.FIRST_TASK,
OnboardingStep.SUBSCRIPTION_UPSELL -> completeOnboarding()
else -> {}
}
@@ -272,7 +357,21 @@ class OnboardingViewModel : ViewModel() {
description = null,
purchaseDate = null,
purchasePrice = null,
isPrimary = true
isPrimary = true,
heatingType = _heatingType.value,
coolingType = _coolingType.value,
waterHeaterType = _waterHeaterType.value,
roofType = _roofType.value,
hasPool = _hasPool.value.takeIf { it },
hasSprinklerSystem = _hasSprinklerSystem.value.takeIf { it },
hasSeptic = _hasSeptic.value.takeIf { it },
hasFireplace = _hasFireplace.value.takeIf { it },
hasGarage = _hasGarage.value.takeIf { it },
hasBasement = _hasBasement.value.takeIf { it },
hasAttic = _hasAttic.value.takeIf { it },
exteriorType = _exteriorType.value,
flooringPrimary = _flooringPrimary.value,
landscapingType = _landscapingType.value
)
)
@@ -300,9 +399,15 @@ class OnboardingViewModel : ViewModel() {
}
/**
* Create selected tasks during onboarding
* Create the user's selected tasks in a single atomic bulk request.
* The server runs the inserts inside one transaction, so either every
* entry lands or none do — no risk of a half-populated kanban board
* if the network flakes mid-batch.
*
* [residenceId] overrides every entry's residence_id on the server side;
* pass the just-created residence's ID.
*/
fun createTasks(taskRequests: List<TaskCreateRequest>) {
fun createTasks(residenceId: Int, taskRequests: List<TaskCreateRequest>) {
viewModelScope.launch {
if (taskRequests.isEmpty()) {
_createTasksState.value = ApiResult.Success(Unit)
@@ -311,31 +416,28 @@ class OnboardingViewModel : ViewModel() {
_createTasksState.value = ApiResult.Loading
var successCount = 0
for (request in taskRequests) {
val result = APILayer.createTask(request)
if (result is ApiResult.Success) {
successCount++
}
}
_createTasksState.value = if (successCount > 0) {
ApiResult.Success(Unit)
} else {
ApiResult.Error("Failed to create tasks")
val request = BulkCreateTasksRequest(
residenceId = residenceId,
tasks = taskRequests
)
_createTasksState.value = when (val result = APILayer.bulkCreateTasks(request)) {
is ApiResult.Success -> ApiResult.Success(Unit)
is ApiResult.Error -> ApiResult.Error(result.message, result.code)
is ApiResult.Loading -> ApiResult.Loading
is ApiResult.Idle -> ApiResult.Idle
}
}
}
/**
* Load regional templates by ZIP code (backend resolves ZIP → state → climate zone).
* Also stores the ZIP code for later use when creating the residence.
* Load the flat template catalog grouped by category. Feeds the Browse
* tab on the First-Task screen; no caching special-case because
* APILayer.getTaskTemplatesGrouped already reads from DataManager first.
*/
fun loadRegionalTemplates(zip: String) {
_postalCode.value = zip
fun loadTemplatesGrouped(forceRefresh: Boolean = false) {
viewModelScope.launch {
_regionalTemplates.value = ApiResult.Loading
_regionalTemplates.value = APILayer.getRegionalTemplates(zip = zip)
_templatesGroupedState.value = ApiResult.Loading
_templatesGroupedState.value = APILayer.getTaskTemplatesGrouped(forceRefresh = forceRefresh)
}
}
@@ -360,8 +462,23 @@ class OnboardingViewModel : ViewModel() {
_createResidenceState.value = ApiResult.Idle
_joinResidenceState.value = ApiResult.Idle
_createTasksState.value = ApiResult.Idle
_regionalTemplates.value = ApiResult.Idle
_templatesGroupedState.value = ApiResult.Idle
_postalCode.value = ""
_heatingType.value = null
_coolingType.value = null
_waterHeaterType.value = null
_roofType.value = null
_hasPool.value = false
_hasSprinklerSystem.value = false
_hasSeptic.value = false
_hasFireplace.value = false
_hasGarage.value = false
_hasBasement.value = false
_hasAttic.value = false
_exteriorType.value = null
_flooringPrimary.value = null
_landscapingType.value = null
_suggestionsState.value = ApiResult.Idle
_isComplete.value = false
}
@@ -2,6 +2,7 @@ package com.tt.honeyDue.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.models.Residence
import com.tt.honeyDue.models.ResidenceCreateRequest
import com.tt.honeyDue.models.TotalSummary
@@ -11,7 +12,10 @@ import com.tt.honeyDue.models.ContractorSummary
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.network.APILayer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class ResidenceViewModel : ViewModel() {
@@ -28,8 +32,24 @@ class ResidenceViewModel : ViewModel() {
private val _updateResidenceState = MutableStateFlow<ApiResult<Residence>>(ApiResult.Idle)
val updateResidenceState: StateFlow<ApiResult<Residence>> = _updateResidenceState
private val _residenceTasksState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle)
val residenceTasksState: StateFlow<ApiResult<TaskColumnsResponse>> = _residenceTasksState
/// Residence-scoped kanban derived from `DataManager.allTasks` filtered
/// by `_currentResidenceId`. Re-emits whenever either upstream changes,
/// so the residence detail screen reacts to new tasks (created or
/// completed elsewhere) without manual refresh. Replaces the previous
/// imperative `_residenceTasksState` that was only written by
/// loadResidenceTasks's API result and stayed stale otherwise.
private val _currentResidenceId = MutableStateFlow<Int?>(null)
val residenceTasksState: StateFlow<ApiResult<TaskColumnsResponse>> =
combine(DataManager.allTasks, _currentResidenceId) { all, id ->
when {
id == null -> ApiResult.Idle
all == null -> ApiResult.Loading
else -> {
val filtered = DataManager.getTasksForResidence(id)
if (filtered != null) ApiResult.Success(filtered) else ApiResult.Loading
}
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ApiResult.Idle)
private val _myResidencesState = MutableStateFlow<ApiResult<MyResidencesResponse>>(ApiResult.Idle)
val myResidencesState: StateFlow<ApiResult<MyResidencesResponse>> = _myResidencesState
@@ -85,13 +105,16 @@ class ResidenceViewModel : ViewModel() {
}
fun resetResidenceTasksState() {
_residenceTasksState.value = ApiResult.Idle
_currentResidenceId.value = null
}
fun loadResidenceTasks(residenceId: Int) {
fun loadResidenceTasks(residenceId: Int, forceRefresh: Boolean = false) {
_currentResidenceId.value = residenceId
// Trigger an _allTasks refresh in the background. The combine flow
// above re-emits Success when allTasks lands, so the screen
// re-renders without needing the result here.
viewModelScope.launch {
_residenceTasksState.value = ApiResult.Loading
_residenceTasksState.value = APILayer.getTasksByResidence(residenceId)
APILayer.getTasks(forceRefresh = forceRefresh)
}
}
@@ -25,38 +25,61 @@ class TaskCompletionViewModel : ViewModel() {
}
/**
* Create task completion with images.
* Create task completion with images, using the presigned-URL upload flow.
*
* For each image: compress, presign + POST direct to B2, collect the
* upload_id. Once all uploads succeed, create the completion with the
* collected upload_ids in a single JSON request. Bytes never traverse
* our API server.
*
* If any individual upload fails, the whole batch fails — partial
* pending_uploads rows are reaped server-side by the hourly cleanup
* cron, so there's nothing to clean up client-side.
*
* @param request The completion request data
* @param images List of ImageData (from platform-specific image pickers)
*/
fun createTaskCompletionWithImages(
request: TaskCompletionCreateRequest,
images: List<com.tt.honeyDue.platform.ImageData> = emptyList()
images: List<com.tt.honeyDue.platform.ImageData> = emptyList(),
) {
viewModelScope.launch {
_createCompletionState.value = ApiResult.Loading
// Compress images and prepare for upload
val compressedImages = images.map { ImageCompressor.compressImage(it) }
val imageFileNames = images.mapIndexed { index, image ->
// Always use .jpg extension since we compress to JPEG
val baseName = image.fileName.ifBlank { "completion_$index" }
if (baseName.endsWith(".jpg", ignoreCase = true) ||
baseName.endsWith(".jpeg", ignoreCase = true)) {
baseName
} else {
// Remove any existing extension and add .jpg
baseName.substringBeforeLast('.', baseName) + ".jpg"
val uploadIds = mutableListOf<Int>()
for ((index, image) in images.withIndex()) {
val compressed = ImageCompressor.compressImage(image)
val fileName = run {
val base = image.fileName.ifBlank { "completion_$index" }
if (base.endsWith(".jpg", ignoreCase = true) ||
base.endsWith(".jpeg", ignoreCase = true)
) base else base.substringBeforeLast('.', base) + ".jpg"
}
val uploadResult = APILayer.uploadImage(
category = "completion",
contentType = "image/jpeg",
bytes = compressed,
fileName = fileName,
)
when (uploadResult) {
is ApiResult.Success -> uploadIds += uploadResult.data
is ApiResult.Error -> {
_createCompletionState.value = ApiResult.Error(uploadResult.message, uploadResult.code)
return@launch
}
else -> {
_createCompletionState.value = ApiResult.Error("Upload failed in unexpected state")
return@launch
}
}
}
// Use APILayer which handles DataManager updates and summary refresh
_createCompletionState.value = APILayer.createTaskCompletionWithImages(
request = request,
images = compressedImages,
imageFileNames = imageFileNames
)
val withUploads = if (uploadIds.isNotEmpty()) {
request.copy(uploadIds = uploadIds.toList())
} else {
request
}
_createCompletionState.value = APILayer.createTaskCompletion(withUploads)
}
}
@@ -17,9 +17,6 @@ class TaskViewModel : ViewModel() {
private val _tasksState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle)
val tasksState: StateFlow<ApiResult<TaskColumnsResponse>> = _tasksState
private val _tasksByResidenceState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle)
val tasksByResidenceState: StateFlow<ApiResult<TaskColumnsResponse>> = _tasksByResidenceState
private val _taskAddNewCustomTaskState = MutableStateFlow<ApiResult<CustomTask>>(ApiResult.Idle)
val taskAddNewCustomTaskState: StateFlow<ApiResult<CustomTask>> = _taskAddNewCustomTaskState
@@ -35,16 +32,6 @@ class TaskViewModel : ViewModel() {
}
}
fun loadTasksByResidence(residenceId: Int, forceRefresh: Boolean = false) {
viewModelScope.launch {
_tasksByResidenceState.value = ApiResult.Loading
_tasksByResidenceState.value = APILayer.getTasksByResidence(
residenceId = residenceId,
forceRefresh = forceRefresh
)
}
}
fun createNewTask(request: TaskCreateRequest) {
println("TaskViewModel: createNewTask called with $request")
viewModelScope.launch {
@@ -0,0 +1,152 @@
package com.tt.honeyDue.data
import com.tt.honeyDue.models.TaskResponse
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.test.BeforeTest
/**
* Regression tests for the gitea#2 task-cache bug:
* `DataManager.updateTask` was a no-op when both `_allTasks` was null AND
* `_tasksByResidence[residenceId]` was empty — exactly the cache state
* after a fresh register-then-bulkCreateTasks flow. The just-created
* tasks would only appear after an app restart.
*
* After the fix, `updateTask` must seed `_allTasks` from empty rather
* than skipping the update.
*/
class DataManagerTaskCacheTest {
@BeforeTest
fun resetState() {
DataManager.clear()
}
/// Onboarding-flow scenario: brand-new user, fresh launch, no kanban
/// has ever been fetched, then a task arrives via bulkCreateTasks →
/// DataManager.updateTask. The new task MUST land in `_allTasks` and
/// be visible to any observer.
@Test
fun updateTask_seedsAllTasks_whenCacheIsEmpty() {
// Given: fresh DataManager, kanban never loaded
assertEquals(null, DataManager.allTasks.value, "_allTasks must start null after clear()")
// When: a new task arrives via the same path bulkCreateTasks uses
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
// Then: _allTasks must contain that task in the right column
val allTasks = DataManager.allTasks.value
assertNotNull(allTasks, "updateTask must seed _allTasks even when it was null")
val upcoming = allTasks.columns.firstOrNull { it.name == "upcoming_tasks" }
assertNotNull(upcoming, "the seeded kanban must include an upcoming_tasks column")
assertTrue(
upcoming.tasks.any { it.id == 1 },
"the new task must land in upcoming_tasks; got columns=${allTasks.columns.map { it.name to it.tasks.map { t -> t.id } }}"
)
assertEquals(upcoming.tasks.size, upcoming.count, "column count must match tasks.size")
}
/// Reasonable-defaults sanity check for the bulk-create scenario:
/// multiple tasks land across different kanban columns and end up
/// distributed correctly. This exercises the upsert when _allTasks
/// was seeded by a previous call.
@Test
fun updateTask_distributesAcrossColumns_whenSeedingThenAdding() {
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "overdue_tasks"))
DataManager.updateTask(sampleTask(id = 2, residenceId = 100, column = "upcoming_tasks"))
DataManager.updateTask(sampleTask(id = 3, residenceId = 100, column = "upcoming_tasks"))
val allTasks = DataManager.allTasks.value
assertNotNull(allTasks)
val overdue = allTasks.columns.first { it.name == "overdue_tasks" }
val upcoming = allTasks.columns.first { it.name == "upcoming_tasks" }
assertEquals(setOf(1), overdue.tasks.map { it.id }.toSet())
assertEquals(setOf(2, 3), upcoming.tasks.map { it.id }.toSet())
}
/// Replacement contract: calling updateTask with the same id twice
/// must not duplicate; the second call replaces the first wherever it
/// lives. Catches the "always-append" implementation mistake.
@Test
fun updateTask_replacesExistingTaskById_acrossColumns() {
DataManager.updateTask(sampleTask(id = 5, residenceId = 100, column = "upcoming_tasks", title = "v1"))
DataManager.updateTask(sampleTask(id = 5, residenceId = 100, column = "in_progress_tasks", title = "v2"))
val allTasks = DataManager.allTasks.value
assertNotNull(allTasks)
val upcoming = allTasks.columns.first { it.name == "upcoming_tasks" }
val inProgress = allTasks.columns.first { it.name == "in_progress_tasks" }
assertTrue(upcoming.tasks.none { it.id == 5 }, "task 5 must move out of upcoming_tasks")
assertEquals(1, inProgress.tasks.count { it.id == 5 }, "task 5 must appear once in in_progress_tasks")
assertEquals("v2", inProgress.tasks.first { it.id == 5 }.title)
}
/// Characterization: getTasksForResidence filters _allTasks by
/// residence id. This is the helper that becomes the primary path
/// for residence-detail in Phase 3 (collapse the dual cache).
@Test
fun getTasksForResidence_filtersAllTasksByResidenceId() {
// Seed _allTasks with tasks across two residences via the upsert path.
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
DataManager.updateTask(sampleTask(id = 2, residenceId = 100, column = "overdue_tasks"))
DataManager.updateTask(sampleTask(id = 3, residenceId = 200, column = "upcoming_tasks"))
val r100 = DataManager.getTasksForResidence(100)
assertNotNull(r100)
val r100Ids = r100.columns.flatMap { it.tasks }.map { it.id }.toSet()
assertEquals(setOf(1, 2), r100Ids)
val r200 = DataManager.getTasksForResidence(200)
assertNotNull(r200)
val r200Ids = r200.columns.flatMap { it.tasks }.map { it.id }.toSet()
assertEquals(setOf(3), r200Ids)
// Counts on each column must match the filtered task lists.
for (column in r100.columns) {
assertEquals(column.tasks.size, column.count, "column ${column.name} count mismatch")
}
}
/// Characterization: residenceId with no tasks returns a non-null
/// shell so the residence-detail screen can distinguish "loading"
/// (null) from "loaded, no tasks" (non-null with empty columns).
@Test
fun getTasksForResidence_returnsEmptyShellForResidenceWithNoTasks() {
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
val r999 = DataManager.getTasksForResidence(999)
assertNotNull(r999, "residence with no tasks must return an empty shell, not null")
assertEquals(0, r999.columns.sumOf { it.tasks.size })
}
/// Characterization: when _allTasks is null entirely (cache never
/// populated), getTasksForResidence returns null — caller must call
/// the API path. Phase 3's getTasksByResidence relies on this.
@Test
fun getTasksForResidence_returnsNullWhenAllTasksIsNull() {
DataManager.clear()
assertEquals(null, DataManager.getTasksForResidence(100))
}
private fun sampleTask(
id: Int,
residenceId: Int,
column: String,
title: String = "Task $id"
) = TaskResponse(
id = id,
residenceId = residenceId,
createdById = 1,
title = title,
kanbanColumn = column,
createdAt = "2026-04-25T00:00:00Z",
updatedAt = "2026-04-25T00:00:00Z"
)
}
@@ -18,15 +18,6 @@ class TaskViewModelTest {
assertIs<ApiResult.Idle>(viewModel.tasksState.value)
}
@Test
fun testInitialTasksByResidenceState() {
// Given
val viewModel = TaskViewModel()
// Then
assertIs<ApiResult.Idle>(viewModel.tasksByResidenceState.value)
}
@Test
fun testInitialAddNewCustomTaskState() {
// Given
@@ -18,6 +18,10 @@ actual object PostHogAnalytics {
// iOS uses Swift PostHogAnalytics.shared.capture() directly
}
actual fun captureException(throwable: Throwable, properties: Map<String, Any>?) {
// iOS exception capture is done in Swift via AnalyticsManager
}
actual fun screen(screenName: String, properties: Map<String, Any>?) {
// iOS uses Swift PostHogAnalytics.shared.screen() directly
}
@@ -29,4 +33,8 @@ actual object PostHogAnalytics {
actual fun flush() {
// iOS uses Swift PostHogAnalytics.shared.flush() directly
}
actual fun setupExceptionHandler() {
// iOS exception handler is set up in Swift via AnalyticsManager.setupExceptionHandler()
}
}
@@ -17,6 +17,10 @@ actual object PostHogAnalytics {
// No-op for web
}
actual fun captureException(throwable: Throwable, properties: Map<String, Any>?) {
// No-op for web
}
actual fun screen(screenName: String, properties: Map<String, Any>?) {
// No-op for web
}
@@ -28,4 +32,8 @@ actual object PostHogAnalytics {
actual fun flush() {
// No-op for web
}
actual fun setupExceptionHandler() {
// No-op for web
}
}
@@ -17,6 +17,10 @@ actual object PostHogAnalytics {
// No-op for desktop
}
actual fun captureException(throwable: Throwable, properties: Map<String, Any>?) {
// No-op for desktop
}
actual fun screen(screenName: String, properties: Map<String, Any>?) {
// No-op for desktop
}
@@ -28,4 +32,8 @@ actual object PostHogAnalytics {
actual fun flush() {
// No-op for desktop
}
actual fun setupExceptionHandler() {
// No-op for desktop
}
}
@@ -17,6 +17,10 @@ actual object PostHogAnalytics {
// No-op for web
}
actual fun captureException(throwable: Throwable, properties: Map<String, Any>?) {
// No-op for web
}
actual fun screen(screenName: String, properties: Map<String, Any>?) {
// No-op for web
}
@@ -28,4 +32,8 @@ actual object PostHogAnalytics {
actual fun flush() {
// No-op for web
}
actual fun setupExceptionHandler() {
// No-op for web
}
}
@@ -0,0 +1,919 @@
# Task Cache Unification Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Make `_allTasks` the single source of truth for tasks; collapse `_tasksByResidence` into a derived view. Fix the bug where tasks created during onboarding don't appear on the Residence Detail screen until app restart (Gitea issue #2).
**Architecture:** The current code has two parallel task caches that must be kept in sync (`_allTasks` for the kanban tab, `_tasksByResidence` per-residence for the residence detail screen). `DataManager.updateTask` is a no-op when either cache is empty, so post-`bulkCreateTasks` the new tasks live only on the server until something forces a fetch. After this change there is exactly one cache (`_allTasks`); residence detail screens observe it and apply an in-memory filter by `residenceId`. Mutations (`createTask`, `bulkCreateTasks`) force a refresh of `_allTasks` from the server to guarantee freshness with one round-trip instead of relying on conditional branches that silently skip.
**Tech Stack:** Kotlin Multiplatform (commonMain), Ktor client, kotlinx.serialization, Combine bridge to SwiftUI iOS, Compose StateFlow on Android. Test framework: `kotlin.test` in commonTest.
**Affected files:**
- `composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt` — remove `_tasksByResidence`, simplify `updateTask`/`removeTask`, add upsert behavior
- `composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt` — change `bulkCreateTasks`, `createTask`, `getTasksByResidence`
- `composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModel.kt` — feed `_residenceTasksState` from a `combine(allTasks, residenceId)` flow
- `composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt` (new) — cache behavior tests
- `iosApp/iosApp/Task/TaskViewModel.swift` — drop `$tasksByResidence` sink, filter `$allTasks` when residence-scoped
- `iosApp/iosApp/Data/DataManagerObservable.swift` — drop `tasksByResidence` `@Published` and its `for await` task
**Out of scope (do not touch):**
- Backend Go API — `/api/tasks/by-residence/:id/` endpoint stays in place untouched (might still be used by web admin)
- Android `ResidenceDetailScreen.kt` — the screen contract (`residenceViewModel.residenceTasksState`) is preserved; only the VM internals change
- Disk persistence schema — kotlinx.serialization is configured with `ignoreUnknownKeys` for forward/backward compat (verified in Task 11)
---
## Pre-flight
### Task 0: Verify clean state and baseline
**Files:** none (read only)
**Step 1: Confirm working tree is clean (or only the expected exception)**
Run: `git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP status --short`
Expected: empty, **or** the only line is `M composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiConfig.kt`. That single file is intentionally on `Environment.LOCAL` for the duration of this work — it stays uncommitted and gets flipped back to `Environment.PROD` in Task 11 Step 5. If anything else shows up, stop and ask the user.
**Step 2: Confirm we're on a feature branch (not master)**
Run: `git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP rev-parse --abbrev-ref HEAD`
Expected: NOT `master`. If `master`, run `git checkout -b fix/task-cache-unification` before continuing.
**Step 3: Run the existing commonTest baseline so we know what currently passes**
Run: `./gradlew :composeApp:testDebugUnitTest`
Expected: BUILD SUCCESSFUL. Note the count — every later run must keep ≥ this count of green tests.
**Step 4: Build iOS to confirm starting point compiles**
Run: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build 2>&1 | tail -20`
Expected: `** BUILD SUCCEEDED **`
No commit — this is verification only.
### Task 0.5: Failing regression XCUITest — reproduce the bug end-to-end
**Goal:** Write a UI test that drives the exact onboarding-to-residence-detail flow from gitea#2 and asserts that tasks appear on the residence detail screen without an app restart. Run it now — it MUST fail. The Phase 1-3 fixes will make it pass; Task 12 re-runs it as the final gate.
**Why before the unit tests:** The unit tests in Phase 1 catch the bug at the cache layer, but the *user-facing* bug is "I tap my residence and see 'no tasks'". A passing UI test is the only thing that proves the user experience is actually fixed. Writing it once up front + running it once at the end is cheaper than running a full UI cycle every iteration.
**Files:**
- Create: `iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift`
- Maybe modify: `iosApp/HoneyDueUITests/AccessibilityIdentifiers.swift` (only if missing IDs are needed — see Step 2)
- Maybe modify: SwiftUI views in `iosApp/iosApp/` (only if the view layer is missing accessibility identifiers — see Step 2)
**Pre-requisites already satisfied by Task 0 setup:**
- iOS app is on `Environment.LOCAL`
- Docker stack is up and healthy at `http://127.0.0.1:8000`
- `DEBUG=true` on the local API → email confirmation code is fixed at `123456` (saves a manual step in the test)
**Step 1: Pick a clean run by wiping prior simulator state**
Run: `xcrun simctl uninstall booted com.myhoneydue.honeyDue.dev`
Run: `docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml down && docker volume rm honeydueapi-go_postgres_data && docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml up -d`
Wait until: `curl -fsS http://127.0.0.1:8000/api/health/` returns 200.
**Step 2: Audit accessibility identifiers along the test path**
The test taps and asserts on these SwiftUI surfaces. Open `iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift` and verify (or add) identifiers for each. Use stable, non-localized strings.
Surfaces to identify:
| Where | Why the test needs it | Suggested ID constant |
|---|---|---|
| Login/Register screen — username, email, password, first/last name fields, "Register" button, "Verify" code field | Drive registration | already in `AccessibilityIdentifiers.Authentication.*` per existing UI tests — verify by grep |
| Onboarding — residence-creation form (name field + Continue) | Drive residence creation | `AccessibilityIdentifiers.Onboarding.residenceNameField`, `.continueButton` (add if missing) |
| Onboarding First-Task screen — "Browse All" tab button | Switch from suggestions to browse | `AccessibilityIdentifiers.Onboarding.browseAllTab` (add if missing) |
| Onboarding First-Task screen — each template row (selectable) | Pick 3 tasks | `AccessibilityIdentifiers.Onboarding.templateRowPrefix` (e.g., `"onboarding.template.<id>"`) — see how `OnboardingFirstTaskView.swift` renders rows; add an `.accessibilityIdentifier(...)` keyed on `template.id` |
| Onboarding First-Task screen — Submit button | Trigger bulk-create | `AccessibilityIdentifiers.Onboarding.submitTasksButton` (add if missing) |
| Residence list / home — the residence cell | Tap into detail | `AccessibilityIdentifiers.Residence.cellPrefix` (e.g., `"residence.cell.<name>"` or `<id>`) — verify in `ResidenceListView` or wherever the post-onboarding landing screen renders cells |
| Residence detail — task row | Assert presence | `AccessibilityIdentifiers.Task.rowPrefix` (e.g., `"task.row"`) — verify the task list inside `TasksSectionContainer` in `ResidenceDetailView.swift:538` |
| Residence detail — empty state ("No tasks" copy) | Assert ABSENCE | `AccessibilityIdentifiers.Task.noTasksLabel` (add if missing) — find the empty-state copy in the residence-detail tasks section and pin an identifier on it |
For each missing ID, add it in two places:
1. The constant in `AccessibilityIdentifiers.swift`
2. `.accessibilityIdentifier(AccessibilityIdentifiers.X.Y)` on the SwiftUI view
Keep these app-side additions in **a single dedicated commit** so reviewers can see "test scaffolding only, no behavior change":
```bash
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add -A
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "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."
```
**Step 3: Write the failing UI test**
Create `iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift`:
```swift
import XCTest
/// Regression test for gitea#2.
///
/// Onboarding flow: register create residence pick 3 tasks submit.
/// After submit, the user lands on the home/residences screen. They tap
/// the new residence WITHOUT visiting the Tasks tab first (the Tasks tab
/// triggers a `getTasks()` that masks the bug by populating `_allTasks`).
///
/// Expected: residence detail shows 1 task row within 10s.
/// Pre-fix: residence detail shows empty state ("no tasks") forever
/// until the app is restarted.
final class Suite11_TaskCacheRegressionTests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
@MainActor
func test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart() throws {
let app = XCUIApplication()
app.launchArguments += ["UI-Testing"]
app.launch()
// 1. Register a fresh user. Email confirmation code is fixed at "123456"
// in DEBUG mode (DEBUG_FIXED_CODES=true on the local docker stack).
let stamp = String(Int(Date().timeIntervalSince1970))
UITestHelpers.register(
in: app,
username: "uitest\(stamp)",
email: "uitest+\(stamp)@treymail.com",
password: "UItest\(stamp)!aZ",
confirmationCode: "123456"
)
// 2. Onboarding: create the residence.
UITestHelpers.completeResidenceCreation(in: app, name: "UI Test Property")
// 3. Switch to "Browse All" tab and pick 3 templates. The "For You"
// suggestions tab depends on a server-side recommendation that
// might be empty for a freshly created residence; Browse is
// deterministic.
let browseTab = app.buttons[AccessibilityIdentifiers.Onboarding.browseAllTab]
XCTAssertTrue(browseTab.waitForExistence(timeout: 5),
"Browse All tab must appear on First-Task screen")
browseTab.tap()
let templates = app.buttons.matching(
NSPredicate(format: "identifier BEGINSWITH %@",
AccessibilityIdentifiers.Onboarding.templateRowPrefix)
)
// Wait for the catalog to load fresh API call against local backend.
XCTAssertTrue(templates.element(boundBy: 0).waitForExistence(timeout: 10),
"Template catalog must load")
for i in 0..<3 {
templates.element(boundBy: i).tap()
}
// 4. Submit. This calls APILayer.bulkCreateTasks POST /api/tasks/bulk/
// The bug lives in the cache update path between this call returning
// and the residence detail screen rendering.
let submit = app.buttons[AccessibilityIdentifiers.Onboarding.submitTasksButton]
XCTAssertTrue(submit.waitForExistence(timeout: 3))
submit.tap()
// 5. Land on home/residences. Tap the residence we just created.
// Critical: do NOT visit the Tasks tab that would call getTasks()
// and populate _allTasks via setAllTasks, masking the bug.
let residenceCell = app.buttons[
AccessibilityIdentifiers.Residence.cellPrefix + "UI Test Property"
]
XCTAssertTrue(residenceCell.waitForExistence(timeout: 10),
"Residence cell must appear on home after onboarding submit")
residenceCell.tap()
// 6. Residence detail must show 1 task row, NOT the empty state.
// Generous timeout (10s) covers the network round-trip on slow
// local Docker startups.
let firstTaskRow = app.cells
.matching(NSPredicate(format: "identifier BEGINSWITH %@",
AccessibilityIdentifiers.Task.rowPrefix))
.firstMatch
XCTAssertTrue(
firstTaskRow.waitForExistence(timeout: 10),
"Tasks created during onboarding must appear on residence detail without restart (gitea#2)"
)
let emptyState = app.staticTexts[AccessibilityIdentifiers.Task.noTasksLabel]
XCTAssertFalse(
emptyState.exists,
"Empty 'no tasks' state must NOT show when tasks exist (gitea#2)"
)
// 7. Cleanup delete the test user via UI (or skip; clearing the
// docker volume between runs is the cheaper reset).
}
}
```
**Notes for the engineer writing this test:**
- `UITestHelpers.register(...)` and `UITestHelpers.completeResidenceCreation(...)` may not exist verbatim — read `iosApp/HoneyDueUITests/UITestHelpers.swift` for the existing helpers. If `register(...)` exists but doesn't take a `confirmationCode:` arg, either add one or inline the verification step.
- DO NOT use `sleep()` anywhere. Use `waitForExistence(timeout:)` everywhere. The skill `axiom-ui-testing` is loaded if you need patterns.
- `continueAfterFailure = false` so we stop at the exact assertion that fails — easier to triage video.
- If you can't get a residence cell identifier reliably (e.g., the home screen shows a custom layout, not standard cells), substitute `app.staticTexts["UI Test Property"]` and tap that. The point is to land on the residence detail without going through the Tasks tab.
**Step 4: Run the test — must FAIL**
Run:
```
xcodebuild -project iosApp/honeyDue.xcodeproj \
-scheme HoneyDueUITests \
-sdk iphonesimulator \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-only-testing:HoneyDueUITests/Suite11_TaskCacheRegressionTests/test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart \
test 2>&1 | tail -40
```
Expected: `Test Suite '...' failed.` with the assertion **"Tasks created during onboarding must appear on residence detail without restart (gitea#2)"**.
If it FAILS for a different reason (residence cell not found, timeout on browse tab, etc.) → that's an accessibility-identifier mismatch, not a bug repro. Fix the test/IDs and re-run. The test must fail SPECIFICALLY on the "no tasks on residence detail" assertion to be a valid bug capture.
If it PASSES → the bug isn't reproducing in this environment. Possibilities:
- App was already cached with `_allTasks` from a prior run (re-run Step 1 to fully wipe simulator + DB)
- The user navigated through the Tasks tab implicitly (check the home screen layout)
- The bug only happens on a code path you didn't replicate (re-read the iOS-side onboarding flow)
**Step 5: Commit the failing test**
```bash
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: failing — onboarding tasks must appear on residence detail without restart
Captures gitea#2 at the user-visible level. The kanban tab works but
the residence detail screen does not, until the app is restarted. This
test must FAIL at this commit and PASS after the cache unification work.
Re-run gates the merge in Task 12."
```
The test stays failing through Phase 1-3 commits. Don't run it on every commit — it's slow. Run it once at the end (Task 12).
---
## Phase 1 — TDD: catch the bug, then fix it
### Task 1: Failing test — `bulkCreateTasks` must populate `_allTasks`
**Files:**
- Create: `composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt`
**Step 1: Write the failing test**
This test reproduces the onboarding bug at the cache layer. We can't easily mock Ktor here without infrastructure, so we test the cache mutation contract directly: after a successful bulk-create, `_allTasks` MUST contain every returned task, regardless of prior cache state.
```kotlin
package com.tt.honeyDue.data
import com.tt.honeyDue.models.TaskResponse
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.test.BeforeTest
class DataManagerTaskCacheTest {
@BeforeTest
fun resetState() {
DataManager.clearAllData()
}
@Test
fun `updateTask seeds _allTasks when cache is empty`() {
// Given: fresh DataManager with no tasks loaded (the onboarding scenario)
assertEquals(null, DataManager.allTasks.value)
// When: a new task arrives via the same path bulkCreateTasks uses
val task = TaskResponse(
id = 1,
residenceId = 100,
title = "Replace HVAC filter",
kanbanColumn = "upcoming_tasks",
// ... fill remaining required TaskResponse fields with sensible defaults
)
DataManager.updateTask(task)
// Then: _allTasks is populated with this task in the right column
val allTasks = DataManager.allTasks.value
assertNotNull(allTasks, "updateTask must seed _allTasks even when it was null")
val upcomingColumn = allTasks.columns.firstOrNull { it.name == "upcoming_tasks" }
assertNotNull(upcomingColumn)
assertTrue(
upcomingColumn.tasks.any { it.id == 1 },
"task must land in the upcoming_tasks column"
)
}
}
```
You'll need to look at `TaskResponse` in `composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/CustomTask.kt` to fill the required fields. Use defaults that match an onboarding-created task (no completion, no priority, due-soon date).
**Step 2: Run the test — must FAIL**
Run: `./gradlew :composeApp:testDebugUnitTest --tests "com.tt.honeyDue.data.DataManagerTaskCacheTest.updateTask seeds _allTasks when cache is empty"`
Expected: FAIL with `expected:<not null> but was:<null>` (or similar). This proves the test catches the bug.
**Step 3: Commit the failing test**
```bash
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: failing — DataManager.updateTask must seed _allTasks"
```
### Task 2: Make `DataManager.updateTask` a real upsert
**Files:**
- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt:482-520`
**Step 1: Replace the conditional branch on `_allTasks` with an upsert**
Current (lines 484-498):
```kotlin
_allTasks.value?.let { current ->
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
val newColumns = current.columns.map { column -> ... }
_allTasks.value = current.copy(columns = newColumns)
}
```
Replace with:
```kotlin
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
val current = _allTasks.value ?: TaskColumnsResponse(
columns = standardKanbanColumns(), // see Step 2
daysThreshold = 30,
residenceId = null
)
val newColumns = current.columns.map { column ->
val filteredTasks = column.tasks.filter { it.id != task.id }
val updatedTasks = if (column.name == targetColumn) filteredTasks + task else filteredTasks
column.copy(tasks = updatedTasks, count = updatedTasks.size)
}
// If targetColumn doesn't exist in current.columns (e.g. fresh seed), append it
val finalColumns = if (newColumns.none { it.name == targetColumn }) {
newColumns + Column(name = targetColumn, tasks = listOf(task), count = 1, /* fill rest */)
} else newColumns
_allTasks.value = current.copy(columns = finalColumns)
```
**Step 2: Add `standardKanbanColumns()` helper**
Look at the backend response — `internal/repositories/task_repo.go` `GetKanbanDataForMultipleResidences` defines the column order. Mirror it in Kotlin:
```kotlin
private fun standardKanbanColumns(): List<Column> = listOf(
Column(name = "overdue_tasks", tasks = emptyList(), count = 0, /* defaults */),
Column(name = "due_soon_tasks", tasks = emptyList(), count = 0, /* defaults */),
Column(name = "in_progress_tasks", tasks = emptyList(), count = 0, /* defaults */),
Column(name = "upcoming_tasks", tasks = emptyList(), count = 0, /* defaults */),
Column(name = "completed_tasks", tasks = emptyList(), count = 0, /* defaults */),
// archived/cancelled are hidden from kanban — see honeyDueAPI-go/CLAUDE.md
)
```
Look at `Column` in `CustomTask.kt` for its required fields (display label, color, etc.). Fill in matching defaults.
**Step 3: Drop the second branch (`_tasksByResidence`) — it's going away in Phase 3**
Remove lines 500-515 entirely. The `_tasksByResidence` slot is still there for now (Phase 3 deletes it), but `updateTask` should not write to it any more.
**Step 4: Run the test — must PASS**
Run: `./gradlew :composeApp:testDebugUnitTest --tests "com.tt.honeyDue.data.DataManagerTaskCacheTest.updateTask seeds _allTasks when cache is empty"`
Expected: PASS
**Step 5: Run the full test suite to confirm no regressions**
Run: `./gradlew :composeApp:testDebugUnitTest`
Expected: same green count as Task 0 baseline + 1 new test.
**Step 6: Commit**
```bash
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "fix: DataManager.updateTask seeds _allTasks when cache is empty
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 if needed and places the task in its target column.
Adds an unknown column at the end for forward compatibility with future
column names from the server.
Refs gitea#2"
```
### Task 3: Add upsert test for `_tasksByResidence` deletion guard
**Files:**
- Modify: `composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt`
**Step 1: Add a test asserting `updateTask` does NOT touch `_tasksByResidence` any more**
```kotlin
@Test
fun `updateTask no longer mutates _tasksByResidence`() {
val before = DataManager.tasksByResidence.value
DataManager.updateTask(/* sample task */)
assertEquals(before, DataManager.tasksByResidence.value,
"updateTask must not touch _tasksByResidence — it's deprecated")
}
```
**Step 2: Run — must PASS** (we already removed the branch in Task 2)
Run: `./gradlew :composeApp:testDebugUnitTest --tests "com.tt.honeyDue.data.DataManagerTaskCacheTest.updateTask no longer mutates _tasksByResidence"`
Expected: PASS
**Step 3: Commit**
```bash
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: lock down that updateTask no longer writes _tasksByResidence"
```
---
## Phase 2 — Belt-and-suspenders: force-refresh after mutations
The Phase 1 upsert handles the fresh-cache case correctly, but it makes assumptions about kanban column placement based on the response's `kanbanColumn` field. The server is the authoritative kanban categorizer. To eliminate any drift, also force a `_allTasks` refresh after multi-task mutations.
### Task 4: Force `_allTasks` refresh after `bulkCreateTasks`
**Files:**
- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt:647-655`
**Step 1: Add post-success refresh**
Current:
```kotlin
suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
val result = taskApi.bulkCreateTasks(token, request)
if (result is ApiResult.Success) {
DataManager.setTotalSummary(result.data.summary)
result.data.tasks.forEach { DataManager.updateTask(it) }
}
return result
}
```
New:
```kotlin
suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
val result = taskApi.bulkCreateTasks(token, request)
if (result is ApiResult.Success) {
DataManager.setTotalSummary(result.data.summary)
// Authoritative refresh — server knows the right kanban placement.
// Cheap (one round-trip) and eliminates any client-side drift between
// the per-task kanbanColumn hint and the global kanban view.
getTasks(forceRefresh = true)
}
return result
}
```
Drop the `forEach { updateTask }` — it becomes redundant with the force-refresh.
**Step 2: Run the full test suite**
Run: `./gradlew :composeApp:testDebugUnitTest`
Expected: all green (the Phase 1 upsert tests still pass because they exercise `updateTask` directly, not `bulkCreateTasks`).
**Step 3: Commit**
```bash
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "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.
Refs gitea#2"
```
---
## Phase 3 — Collapse the dual cache
### Task 5: Characterization test for `getTasksForResidence`
`getTasksForResidence` already implements the filter we want to use everywhere. Lock it down with a test before we make it the primary path.
**Files:**
- Modify: `composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt`
**Step 1: Add the test**
```kotlin
@Test
fun `getTasksForResidence filters _allTasks by residence id`() {
DataManager.setAllTasks(/* response with tasks across residences 100 and 200 */)
val r100 = DataManager.getTasksForResidence(100)
assertNotNull(r100)
assertTrue(r100.columns.flatMap { it.tasks }.all { it.residenceId == 100 })
val r999 = DataManager.getTasksForResidence(999)
assertNotNull(r999)
assertEquals(0, r999.columns.sumOf { it.tasks.size }) // valid id, just no tasks
}
@Test
fun `getTasksForResidence returns null when _allTasks is null`() {
DataManager.clearAllData()
assertEquals(null, DataManager.getTasksForResidence(100))
}
```
**Step 2: Run — must PASS** (no implementation change yet)
Run: `./gradlew :composeApp:testDebugUnitTest --tests "com.tt.honeyDue.data.DataManagerTaskCacheTest.getTasksForResidence*"`
Expected: PASS for both.
**Step 3: Commit**
```bash
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: characterize getTasksForResidence filter contract"
```
### Task 6: Simplify `APILayer.getTasksByResidence`
**Files:**
- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt:591-621`
**Step 1: Replace the 3-path implementation with "ensure _allTasks fresh, then filter"**
```kotlin
suspend fun getTasksByResidence(residenceId: Int, forceRefresh: Boolean = false): ApiResult<TaskColumnsResponse> {
// Ensure _allTasks is loaded and reasonably fresh.
// getTasks itself respects forceRefresh and the global tasksCacheTime.
val allTasksResult = getTasks(forceRefresh = forceRefresh)
if (allTasksResult is ApiResult.Error) return allTasksResult
val filtered = DataManager.getTasksForResidence(residenceId)
?: return ApiResult.Error("Tasks unavailable", 0)
return ApiResult.Success(filtered)
}
```
This deletes the per-residence cache reliance entirely. `_tasksByResidence` is no longer written by this path.
**Step 2: Run the test suite**
Run: `./gradlew :composeApp:testDebugUnitTest`
Expected: all green.
**Step 3: Commit**
```bash
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "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."
```
### Task 7: iOS — `TaskViewModel` observes `$allTasks` with filter
**Files:**
- Modify: `iosApp/iosApp/Task/TaskViewModel.swift:46-74`
**Step 1: Replace dual-sink with single-sink + filter**
Current logic uses two Combine sinks: `$allTasks` (only when `currentResidenceId == nil`) and `$tasksByResidence` (only when set).
Replace with one sink on `$allTasks` that conditionally filters:
```swift
DataManagerObservable.shared.$allTasks
.receive(on: DispatchQueue.main)
.sink { [weak self] allTasks in
guard let self else { return }
guard !self.isAnimatingCompletion else { return }
if let allTasks {
if let resId = self.currentResidenceId {
self.tasksResponse = self.filterByResidence(allTasks, residenceId: resId)
} else {
self.tasksResponse = allTasks
}
self.isLoadingTasks = false
}
}
.store(in: &cancellables)
```
**Step 2: Add the `filterByResidence` helper**
```swift
private func filterByResidence(_ response: TaskColumnsResponse, residenceId: Int32) -> TaskColumnsResponse {
let filteredColumns = response.columns.map { column -> Column in
let filteredTasks = column.tasks.filter { Int32($0.residenceId ?? 0) == residenceId }
return column.copy(tasks: filteredTasks, count: Int32(filteredTasks.count))
}
return response.copy(columns: filteredColumns, residenceId: String(residenceId))
}
```
(Use `.doCopy(...)` SKIE syntax if `.copy` isn't directly callable from Swift — check what other Swift code does with TaskColumnsResponse copies.)
**Step 3: Drop the `$tasksByResidence` subscription block entirely**
Remove the second `.sink` on `$tasksByResidence` (currently lines 62-74).
**Step 4: Build iOS**
Run: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build 2>&1 | tail -20`
Expected: `** BUILD SUCCEEDED **`
**Step 5: Run iOS unit tests**
Run: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:HoneyDueTests test 2>&1 | tail -20`
Expected: TEST SUCCEEDED.
**Step 6: Commit**
```bash
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add iosApp/iosApp/Task/TaskViewModel.swift
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "ios: TaskViewModel observes \$allTasks and filters by residence in-memory
Single source of truth eliminates the race window where the residence
detail screen could mount before the per-residence cache slot existed.
After this, every emit of _allTasks rerenders every observing view —
kanban tab, residence detail, dashboards — atomically.
Refs gitea#2"
```
### Task 8: Android — `ResidenceViewModel` feeds `residenceTasksState` from a combined flow
**Files:**
- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModel.kt:31-94`
**Step 1: Replace the imperative `loadResidenceTasks` with a derived flow**
Look at how `_residenceTasksState` is currently populated (line 88-94). Instead of imperatively calling `APILayer.getTasksByResidence` and storing the result, derive it:
```kotlin
private val _currentResidenceId = MutableStateFlow<Int?>(null)
val residenceTasksState: StateFlow<ApiResult<TaskColumnsResponse>> =
combine(DataManager.allTasks, _currentResidenceId) { all, id ->
when {
id == null -> ApiResult.Idle
all == null -> ApiResult.Loading
else -> {
val filtered = DataManager.getTasksForResidence(id)
if (filtered != null) ApiResult.Success(filtered) else ApiResult.Loading
}
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ApiResult.Idle)
fun loadResidenceTasks(residenceId: Int, forceRefresh: Boolean = false) {
viewModelScope.launch {
_currentResidenceId.value = residenceId
// Trigger the underlying _allTasks refresh; the combine above
// re-emits Success when allTasks arrives.
APILayer.getTasks(forceRefresh = forceRefresh)
}
}
```
The screen contract (`residenceViewModel.residenceTasksState`) is preserved — `ResidenceDetailScreen.kt:59` doesn't need any change.
**Step 2: Build Android debug**
Run: `./gradlew :composeApp:assembleDebug`
Expected: BUILD SUCCESSFUL.
**Step 3: Run commonTest again**
Run: `./gradlew :composeApp:testDebugUnitTest`
Expected: all green. (`ResidenceViewModelTest` may need adjusting — check it.)
**Step 4: Commit**
```bash
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModel.kt
# Also stage any test fixes
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "android: ResidenceViewModel.residenceTasksState derives from _allTasks
Same screen contract, but the data flows from DataManager.allTasks
through a combine(...) into the existing StateFlow. No per-residence
network call needed; the upstream getTasks() refresh propagates."
```
### Task 9: Delete dead code — `_tasksByResidence` and friends
**Files:**
- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt`
- Modify: `iosApp/iosApp/Data/DataManagerObservable.swift`
**Step 1: In DataManager.kt, delete:**
- `_tasksByResidence` (line 141) and `tasksByResidence` (line 142)
- `tasksByResidenceCacheTime` (line 65)
- `setTasksForResidence` (lines 448-451)
- `invalidateTasksFor` (line 417 — verify it has no other callers first via grep)
- `_tasksByResidence` mutations in `removeTask` (lines 533-onwards) — keep only the `_allTasks` removal
- `_tasksByResidence.value = emptyMap()` in clearAllData and similar wipes (lines 783, 836)
- `tasksByResidenceCacheTime.clear()` in same wipes (lines 814, 849)
Keep `getTasksForResidence` — it's the public filter API, still used by the new `getTasksByResidence` and Android VM.
**Step 2: In DataManagerObservable.swift, delete:**
- `@Published var tasksByResidence: [Int32: TaskColumnsResponse] = [:]` (line 44)
- The `for await tasks in DataManager.shared.tasksByResidence` task (lines 195-201)
- The `tasksByResidence[residenceId]` reader at line 524 (replace with `DataManager.shared.getTasksForResidence(residenceId)` or its iOS-friendly equivalent if anything still calls this — grep first)
**Step 3: Compile both targets**
Run: `./gradlew :composeApp:assembleDebug && ./gradlew :composeApp:testDebugUnitTest`
Then: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build 2>&1 | tail -20`
Expected: both BUILD SUCCEEDED, all tests green.
If anything fails to compile, follow the compiler — there's likely a missed reader. Common suspects: `TaskViewModel.kt` (Kotlin VM, not Swift) line ~38-42 references `_tasksByResidenceState`; verify it's still wired correctly or also delete it.
**Step 4: Commit**
```bash
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add -A
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "refactor: delete _tasksByResidence and per-residence task cache plumbing
All readers and writers gone after the previous commits. Single source
of truth = DataManager._allTasks, residence views derive via
getTasksForResidence(id). Net deletion ~100 LOC across DataManager,
APILayer, DataManagerObservable, and iOS TaskViewModel.
Closes gitea#2"
```
---
## Phase 4 — Verification
### Task 10: Verify disk persistence is forward-compatible
**Files:** none (verification only)
**Step 1: Find the persistence model**
Run: `grep -rn "ignoreUnknownKeys\|Json {\|tasksByResidence" composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/PersistenceManager.kt`
Expected: kotlinx.serialization Json config with `ignoreUnknownKeys = true`. If NOT, an existing user upgrading the app will crash on first launch when the persisted blob has the now-removed `tasksByResidence` field.
**Step 2: If `ignoreUnknownKeys` is missing, ADD IT BEFORE SHIPPING**
Edit the Json config:
```kotlin
val json = Json {
ignoreUnknownKeys = true
// ... existing config
}
```
Commit separately as `chore: persistence Json must ignoreUnknownKeys`.
**Step 3: Manual test — wipe simulator app data, install old app, then this version**
If you have a TestFlight build of the previous version:
1. Install old version → register → create residence → quit
2. Update to this build → launch → confirm no crash, data loads
3. Quit → relaunch → confirm persistence works correctly
If no old TestFlight build available, skip this empirical check but the `ignoreUnknownKeys` setting is sufficient.
### Task 11: Manual smoke — the actual bug repro
**Files:** none (manual test)
**Step 1: Wipe simulator state for the dev build**
Run: `xcrun simctl uninstall booted com.myhoneydue.honeyDue.dev`
**Step 2: Confirm iOS is on LOCAL (set during pre-flight, stays uncommitted)**
Run: `grep "CURRENT_ENV" /Users/treyt/Desktop/code/honeyDue/honeyDueKMP/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiConfig.kt`
Expected: `val CURRENT_ENV = Environment.LOCAL`. If it's not, edit the file. DO NOT COMMIT — revert in Step 5.
**Step 3: Build and install**
Run: `./gradlew :composeApp:assembleDebug` and Xcode build to simulator.
**Step 4: Reproduce the original bug path**
1. Launch app → land on register screen
2. Register a fresh user with a unique email
3. Onboarding: create residence → choose 3+ tasks from the catalog → submit
4. Land on home/dashboard
5. Navigate to the new residence's detail screen WITHOUT visiting the Tasks tab first
6. **Expected: tasks visible immediately. No "no tasks" state. No restart needed.**
If the bug still reproduces, return to Phase 1 — the upsert or refresh isn't working. Capture iOS console with `xclog launch com.myhoneydue.honeyDue.dev` and inspect.
**Step 5: Revert the ApiConfig change**
Edit `ApiConfig.kt` back to `Environment.PROD` (or whatever it was). Confirm with `git diff`. Do not commit.
### Task 12: Final regression sweep
**Files:** none (verification only)
**Step 1: Full Kotlin test suite green**
Run: `./gradlew :composeApp:testDebugUnitTest`
**Step 2: iOS unit tests green**
Run: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme HoneyDue -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:HoneyDueTests test 2>&1 | tail -20`
**Step 3: The Task 0.5 regression XCUITest now passes**
Wipe state for a clean run (mirrors Task 0.5 Step 1):
```
xcrun simctl uninstall booted com.myhoneydue.honeyDue.dev
docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml down
docker volume rm honeydueapi-go_postgres_data
docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml up -d
```
Wait for `curl -fsS http://127.0.0.1:8000/api/health/` → 200. Then re-run the regression test:
```
xcodebuild -project iosApp/honeyDue.xcodeproj \
-scheme HoneyDueUITests \
-sdk iphonesimulator \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-only-testing:HoneyDueUITests/Suite11_TaskCacheRegressionTests/test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart \
test 2>&1 | tail -20
```
Expected: **TEST SUCCEEDED**. The "Tasks created during onboarding must appear on residence detail without restart" assertion now holds.
**If it FAILS:** the cache fix is incomplete. Inspect the test report video (`xcrun xcresulttool get --path build/reports/...xcresult ...`) and follow the failure point. Common causes: missed updateTask call site in the dual-cache deletion, a residual reader of `_tasksByResidence` in iOS not pruned, or a race between `getTasks(forceRefresh=true)` and the residence detail's first observation. **DO NOT** weaken the test to make it pass — fix the underlying issue.
**Step 4: Stress run (catch flakiness before merge)**
Run the test 5× to confirm it's stable, not just lucky:
```
for i in 1 2 3 4 5; do
xcrun simctl uninstall booted com.myhoneydue.honeyDue.dev
docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml down >/dev/null
docker volume rm honeydueapi-go_postgres_data >/dev/null
docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml up -d >/dev/null
until curl -fsS http://127.0.0.1:8000/api/health/ >/dev/null 2>&1; do sleep 2; done
xcodebuild -project iosApp/honeyDue.xcodeproj -scheme HoneyDueUITests -sdk iphonesimulator \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-only-testing:HoneyDueUITests/Suite11_TaskCacheRegressionTests/test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart \
test 2>&1 | tail -3
echo "=== run $i done ==="
done
```
Expected: 5/5 TEST SUCCEEDED. If even one fails, treat as flaky — don't merge.
**Step 5: Diff summary**
Run: `git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP diff --stat master...HEAD`
Expected: net deletion ~80-150 lines across the listed files. If the diff is much larger, scope creep — review commits.
**Step 6: Push and open a PR (only if user confirms)**
Don't push without asking the user. Wait for explicit go-ahead.
---
## Rollback plan
If anything goes sideways in production:
1. `git revert <merge-commit-sha>` — every commit in this plan is independently revertable in reverse order, but the cleanest rollback is reverting the merge commit.
2. Old persistence blob format is preserved by `ignoreUnknownKeys` — no migration required.
3. Backend `/api/tasks/by-residence/:id/` was never touched, so a rolled-back client immediately starts using it again with no server change.
---
## Notes for the executing engineer
- **Frequent commits.** Every task ends with a commit. If you deviate from the plan, commit before deviating.
- **Don't auto-commit any other changes.** Per `honeyDueKMP/CLAUDE.md`: "DO NOT auto-commit code changes." Commit only what's specified.
- **Don't push to remote.** Let the user trigger the push after they review.
- **TaskColumnsResponse fields.** The `Column` data class in `CustomTask.kt` may have more fields than shown (display label, color, sort order). Read it before writing the standard column shell in Task 2 — the test will fail on missing required constructor args.
- **TaskResponse fields.** Same — has many fields. For test fixtures, build a small helper:
```kotlin
private fun sampleTask(id: Int, residenceId: Int, column: String) = TaskResponse(...)
```
in the test file rather than repeating the giant constructor.
- **SKIE/Swift copy.** TaskColumnsResponse `.copy()` from Swift may need `.doCopy(...)` if SKIE renamed it. Check `iosApp/iosApp/Task/TaskViewModel.swift` line 387 onward for an existing example of how Swift copies a Kotlin data class.
- **Don't refactor "while you're here."** This plan is laser-focused on the cache unification. Other smells you spot — log them, don't fix them in this PR.
+32
View File
@@ -0,0 +1,32 @@
{
"configurations" : [
{
"id" : "D4000004-CI00-4D4D-BFDC-000000000004",
"name" : "CI Configuration",
"options" : {
}
}
],
"defaultOptions" : {
"testTimeoutsEnabled" : true,
"defaultTestExecutionTimeAllowance" : 300,
"maximumTestRepetitions" : 1,
"targetForVariableExpansion" : {
"containerPath" : "container:honeyDue.xcodeproj",
"identifier" : "D4ADB376A7A4CFB73469E173",
"name" : "HoneyDue"
}
},
"testTargets" : [
{
"parallelizable" : true,
"target" : {
"containerPath" : "container:honeyDue.xcodeproj",
"identifier" : "1CBF1BEC2ECD9768001BF56C",
"name" : "HoneyDueUITests"
}
}
],
"version" : 1
}
+31
View File
@@ -0,0 +1,31 @@
{
"configurations" : [
{
"id" : "C3000003-CLEN-4C3C-BFDC-000000000003",
"name" : "Cleanup Configuration",
"options" : {
}
}
],
"defaultOptions" : {
"targetForVariableExpansion" : {
"containerPath" : "container:honeyDue.xcodeproj",
"identifier" : "D4ADB376A7A4CFB73469E173",
"name" : "HoneyDue"
}
},
"testTargets" : [
{
"selectedTests" : [
"SuiteZZ_CleanupTests"
],
"target" : {
"containerPath" : "container:honeyDue.xcodeproj",
"identifier" : "1CBF1BEC2ECD9768001BF56C",
"name" : "HoneyDueUITests"
}
}
],
"version" : 1
}
+1 -1
View File
@@ -15,6 +15,6 @@
<key>manageAppVersionAndBuildNumber</key>
<true/>
<key>teamID</key>
<string>V3PF3M6B6U</string>
<string>X86BR9WTLD</string>
</dict>
</plist>
+1 -1
View File
@@ -148,7 +148,7 @@ final class WidgetActionManager {
static let shared = WidgetActionManager()
private let appGroupIdentifier: String = {
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.tt.honeyDue.dev"
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev"
}()
private let pendingTasksFileName = "widget_pending_tasks.json"
private let tokenKey = "widget_auth_token"
+1 -1
View File
@@ -111,7 +111,7 @@ class CacheManager {
}
private static let appGroupIdentifier: String = {
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.tt.honeyDue.dev"
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev"
}()
private static let tasksFileName = "widget_tasks.json"
+2 -2
View File
@@ -9,13 +9,13 @@
}
],
"defaultOptions" : {
"testTimeoutsEnabled" : true,
"defaultTestExecutionTimeAllowance" : 300,
"targetForVariableExpansion" : {
"containerPath" : "container:honeyDue.xcodeproj",
"identifier" : "D4ADB376A7A4CFB73469E173",
"name" : "HoneyDue"
}
},
"testTimeoutsEnabled" : true
},
"testTargets" : [
{
@@ -15,8 +15,6 @@ class AuthenticatedUITestCase: BaseUITestCase {
("admin", "test1234")
}
override var includeResetStateLaunchArgument: Bool { false }
// MARK: - API Session
private(set) var session: TestSession!
@@ -24,12 +22,22 @@ class AuthenticatedUITestCase: BaseUITestCase {
// MARK: - Lifecycle
override class func setUp() {
super.setUp()
guard TestAccountAPIClient.isBackendReachable() else { return }
// Ensure both known test accounts exist (covers all subclass credential overrides)
if TestAccountAPIClient.login(username: "testuser", password: "TestPass123!") == nil {
_ = TestAccountAPIClient.createVerifiedAccount(username: "testuser", email: "testuser@honeydue.com", password: "TestPass123!")
}
if TestAccountAPIClient.login(username: "admin", password: "test1234") == nil {
_ = TestAccountAPIClient.createVerifiedAccount(username: "admin", email: "admin@honeydue.com", password: "test1234")
}
}
override func setUpWithError() throws {
if needsAPISession {
guard TestAccountAPIClient.isBackendReachable() else {
throw XCTSkip("Backend not reachable at \(TestAccountAPIClient.baseURL)")
}
}
try super.setUpWithError()
@@ -191,19 +191,35 @@ extension XCUIElement {
// SecureTextFields may trigger iOS strong password suggestion dialog
// which blocks the regular keyboard. Handle them with a dedicated path.
if elementType == .secureTextField {
// Dismiss any open keyboard first iOS 26 fails to transfer focus
// from a TextField to a SecureTextField if the keyboard is already up.
if app.keyboards.firstMatch.exists {
let navBar = app.navigationBars.firstMatch
if navBar.exists {
navBar.tap()
}
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2)
}
tap()
// Dismiss "Choose My Own Password" or "Not Now" if iOS suggests a strong password
let chooseOwn = app.buttons["Choose My Own Password"]
if chooseOwn.waitForExistence(timeout: 1) {
if chooseOwn.waitForExistence(timeout: 0.5) {
chooseOwn.tap()
} else {
let notNow = app.buttons["Not Now"]
if notNow.exists && notNow.isHittable { notNow.tap() }
}
if app.keyboards.firstMatch.waitForExistence(timeout: 2) {
// Wait for keyboard after tapping SecureTextField
if !app.keyboards.firstMatch.waitForExistence(timeout: 5) {
// Retry tap first tap may not have acquired focus
tap()
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
}
if app.keyboards.firstMatch.exists {
typeText(text)
} else {
app.typeText(text)
XCTFail("Keyboard did not appear after tapping SecureTextField: \(self)", file: file, line: line)
}
return
}
@@ -257,8 +257,6 @@ struct RegisterScreenObject {
private var usernameField: XCUIElement { app.textFields[UITestID.Auth.registerUsernameField] }
private var emailField: XCUIElement { app.textFields[UITestID.Auth.registerEmailField] }
private var passwordField: XCUIElement { app.secureTextFields[UITestID.Auth.registerPasswordField] }
private var confirmPasswordField: XCUIElement { app.secureTextFields[UITestID.Auth.registerConfirmPasswordField] }
private var registerButton: XCUIElement { app.buttons[UITestID.Auth.registerButton] }
private var cancelButton: XCUIElement { app.buttons[UITestID.Auth.registerCancelButton] }
@@ -268,30 +266,32 @@ struct RegisterScreenObject {
}
func fill(username: String, email: String, password: String) {
func advanceToNextField() {
let keys = ["Next", "Return", "return", "Done", "done"]
for key in keys {
let button = app.keyboards.buttons[key]
if button.waitForExistence(timeout: 1) && button.isHittable {
button.tap()
return
}
}
}
// iOS 26 bug: SecureTextField won't gain keyboard focus when tapped directly.
// Workaround: toggle password visibility first to convert SecureField TextField,
// then use focusAndType() on all regular TextFields.
usernameField.waitForExistenceOrFail(timeout: 10)
// Scroll down to reveal the password toggle buttons (they're below the fold)
let scrollView = app.scrollViews.firstMatch
if scrollView.exists { scrollView.swipeUp() }
// Toggle both password visibility buttons (converts SecureField TextField)
let toggleButtons = app.buttons.matching(NSPredicate(format: "label == 'Toggle password visibility'"))
for i in 0..<toggleButtons.count {
let toggle = toggleButtons.element(boundBy: i)
if toggle.exists && toggle.isHittable {
toggle.tap()
}
}
// After toggling, password fields are regular TextFields.
// Don't swipeDown it dismisses the sheet. focusAndType() auto-scrolls via tap().
let passwordField = app.textFields[UITestID.Auth.registerPasswordField]
let confirmPasswordField = app.textFields[UITestID.Auth.registerConfirmPasswordField]
usernameField.focusAndType(username, app: app)
advanceToNextField()
emailField.waitForExistenceOrFail(timeout: 10)
emailField.focusAndType(email, app: app)
advanceToNextField()
passwordField.waitForExistenceOrFail(timeout: 10)
passwordField.focusAndType(password, app: app)
advanceToNextField()
confirmPasswordField.waitForExistenceOrFail(timeout: 10)
confirmPasswordField.focusAndType(password, app: app)
}
@@ -51,7 +51,28 @@ struct TaskListScreen {
}
func findTask(title: String) -> XCUIElement {
app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", title)).firstMatch
let predicate = NSPredicate(format: "label CONTAINS %@", title)
let match = app.staticTexts.containing(predicate).firstMatch
// If found immediately, return
if match.waitForExistence(timeout: 3) { return match }
// Scroll through kanban columns (swipe left up to 6 times)
let scrollView = app.scrollViews.firstMatch
guard scrollView.exists else { return match }
for _ in 0..<6 {
scrollView.swipeLeft()
if match.waitForExistence(timeout: 1) { return match }
}
// Scroll back and try right direction
for _ in 0..<6 {
scrollView.swipeRight()
if match.waitForExistence(timeout: 1) { return match }
}
return match
}
}
@@ -0,0 +1,221 @@
import XCTest
/// Suite 11 captures the gitea#2 regression at the user-visible level:
/// after onboarding (register name residence bulk-create tasks land
/// on home), tapping the residence cell shows "no tasks" even though the
/// server has them. Restarting the app fixes it. This test reproduces the
/// flow without an app restart and asserts that tasks render on the
/// residence detail screen.
///
/// CRITICAL: this test must FAIL at the cache-unification fix's first
/// commit and must PASS after Phase 1-3 lands. The failing assertion is
/// pinned to a specific message so the regression is unambiguous.
///
/// The test deliberately does NOT visit the Tasks tab between onboarding
/// and tapping the residence cell. Visiting the Tasks tab would prime
/// `_allTasks` and mask the bug the bug is that residence detail
/// cannot recover from the empty-cache + sink-timing window on its own.
final class Suite11_TaskCacheRegressionTests: BaseUITestCase {
// We need to start at the onboarding welcome screen, not the standalone
// login screen `completeOnboarding` would skip the entire flow.
override var completeOnboarding: Bool { false }
// Single test in this suite relaunch isn't necessary, but we want a
// clean state every time (handled by the default --reset-state).
override var relaunchBetweenTests: Bool { true }
// MARK: - Constants
/// DEBUG_FIXED_CODES=true on the local Go API hardcodes this code.
private let debugVerificationCode = "123456"
/// Stable name for the residence we create in onboarding. Used both for
/// the form input and to address the cell on the home screen via
/// `app.staticTexts[residenceName]` if the id-based identifier doesn't
/// resolve in time.
private let residenceName = "UI Test Property"
// MARK: - Test
/// Reproduces gitea#2: tasks created via the onboarding bulk endpoint
/// must appear on the residence detail screen without an app restart
/// and without first visiting the Tasks tab.
@MainActor
func test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart() throws {
// Step 1 Register a fresh user via the onboarding Start Fresh flow.
// The flow is: Welcome ValueProps NameResidence CreateAccount
// VerifyEmail HomeProfile FirstTask main app.
let createAccount = TestFlows.navigateStartFreshToCreateAccount(
app: app,
residenceName: residenceName
)
createAccount.waitForLoad(timeout: navigationTimeout)
// Step 2 Fill the create-account form. We address the onboarding
// form's fields (not the standalone register sheet's fields).
let creds = TestAccountManager.uniqueCredentials(prefix: "gitea2")
createAccount.expandEmailSignup()
// Use the same focusAndType path that OnboardingTests uses it
// already handles SecureTextField + iOS strong-password panel.
// Under --ui-testing, OrganicOnboardingSecureField defaults to
// visibility=ON (renders as TextField) to dodge the iOS 26 SecureField
// keyboard bug. Query textFields, not secureTextFields.
let usernameField = app.textFields[AccessibilityIdentifiers.Onboarding.usernameField]
let emailField = app.textFields[AccessibilityIdentifiers.Onboarding.emailField]
let passwordField = app.textFields[AccessibilityIdentifiers.Onboarding.passwordField]
let confirmPasswordField = app.textFields[AccessibilityIdentifiers.Onboarding.confirmPasswordField]
usernameField.waitForExistenceOrFail(timeout: navigationTimeout)
usernameField.focusAndType(creds.username, app: app)
emailField.waitForExistenceOrFail(timeout: navigationTimeout)
emailField.focusAndType(creds.email, app: app)
passwordField.waitForExistenceOrFail(timeout: navigationTimeout)
passwordField.focusAndType(creds.password, app: app)
confirmPasswordField.waitForExistenceOrFail(timeout: navigationTimeout)
confirmPasswordField.focusAndType(creds.password, app: app)
let createAccountButton = app.descendants(matching: .any)
.matching(identifier: AccessibilityIdentifiers.Onboarding.createAccountButton)
.firstMatch
createAccountButton.waitForExistenceOrFail(timeout: navigationTimeout)
createAccountButton.forceTap()
// Step 3 Verify email with the debug fixed code.
let verification = VerificationScreen(app: app)
verification.waitForLoad(timeout: loginTimeout)
verification.enterCode(debugVerificationCode)
// Many onboarding verification screens auto-submit on a 6-digit
// code. If a verify button still exists and a code field is still
// visible, tap it to push past edge cases.
if verification.codeField.waitForExistence(timeout: 1) && verification.verifyButton.exists {
verification.submitCode()
}
// Step 4 Skip the home-profile step. The home-profile screen has
// its own Skip button (the shared onboarding skip in the nav bar)
// which routes to the first-task step without making us pick climate
// / appliance fields.
let onboardingSkipButton = app.buttons[AccessibilityIdentifiers.Onboarding.skipButton]
XCTAssertTrue(
onboardingSkipButton.waitForExistence(timeout: loginTimeout),
"Onboarding skip button should exist on the home-profile screen"
)
// The skip button can briefly be non-hittable during the screen-in
// transition. Use forceTap() to bypass the strict hittable check.
// We confirmed existence above; if the tap doesn't land on the
// intended button the next assertion (Browse All tab) will catch it.
onboardingSkipButton.forceTap()
// Step 5 Switch to the "Browse All" tab on the First-Task screen.
// "For You" suggestions can be empty for a fresh residence with no
// home-profile data, so deterministic browsing is required.
// The tab bar is a SwiftUI segmented Picker its segments are
// exposed as buttons with the segment label, regardless of an
// identifier on the parent.
let browseAllTab = app.buttons["Browse All"]
XCTAssertTrue(
browseAllTab.waitForExistence(timeout: loginTimeout),
"Browse All tab should appear on the first-task screen"
)
browseAllTab.tap()
// Step 6 Pick 3 templates by accessibility identifier prefix.
// The catalog is loaded via GET /api/tasks/templates/grouped/, so
// we need to wait for at least one row to render before tapping.
let templateRowQuery = app.buttons.matching(
NSPredicate(format: "identifier BEGINSWITH %@",
AccessibilityIdentifiers.Onboarding.templateRowPrefix)
)
// Wait for the catalog to load. The grouped endpoint returns first
// category expanded by default in the view, so rows should appear
// shortly after Browse All becomes visible. Network call: 10s.
let firstRow = templateRowQuery.element(boundBy: 0)
XCTAssertTrue(
firstRow.waitForExistence(timeout: loginTimeout),
"At least one template row must render on the Browse All tab. " +
"If no rows appear, the catalog endpoint failed — bug repro is invalid."
)
// Tap the first 3 visible rows. Some categories may collapse rows
// we never see; we only need at least 1, so the floor is 1 with a
// soft cap of 3.
let rowCount = templateRowQuery.count
let toPick = min(3, rowCount)
XCTAssertGreaterThanOrEqual(toPick, 1, "Expected at least one template row")
for index in 0..<toPick {
let row = templateRowQuery.element(boundBy: index)
row.waitUntilHittable(timeout: navigationTimeout)
row.tap()
}
// Step 7 Submit the bulk-create. This is the
// POST /api/tasks/bulk/ call that produces the inconsistent client
// cache state at the heart of gitea#2.
let submitButton = app.buttons[AccessibilityIdentifiers.Onboarding.submitTasksButton]
XCTAssertTrue(
submitButton.waitForExistence(timeout: navigationTimeout),
"Submit-tasks button must exist on the first-task screen"
)
submitButton.waitUntilHittable(timeout: navigationTimeout).tap()
// Step 8 Land on the main app (Residences tab is selected by
// default). CRITICAL: do NOT tap the Tasks tab. Tapping it would
// populate `_allTasks` and mask the bug.
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
let tabBar = app.tabBars.firstMatch
let reachedMain = mainTabs.waitForExistence(timeout: loginTimeout)
|| tabBar.waitForExistence(timeout: navigationTimeout)
XCTAssertTrue(reachedMain, "App should reach main tabs after onboarding submit")
// Step 9 Tap the residence cell directly. Prefer the
// identifier-prefix match for any cell; fall back to the static
// text match by name.
let residenceCellQuery = app.buttons.matching(
NSPredicate(format: "identifier BEGINSWITH %@",
AccessibilityIdentifiers.Residence.cellPrefix)
)
let residenceCell = residenceCellQuery.firstMatch
if residenceCell.waitForExistence(timeout: navigationTimeout) && residenceCell.isHittable {
residenceCell.tap()
} else {
// Fallback: tap the static text inside the card. The
// NavigationLink wraps the entire card so a tap on the name
// still routes into the detail view.
let residenceText = app.staticTexts[residenceName]
XCTAssertTrue(
residenceText.waitForExistence(timeout: navigationTimeout),
"Residence cell or name '\(residenceName)' must exist on the residences list"
)
residenceText.tap()
}
// Step 10 THE BUG ASSERTION. With the bug present:
// - `_allTasks` is null on the client (never primed).
// - `_tasksByResidence[id]` is empty (cache miss).
// - residence detail attempts to load, hits the iOS Combine sink
// timing window, and renders the empty state.
// With the fix, both `_allTasks` is populated by `bulkCreateTasks`
// and residence detail filters from it in-memory, so the empty
// state must not appear.
let taskRowQuery = app.descendants(matching: .any).matching(
NSPredicate(format: "identifier BEGINSWITH %@",
AccessibilityIdentifiers.Task.rowPrefix)
)
let firstTaskRow = taskRowQuery.element(boundBy: 0)
let anyTaskAppeared = firstTaskRow.waitForExistence(timeout: 10)
let emptyState = app.otherElements[AccessibilityIdentifiers.Task.noTasksLabel]
let emptyStateVisible = emptyState.exists
// Pin the failure message so the bug-capture is unambiguous. This
// is the assertion that should FAIL at this commit and PASS after
// the cache fix lands. Don't change the message Task 12 grep's
// for it.
XCTAssertTrue(
anyTaskAppeared && !emptyStateVisible,
"Tasks created during onboarding must appear on residence detail without restart (gitea#2)"
)
}
}
@@ -4,7 +4,6 @@ import XCTest
/// Tests verify both positive AND negative conditions to ensure robust validation
final class Suite1_RegistrationTests: BaseUITestCase {
override var completeOnboarding: Bool { true }
override var includeResetStateLaunchArgument: Bool { false }
override var relaunchBetweenTests: Bool { true }
@@ -154,8 +153,26 @@ final class Suite1_RegistrationTests: BaseUITestCase {
return app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Verify'")).firstMatch
}
/// Dismiss keyboard safely use the Done button if available, or tap
/// a non-interactive area. Avoid nav bar (has Cancel button) and Return key (triggers onSubmit).
/// Submit the registration form after filling it. Uses keyboard "Go" button
/// or falls back to dismissing keyboard and tapping the register button.
private func submitRegistrationForm() {
let goButton = app.keyboards.buttons["Go"]
if goButton.waitForExistence(timeout: 2) && goButton.isHittable {
goButton.tap()
return
}
// Fallback: dismiss keyboard, then tap register button
dismissKeyboard()
let registerButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
registerButton.waitForExistenceOrFail(timeout: 5)
if !registerButton.isHittable {
let scrollView = app.scrollViews.firstMatch
if scrollView.exists { registerButton.scrollIntoView(in: scrollView) }
}
registerButton.forceTap()
}
/// Dismiss keyboard safely by tapping a neutral area.
private func dismissKeyboard() {
guard app.keyboards.firstMatch.exists else { return }
// Try toolbar Done button first
@@ -165,63 +182,44 @@ final class Suite1_RegistrationTests: BaseUITestCase {
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2)
return
}
// Tap the sheet title area (safe neutral zone in the registration form)
let title = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Create' OR label CONTAINS[c] 'Register' OR label CONTAINS[c] 'Account'")).firstMatch
if title.exists && title.isHittable {
title.tap()
// Try navigation bar (works on most screens)
let navBar = app.navigationBars.firstMatch
if navBar.exists && navBar.isHittable {
navBar.tap()
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2)
return
}
// Last resort: tap the form area above the keyboard
let formArea = app.scrollViews.firstMatch
if formArea.exists {
let topCenter = formArea.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1))
topCenter.tap()
}
// Fallback: tap top-center of the app
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap()
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2)
}
/// Fill registration form with given credentials
/// Fill registration form with given credentials.
/// Uses Return key (\n) to trigger SwiftUI's .onSubmit / @FocusState field
/// transitions. Direct field taps fail on iOS 26 when transitioning from
/// TextField to SecureTextField (keyboard never appears).
private func fillRegistrationForm(username: String, email: String, password: String, confirmPassword: String) {
// iOS 26 bug: SecureTextField won't gain keyboard focus when tapped directly.
// Workaround: toggle password visibility first to convert SecureField TextField.
let scrollView = app.scrollViews.firstMatch
if scrollView.exists { scrollView.swipeUp() }
let toggleButtons = app.buttons.matching(NSPredicate(format: "label == 'Toggle password visibility'"))
for i in 0..<toggleButtons.count {
let toggle = toggleButtons.element(boundBy: i)
if toggle.exists && toggle.isHittable { toggle.tap() }
}
// Don't swipeDown it dismisses the sheet. focusAndType() auto-scrolls via tap().
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
let emailField = app.textFields[AccessibilityIdentifiers.Authentication.registerEmailField]
let passwordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerPasswordField]
let confirmPasswordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField]
// STRICT: All fields must exist and be hittable
XCTAssertTrue(usernameField.isHittable, "Username field must be hittable")
XCTAssertTrue(emailField.isHittable, "Email field must be hittable")
XCTAssertTrue(passwordField.isHittable, "Password field must be hittable")
XCTAssertTrue(confirmPasswordField.isHittable, "Confirm password field must be hittable")
let passwordField = app.textFields[AccessibilityIdentifiers.Authentication.registerPasswordField]
let confirmPasswordField = app.textFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField]
usernameField.focusAndType(username, app: app)
emailField.focusAndType(email, app: app)
// SecureTextFields: tap, handle strong password suggestion, type directly
passwordField.tap()
let chooseOwn = app.buttons["Choose My Own Password"]
if chooseOwn.waitForExistence(timeout: 2) { chooseOwn.tap() }
let notNow = app.buttons["Not Now"]
if notNow.exists && notNow.isHittable { notNow.tap() }
_ = app.keyboards.firstMatch.waitForExistence(timeout: 2)
app.typeText(password)
// Use Next keyboard button to advance to confirm password (avoids tap-interception)
let nextButton = app.keyboards.buttons["Next"]
let goButton = app.keyboards.buttons["Go"]
if nextButton.exists && nextButton.isHittable {
nextButton.tap()
} else if goButton.exists && goButton.isHittable {
// Don't tap Go it would submit the form. Tap the field instead.
confirmPasswordField.tap()
} else {
confirmPasswordField.tap()
}
if chooseOwn.waitForExistence(timeout: 2) { chooseOwn.tap() }
if notNow.exists && notNow.isHittable { notNow.tap() }
_ = app.keyboards.firstMatch.waitForExistence(timeout: 2)
app.typeText(confirmPassword)
passwordField.focusAndType(password, app: app)
confirmPasswordField.focusAndType(confirmPassword, app: app)
}
// MARK: - 1. UI/Element Tests (no backend, pure UI verification)
@@ -386,43 +384,19 @@ final class Suite1_RegistrationTests: BaseUITestCase {
let username = testUsername
let email = testEmail
// Use the proven RegisterScreenObject approach (navigates + fills via screen object)
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.waitForLoad(timeout: defaultTimeout)
login.tapSignUp()
// Use the same proven flow as tests 09-12
navigateToRegistration()
fillRegistrationForm(
username: username,
email: email,
password: testPassword,
confirmPassword: testPassword
)
let register = RegisterScreenObject(app: app)
register.waitForLoad(timeout: navigationTimeout)
register.fill(username: username, email: email, password: testPassword)
submitRegistrationForm()
// Dismiss keyboard, then scroll to and tap the register button
let registerButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
registerButton.waitForExistenceOrFail(timeout: defaultTimeout, message: "Register button should exist")
if !registerButton.isHittable {
let scrollView = app.scrollViews.firstMatch
if scrollView.exists { registerButton.scrollIntoView(in: scrollView) }
}
// Try keyboard Go button first (confirm password has .submitLabel(.go) + .onSubmit { register() })
let goButton = app.keyboards.buttons["Go"]
if goButton.exists && goButton.isHittable {
goButton.tap()
} else {
// Fallback: scroll to and tap the register button
if !registerButton.isHittable {
let scrollView = app.scrollViews.firstMatch
if scrollView.exists { registerButton.scrollIntoView(in: scrollView) }
}
registerButton.forceTap()
}
// Wait for form to dismiss (API call completes and navigates to verification)
let regUsernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
XCTAssertTrue(regUsernameField.waitForNonExistence(timeout: 15),
"Registration form must disappear. If this fails consistently, iOS Strong Password autofill " +
"may be interfering with SecureTextField input in the simulator.")
// STRICT: Verification screen must appear
XCTAssertTrue(waitForVerificationScreen(timeout: 10), "Verification screen must appear after registration")
// Wait for verification screen to appear (registration form may still exist underneath)
XCTAssertTrue(waitForVerificationScreen(timeout: 15), "Verification screen must appear after registration")
// NEGATIVE CHECK: Tab bar should NOT be hittable while on verification
let tabBar = app.tabBars.firstMatch
@@ -430,55 +404,43 @@ final class Suite1_RegistrationTests: BaseUITestCase {
XCTAssertFalse(tabBar.isHittable, "Tab bar should NOT be interactive while verification is required")
}
// Enter verification code
// Enter verification code the verification screen auto-submits when 6 digits are typed.
// IMPORTANT: Do NOT use focusAndType() here it taps the nav bar to dismiss the keyboard,
// which can accidentally hit the logout button in the toolbar.
let codeField = verificationCodeField()
XCTAssertTrue(codeField.waitForExistence(timeout: 5), "Verification code field must exist")
XCTAssertTrue(codeField.isHittable, "Verification code field must be tappable")
codeField.tap()
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
codeField.typeText(testVerificationCode)
codeField.focusAndType(testVerificationCode, app: app)
// Auto-submit: typing 6 digits triggers verifyEmail() and navigates to main app.
// Wait for the main app to appear (RootView sets ui.root.mainTabs when showing MainTabView).
let mainTabs = app.otherElements["ui.root.mainTabs"]
let mainAppAppeared = mainTabs.waitForExistence(timeout: 15)
dismissKeyboard()
let verifyButton = verificationButton()
XCTAssertTrue(verifyButton.exists && verifyButton.isHittable, "Verify button must be tappable")
verifyButton.tap()
if !mainAppAppeared {
// Diagnostic: capture what's on screen
let screenshot = XCTAttachment(screenshot: app.screenshot())
screenshot.name = "post-verification-no-main-tabs"
screenshot.lifetime = .keepAlways
add(screenshot)
// STRICT: Verification screen must DISAPPEAR
XCTAssertTrue(waitForElementToDisappear(codeField, timeout: 15), "Verification code field MUST disappear after successful verification")
// Check if we're stuck on verification screen or login
let stillOnVerify = codeField.exists
let onLogin = app.textFields[AccessibilityIdentifiers.Authentication.usernameField].exists
XCTFail("Main app did not appear after verification. StillOnVerify=\(stillOnVerify), OnLogin=\(onLogin)")
return
}
// STRICT: Must be on main app screen
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
XCTAssertTrue(residencesTab.waitForExistence(timeout: 15), "Tab bar must appear after verification")
XCTAssertTrue(waitForElementToBeHittable(residencesTab, timeout: 5), "Residences tab MUST be tappable after verification")
// STRICT: Tab bar must exist and be interactive
XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 5), "Tab bar must exist in main app")
// NEGATIVE CHECK: Verification screen should be completely gone
XCTAssertFalse(codeField.exists, "Verification code field must NOT exist after successful verification")
// Verify we can interact with the app (tap tab)
// Cleanup: Logout via profile tab settings logout
dismissKeyboard()
residencesTab.tap()
// Cleanup: Logout via settings button on Residences tab
dismissKeyboard()
residencesTab.tap()
let settingsButton = app.buttons[AccessibilityIdentifiers.Navigation.settingsButton]
XCTAssertTrue(settingsButton.waitForExistence(timeout: 5) && settingsButton.isHittable, "Settings button must be tappable")
settingsButton.tap()
let logoutButton = app.buttons[AccessibilityIdentifiers.Profile.logoutButton].firstMatch
XCTAssertTrue(logoutButton.waitForExistence(timeout: 5) && logoutButton.isHittable, "Logout button must be tappable")
dismissKeyboard()
logoutButton.tap()
let alertLogout = app.alerts.buttons["Log Out"]
if alertLogout.waitForExistence(timeout: 3) {
dismissKeyboard()
alertLogout.tap()
}
// STRICT: Must return to login screen
let welcomeText = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
XCTAssertTrue(welcomeText.waitForExistence(timeout: 5), "Must return to login screen after logout")
ensureLoggedOut()
}
// MARK: - 4. Server-Side Validation Tests (NOW a user exists from test07)
@@ -526,25 +488,23 @@ final class Suite1_RegistrationTests: BaseUITestCase {
confirmPassword: testPassword
)
dismissKeyboard()
app.buttons[AccessibilityIdentifiers.Authentication.registerButton].tap()
submitRegistrationForm()
// Wait for verification screen
XCTAssertTrue(waitForVerificationScreen(timeout: 10), "Must navigate to verification screen")
// Enter INVALID code
// Enter INVALID code auto-submits at 6 digits
// Don't use focusAndType() it taps nav bar which can hit the logout button
let codeField = verificationCodeField()
XCTAssertTrue(codeField.waitForExistence(timeout: 5) && codeField.isHittable)
codeField.focusAndType("000000", app: app) // Wrong code
XCTAssertTrue(codeField.waitForExistence(timeout: 5))
codeField.tap()
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
codeField.typeText("000000") // Wrong code auto-submit API error
let verifyButton = verificationButton()
dismissKeyboard()
verifyButton.tap()
// STRICT: Error message must appear
let errorPredicate = NSPredicate(format: "label CONTAINS[c] 'invalid' OR label CONTAINS[c] 'error' OR label CONTAINS[c] 'incorrect' OR label CONTAINS[c] 'wrong'")
// STRICT: Error message must appear (auto-submit verifies with wrong code)
let errorPredicate = NSPredicate(format: "label CONTAINS[c] 'invalid' OR label CONTAINS[c] 'error' OR label CONTAINS[c] 'incorrect' OR label CONTAINS[c] 'wrong' OR label CONTAINS[c] 'expired'")
let errorMessage = app.staticTexts.containing(errorPredicate).firstMatch
XCTAssertTrue(errorMessage.waitForExistence(timeout: 5), "Error message MUST appear for invalid verification code")
XCTAssertTrue(errorMessage.waitForExistence(timeout: 10), "Error message MUST appear for invalid verification code")
}
func test10_verificationCodeFieldValidation() {
@@ -559,26 +519,20 @@ final class Suite1_RegistrationTests: BaseUITestCase {
confirmPassword: testPassword
)
dismissKeyboard()
app.buttons[AccessibilityIdentifiers.Authentication.registerButton].tap()
submitRegistrationForm()
XCTAssertTrue(waitForVerificationScreen(timeout: 10))
// Enter incomplete code (only 3 digits)
// Enter incomplete code (only 3 digits won't trigger auto-submit)
// Don't use focusAndType() it taps nav bar which can hit the logout button
let codeField = verificationCodeField()
XCTAssertTrue(codeField.waitForExistence(timeout: 5) && codeField.isHittable)
codeField.focusAndType("123", app: app) // Incomplete
XCTAssertTrue(codeField.waitForExistence(timeout: 5))
codeField.tap()
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
codeField.typeText("123") // Incomplete
let verifyButton = verificationButton()
// Button might be disabled with incomplete code
if verifyButton.isEnabled {
dismissKeyboard()
verifyButton.tap()
}
// STRICT: Must still be on verification screen
XCTAssertTrue(codeField.exists && codeField.isHittable, "Must remain on verification screen with incomplete code")
// STRICT: Must still be on verification screen (3 digits won't auto-submit)
XCTAssertTrue(codeField.exists, "Must remain on verification screen with incomplete code")
// NEGATIVE CHECK: Should NOT have navigated to main app
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
@@ -588,7 +542,7 @@ final class Suite1_RegistrationTests: BaseUITestCase {
}
func test11_appRelaunchWithUnverifiedUser() {
// This test verifies the fix for: user kills app on verification screen, relaunches, should see verification again
// This test verifies: user kills app on verification screen, relaunches, should see verification again
let username = testUsername
let email = testEmail
@@ -601,35 +555,37 @@ final class Suite1_RegistrationTests: BaseUITestCase {
confirmPassword: testPassword
)
dismissKeyboard()
app.buttons[AccessibilityIdentifiers.Authentication.registerButton].tap()
submitRegistrationForm()
// Wait for verification screen
XCTAssertTrue(waitForVerificationScreen(timeout: 10), "Must reach verification screen")
XCTAssertTrue(waitForVerificationScreen(timeout: 20), "Must reach verification screen")
// Simulate app kill and relaunch (terminate and launch)
// Relaunch WITHOUT --reset-state so the unverified session persists.
// Keep --ui-testing and --disable-animations but remove --reset-state and --complete-onboarding.
app.terminate()
app.launchArguments = ["--ui-testing", "--disable-animations"]
app.launch()
// Wait for app to fully initialize
app.otherElements["ui.app.ready"].waitForExistenceOrFail(timeout: 15)
// STRICT: After relaunch, unverified user MUST see verification screen, NOT main app
let authCodeFieldAfterRelaunch = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField]
let onboardingCodeFieldAfterRelaunch = app.textFields[AccessibilityIdentifiers.Onboarding.verificationCodeField]
let loginScreen = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
let tabBar = app.tabBars.firstMatch
// Wait for app to settle
// Wait for one of the expected screens to appear
_ = authCodeFieldAfterRelaunch.waitForExistence(timeout: 10)
|| onboardingCodeFieldAfterRelaunch.waitForExistence(timeout: 10)
|| loginScreen.waitForExistence(timeout: 10)
|| onboardingCodeFieldAfterRelaunch.waitForExistence(timeout: 5)
|| loginScreen.waitForExistence(timeout: 5)
// User should either be on verification screen OR login screen (if token expired)
// They should NEVER be on main app with unverified email
// User should NEVER be on main app with unverified email
if tabBar.exists && tabBar.isHittable {
// If tab bar is accessible, that's a FAILURE - unverified user should not access main app
XCTFail("CRITICAL: Unverified user should NOT have access to main app after relaunch. Tab bar is hittable!")
}
// Acceptable states: verification screen OR login screen
// Acceptable states: verification screen OR login screen (if token expired)
let onVerificationScreen =
(authCodeFieldAfterRelaunch.exists && authCodeFieldAfterRelaunch.isHittable)
|| (onboardingCodeFieldAfterRelaunch.exists && onboardingCodeFieldAfterRelaunch.isHittable)
@@ -638,10 +594,10 @@ final class Suite1_RegistrationTests: BaseUITestCase {
XCTAssertTrue(onVerificationScreen || onLoginScreen,
"After relaunch, unverified user must be on verification screen or login screen, NOT main app")
// Cleanup
// Cleanup: logout from whatever screen we're on
if onVerificationScreen {
let logoutButton = app.buttons[AccessibilityIdentifiers.Profile.logoutButton].firstMatch
if logoutButton.exists && logoutButton.isHittable {
let logoutButton = app.buttons[AccessibilityIdentifiers.Authentication.verificationLogoutButton]
if logoutButton.waitForExistence(timeout: 3) && logoutButton.isHittable {
dismissKeyboard()
logoutButton.tap()
}
@@ -660,18 +616,15 @@ final class Suite1_RegistrationTests: BaseUITestCase {
confirmPassword: testPassword
)
dismissKeyboard()
app.buttons[AccessibilityIdentifiers.Authentication.registerButton].tap()
submitRegistrationForm()
// Wait for verification screen
XCTAssertTrue(waitForVerificationScreen(timeout: 10), "Must navigate to verification screen")
// STRICT: Logout button must exist and be tappable
let logoutButton = app.buttons[AccessibilityIdentifiers.Profile.logoutButton].firstMatch
// STRICT: Logout button must exist and be tappable (uses dedicated verify screen ID)
let logoutButton = app.buttons[AccessibilityIdentifiers.Authentication.verificationLogoutButton]
XCTAssertTrue(logoutButton.waitForExistence(timeout: 5), "Logout button MUST exist on verification screen")
XCTAssertTrue(logoutButton.isHittable, "Logout button MUST be tappable on verification screen")
dismissKeyboard()
logoutButton.waitUntilHittable(timeout: 5)
logoutButton.tap()
// STRICT: Verification screen must disappear
+19 -20
View File
@@ -100,18 +100,18 @@ final class Suite5_TaskTests: AuthenticatedUITestCase {
// Wait for form to dismiss
_ = saveButton.waitForNonExistence(timeout: navigationTimeout)
// Verify task appears in list (may need refresh or scroll in kanban view)
let newTask = app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", taskTitle)).firstMatch
if !newTask.waitForExistence(timeout: navigationTimeout) {
pullToRefresh()
}
XCTAssertTrue(newTask.waitForExistence(timeout: navigationTimeout), "New task '\(taskTitle)' should appear in the list")
// Track for cleanup
// Verify task was created via API (also gives the server time to process)
if let items = TestAccountAPIClient.listTasks(token: session.token),
let created = items.first(where: { $0.title.contains(taskTitle) }) {
cleaner.trackTask(created.id)
}
// Navigate to tasks tab and refresh to pick up the newly created task
navigateToTasks()
refreshTasks()
let taskListScreen = TaskListScreen(app: app)
let newTask = taskListScreen.findTask(title: taskTitle)
XCTAssertTrue(newTask.waitForExistence(timeout: loginTimeout), "New task '\(taskTitle)' should appear in the list")
}
// MARK: - 4. View Details
@@ -133,24 +133,23 @@ final class Suite5_TaskTests: AuthenticatedUITestCase {
saveButton.tap()
_ = saveButton.waitForNonExistence(timeout: navigationTimeout)
// Find and tap the task (may need refresh)
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", taskTitle)).firstMatch
if !taskCard.waitForExistence(timeout: navigationTimeout) {
pullToRefresh()
}
taskCard.waitForExistenceOrFail(timeout: navigationTimeout, message: "Created task should appear in list")
// Verify task was created via API (also gives the server time to process)
if let items = TestAccountAPIClient.listTasks(token: session.token),
let created = items.first(where: { $0.title.contains(taskTitle) }) {
cleaner.trackTask(created.id)
}
taskCard.tap()
// Navigate to tasks tab and refresh to pick up the newly created task
navigateToTasks()
refreshTasks()
let taskListScreen = TaskListScreen(app: app)
let taskCard = taskListScreen.findTask(title: taskTitle)
taskCard.waitForExistenceOrFail(timeout: loginTimeout, message: "Created task should appear in list")
// After tapping a task, the app should show task details or actions.
// The navigation bar title or a detail view element should appear.
let navBar = app.navigationBars.firstMatch
XCTAssertTrue(navBar.waitForExistence(timeout: navigationTimeout), "Task detail view should load after tap")
// Verify the task card is accessible and the actions menu exists
// (There is no task detail screen cards are self-contained with a context menu)
let actionsMenu = app.buttons["Task actions"].firstMatch
XCTAssertTrue(actionsMenu.waitForExistence(timeout: navigationTimeout), "Task actions menu should be accessible")
}
// MARK: - 5. Navigation
@@ -22,9 +22,25 @@ final class Suite6_ComprehensiveTaskTests: AuthenticatedUITestCase {
// Test data tracking
var createdTaskTitles: [String] = []
private static var hasCleanedStaleData = false
override func setUpWithError() throws {
try super.setUpWithError()
// Dismiss any open form from previous test
let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton].firstMatch
if cancelButton.exists { cancelButton.tap() }
// One-time cleanup of stale tasks from previous test runs
if !Self.hasCleanedStaleData {
Self.hasCleanedStaleData = true
if let stale = TestAccountAPIClient.listTasks(token: session.token) {
for task in stale {
_ = TestAccountAPIClient.deleteTask(token: session.token, id: task.id)
}
}
}
// Ensure at least one residence exists (task add button requires it)
if let residences = TestAccountAPIClient.listResidences(token: session.token),
residences.isEmpty {
@@ -109,6 +125,9 @@ final class Suite6_ComprehensiveTaskTests: AuthenticatedUITestCase {
cleaner.trackTask(created.id)
}
// Navigate to tasks tab to trigger list refresh and reset scroll position
navigateToTasks()
return true
}
@@ -272,7 +291,11 @@ final class Suite6_ComprehensiveTaskTests: AuthenticatedUITestCase {
let task = findTask(title: originalTitle)
XCTAssertTrue(task.waitForExistence(timeout: defaultTimeout), "Task should exist")
task.tap()
// Open the task actions menu on the card (edit is inside a Menu, not a detail screen)
let actionsMenu = app.buttons["Task actions"].firstMatch
if actionsMenu.waitForExistence(timeout: defaultTimeout) {
actionsMenu.tap()
let editButton = app.buttons[AccessibilityIdentifiers.Task.editButton].firstMatch
if editButton.waitForExistence(timeout: defaultTimeout) {
@@ -296,6 +319,7 @@ final class Suite6_ComprehensiveTaskTests: AuthenticatedUITestCase {
}
}
}
}
// test10_updateAllTaskFields removed requires Actions menu accessibility identifiers
@@ -14,10 +14,21 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
// Test data tracking
var createdContractorNames: [String] = []
private static var hasCleanedStaleData = false
override func setUpWithError() throws {
try super.setUpWithError()
// One-time cleanup of stale contractors from previous test runs
if !Self.hasCleanedStaleData {
Self.hasCleanedStaleData = true
if let stale = TestAccountAPIClient.listContractors(token: session.token) {
for contractor in stale {
_ = TestAccountAPIClient.deleteContractor(token: session.token, id: contractor.id)
}
}
}
// Dismiss any open form from previous test
let cancelButton = app.buttons[AccessibilityIdentifiers.Contractor.formCancelButton].firstMatch
if cancelButton.exists { cancelButton.tap() }
@@ -133,6 +144,9 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
let created = items.first(where: { $0.name.contains(name) }) {
cleaner.trackContractor(created.id)
}
// Navigate to contractors tab to trigger list refresh and reset scroll position
navigateToContractors()
}
private func findContractor(name: String, scrollIfNeeded: Bool = true) -> XCUIElement {
@@ -248,9 +262,10 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
}
for (index, specialty) in specialties.enumerated() {
navigateToContractors()
let contractorName = "\(specialty) Expert \(timestamp)_\(index)"
let contractor = findContractor(name: contractorName)
XCTAssertTrue(contractor.exists, "\(specialty) contractor should exist in list")
XCTAssertTrue(contractor.waitForExistence(timeout: 10), "\(specialty) contractor should exist in list")
}
}
@@ -290,9 +305,10 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
}
for (index, (_, format)) in phoneFormats.enumerated() {
navigateToContractors()
let contractorName = "\(format) Phone \(timestamp)_\(index)"
let contractor = findContractor(name: contractorName)
XCTAssertTrue(contractor.exists, "Contractor with \(format) phone should exist")
XCTAssertTrue(contractor.waitForExistence(timeout: 10), "Contractor with \(format) phone should exist")
}
}
@@ -128,18 +128,35 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
}
private func submitForm(file: StaticString = #filePath, line: UInt = #line) {
// Dismiss keyboard so submit button is visible
dismissKeyboard()
// Dismiss keyboard by tapping outside form fields
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15)).tap()
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3)
// If keyboard still showing (can happen with long text / autocorrect), try Return key
if app.keyboards.firstMatch.exists {
app.typeText("\n")
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2)
}
let submitButton = docForm.saveButton
if !submitButton.exists || !submitButton.isHittable {
app.swipeUp()
_ = submitButton.waitForExistence(timeout: defaultTimeout)
_ = submitButton.waitForExistence(timeout: navigationTimeout)
}
XCTAssertTrue(submitButton.exists && submitButton.isEnabled, "Submit button should exist and be enabled", file: file, line: line)
// First tap attempt
if submitButton.isHittable {
submitButton.tap()
// Wait for form to dismiss after submit
submitButton.waitForNonExistence(timeout: navigationTimeout, file: file, line: line)
} else {
submitButton.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
// Wait for form to dismiss retry tap if button doesn't disappear
if !submitButton.waitForNonExistence(timeout: loginTimeout) && submitButton.exists {
submitButton.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
_ = submitButton.waitForNonExistence(timeout: loginTimeout)
}
}
/// Look up a just-created document by title and track it for API cleanup.
@@ -770,10 +787,17 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
submitForm()
// Just verify it was created (partial match)
// Track via API (also gives server time to process)
trackDocumentForCleanup(title: longTitle)
// Re-navigate to refresh the list after creation
navigateToDocuments()
switchToDocumentsTab()
// Verify it was created (partial match with wait)
let partialTitle = String(longTitle.prefix(30))
let documentExists = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] '\(partialTitle)'")).firstMatch.exists
XCTAssertTrue(documentExists, "Document with long title should be created")
let documentCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] '\(partialTitle)'")).firstMatch
XCTAssertTrue(documentCard.waitForExistence(timeout: loginTimeout), "Document with long title should be created")
}
func test23_CreateWarrantyWithSpecialCharacters() {
@@ -792,9 +816,17 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
submitForm()
// Track via API (also gives server time to process)
trackDocumentForCleanup(title: specialTitle)
// Re-navigate to refresh the list after creation
navigateToDocuments()
switchToWarrantiesTab()
// Verify it was created (partial match with wait)
let partialTitle = String(specialTitle.prefix(20))
let warrantyExists = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(partialTitle)'")).firstMatch.exists
XCTAssertTrue(warrantyExists, "Warranty with special characters should be created")
let warrantyCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(partialTitle)'")).firstMatch
XCTAssertTrue(warrantyCard.waitForExistence(timeout: loginTimeout), "Warranty with special characters should be created")
}
func test24_RapidTabSwitching() {
+46
View File
@@ -0,0 +1,46 @@
{
"configurations" : [
{
"id" : "B2000002-PARA-4B2B-BFDC-000000000002",
"name" : "Parallel Configuration",
"options" : {
}
}
],
"defaultOptions" : {
"targetForVariableExpansion" : {
"containerPath" : "container:honeyDue.xcodeproj",
"identifier" : "D4ADB376A7A4CFB73469E173",
"name" : "HoneyDue"
}
},
"testTargets" : [
{
"parallelizable" : true,
"selectedTests" : [
"AuthCriticalPathTests",
"NavigationCriticalPathTests",
"SmokeTests",
"SimpleLoginTest",
"Suite0_OnboardingRebuildTests",
"Suite1_RegistrationTests",
"Suite2_AuthenticationRebuildTests",
"Suite3_ResidenceRebuildTests",
"Suite4_ComprehensiveResidenceTests",
"Suite5_TaskTests",
"Suite6_ComprehensiveTaskTests",
"Suite7_ContractorTests",
"Suite8_DocumentWarrantyTests",
"Suite9_IntegrationE2ETests",
"Suite10_ComprehensiveE2ETests"
],
"target" : {
"containerPath" : "container:honeyDue.xcodeproj",
"identifier" : "1CBF1BEC2ECD9768001BF56C",
"name" : "HoneyDueUITests"
}
}
],
"version" : 1
}
+31
View File
@@ -0,0 +1,31 @@
{
"configurations" : [
{
"id" : "A1000001-SEED-4A1A-BFDC-000000000001",
"name" : "Seed Configuration",
"options" : {
}
}
],
"defaultOptions" : {
"targetForVariableExpansion" : {
"containerPath" : "container:honeyDue.xcodeproj",
"identifier" : "D4ADB376A7A4CFB73469E173",
"name" : "HoneyDue"
}
},
"testTargets" : [
{
"selectedTests" : [
"AAA_SeedTests"
],
"target" : {
"containerPath" : "container:honeyDue.xcodeproj",
"identifier" : "1CBF1BEC2ECD9768001BF56C",
"name" : "HoneyDueUITests"
}
}
],
"version" : 1
}
+34 -32
View File
@@ -663,7 +663,7 @@
0248CABA5A5197845F2E5C26 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
APP_GROUP_IDENTIFIER = group.com.tt.honeyDue;
APP_GROUP_IDENTIFIER = group.com.myhoneydue.honeyDue;
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@@ -671,14 +671,15 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = V3PF3M6B6U;
DEVELOPMENT_TEAM = X86BR9WTLD;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iosApp/Info.plist;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO;
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs access to your camera to take photos of completed tasks";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs access to your photo library to select photos of completed tasks";
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs camera access to take photos of tasks, documents, and receipts.";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "honeyDue needs permission to save photos to your library.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs photo library access to attach photos to tasks and documents.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -688,7 +689,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue;
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -698,14 +699,14 @@
1C0789552EBC218D00392B46 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
APP_GROUP_IDENTIFIER = group.com.tt.honeyDue.dev;
APP_GROUP_IDENTIFIER = group.com.myhoneydue.honeyDue.dev;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = HoneyDueExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V3PF3M6B6U;
DEVELOPMENT_TEAM = X86BR9WTLD;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = HoneyDue/Info.plist;
@@ -719,7 +720,7 @@
);
MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "-DWIDGET_EXTENSION";
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.dev.HoneyDueExtension;
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.dev.HoneyDueExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -734,14 +735,14 @@
1C0789562EBC218D00392B46 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
APP_GROUP_IDENTIFIER = group.com.tt.honeyDue;
APP_GROUP_IDENTIFIER = group.com.myhoneydue.honeyDue;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = HoneyDueExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V3PF3M6B6U;
DEVELOPMENT_TEAM = X86BR9WTLD;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = HoneyDue/Info.plist;
@@ -755,7 +756,7 @@
);
MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "-DWIDGET_EXTENSION";
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.HoneyDueExtension;
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.HoneyDueExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -774,12 +775,12 @@
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V3PF3M6B6U;
DEVELOPMENT_TEAM = X86BR9WTLD;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.tt.HoneyDueTests;
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.HoneyDueTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -800,12 +801,12 @@
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V3PF3M6B6U;
DEVELOPMENT_TEAM = X86BR9WTLD;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.tt.HoneyDueTests;
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.HoneyDueTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -825,7 +826,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V3PF3M6B6U;
DEVELOPMENT_TEAM = X86BR9WTLD;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = HoneyDueQLPreview/Info.plist;
@@ -838,7 +839,7 @@
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.dev.HoneyDueQLPreview;
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.dev.HoneyDueQLPreview;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -856,7 +857,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V3PF3M6B6U;
DEVELOPMENT_TEAM = X86BR9WTLD;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = HoneyDueQLPreview/Info.plist;
@@ -869,7 +870,7 @@
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.HoneyDueQLPreview;
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.HoneyDueQLPreview;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -887,7 +888,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V3PF3M6B6U;
DEVELOPMENT_TEAM = X86BR9WTLD;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = HoneyDueQLThumbnail/Info.plist;
@@ -900,7 +901,7 @@
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.dev.HoneyDueQLThumbnail;
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.dev.HoneyDueQLThumbnail;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -918,7 +919,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V3PF3M6B6U;
DEVELOPMENT_TEAM = X86BR9WTLD;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = HoneyDueQLThumbnail/Info.plist;
@@ -931,7 +932,7 @@
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.HoneyDueQLThumbnail;
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.HoneyDueQLThumbnail;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -948,13 +949,13 @@
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V3PF3M6B6U;
DEVELOPMENT_TEAM = X86BR9WTLD;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
MACOSX_DEPLOYMENT_TARGET = 26.1;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.tt.HoneyDueUITests;
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.HoneyDueUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -974,13 +975,13 @@
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V3PF3M6B6U;
DEVELOPMENT_TEAM = X86BR9WTLD;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
MACOSX_DEPLOYMENT_TARGET = 26.1;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.tt.HoneyDueUITests;
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.HoneyDueUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -1123,7 +1124,7 @@
E767E942685C7832D51FF978 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
APP_GROUP_IDENTIFIER = group.com.tt.honeyDue.dev;
APP_GROUP_IDENTIFIER = group.com.myhoneydue.honeyDue.dev;
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@@ -1131,14 +1132,15 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = V3PF3M6B6U;
DEVELOPMENT_TEAM = X86BR9WTLD;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iosApp/Info.plist;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO;
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs access to your camera to take photos of completed tasks";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs access to your photo library to select photos of completed tasks";
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs camera access to take photos of tasks, documents, and receipts.";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "honeyDue needs permission to save photos to your library.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs photo library access to attach photos to tasks and documents.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -1148,7 +1150,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.dev;
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.dev;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -17,6 +17,16 @@ enum AnalyticsEvent {
// MARK: - Task
case taskCreated(residenceId: Int32)
// MARK: - Onboarding (First Task screen)
// Event names must stay in lockstep with
// composeApp/.../analytics/Analytics.kt so PostHog funnels join cleanly
// across iOS and Android.
case onboardingSuggestionsLoaded(count: Int, profileCompleteness: Double)
case onboardingSuggestionAccepted(templateId: Int32, relevanceScore: Double)
case onboardingBrowseTemplateAccepted(templateId: Int32, categoryId: Int32?)
case onboardingTasksCreated(count: Int)
case onboardingTaskStepSkipped(reason: String)
// MARK: - Contractor
case contractorCreated
case contractorShared
@@ -64,6 +74,26 @@ enum AnalyticsEvent {
case .taskCreated(let residenceId):
return ("task_created", ["residence_id": residenceId])
// Onboarding
case .onboardingSuggestionsLoaded(let count, let completeness):
return ("onboarding_suggestions_loaded", [
"count": count,
"profile_completeness": completeness
])
case .onboardingSuggestionAccepted(let templateId, let relevance):
return ("onboarding_suggestion_accepted", [
"template_id": templateId,
"relevance_score": relevance
])
case .onboardingBrowseTemplateAccepted(let templateId, let categoryId):
var props: [String: Any] = ["template_id": templateId]
if let categoryId { props["category_id"] = categoryId }
return ("onboarding_browse_template_accepted", props)
case .onboardingTasksCreated(let count):
return ("onboarding_tasks_created", ["count": count])
case .onboardingTaskStepSkipped(let reason):
return ("onboarding_task_step_skipped", ["reason": reason])
// Contractor
case .contractorCreated:
return ("contractor_created", nil)
@@ -91,6 +91,47 @@ final class AnalyticsManager {
PostHogSDK.shared.capture("screen_viewed", properties: props)
}
// MARK: - Exception / Crash Capture
/// Capture a Swift Error as a PostHog `$exception` event.
func captureException(_ error: Error, properties: [String: Any]? = nil) {
guard isConfigured else { return }
var props: [String: Any] = [
"$exception_type": String(describing: type(of: error)),
"$exception_message": error.localizedDescription,
"$exception_stack_trace_raw": Thread.callStackSymbols.joined(separator: "\n")
]
if let properties { props.merge(properties) { _, new in new } }
PostHogSDK.shared.capture("$exception", properties: props)
}
/// Capture an NSException as a PostHog `$exception` event.
func captureNSException(_ exception: NSException, isFatal: Bool = false) {
guard isConfigured else { return }
PostHogSDK.shared.capture("$exception", properties: [
"$exception_type": exception.name.rawValue,
"$exception_message": exception.reason ?? "No reason",
"$exception_stack_trace_raw": exception.callStackSymbols.joined(separator: "\n"),
"$exception_is_fatal": isFatal
])
}
/// Install an uncaught NSException handler that captures crashes to PostHog
/// before the app terminates. Call this once after configure().
func setupExceptionHandler() {
NSSetUncaughtExceptionHandler { exception in
// Cannot use AnalyticsManager.shared here (may deadlock on @MainActor),
// so call PostHogSDK directly.
PostHogSDK.shared.capture("$exception", properties: [
"$exception_type": exception.name.rawValue,
"$exception_message": exception.reason ?? "No reason",
"$exception_stack_trace_raw": exception.callStackSymbols.joined(separator: "\n"),
"$exception_is_fatal": true
])
PostHogSDK.shared.flush()
}
}
// MARK: - Flush & Reset
func flush() {
@@ -16,7 +16,7 @@ final class BackgroundTaskManager {
static let shared = BackgroundTaskManager()
/// Background task identifier - must match Info.plist BGTaskSchedulerPermittedIdentifiers
static let taskIdentifier = "com.tt.honeyDue.refresh"
static let taskIdentifier = "com.myhoneydue.honeyDue.refresh"
/// Time window for overnight refresh (12:00 AM - 4:00 AM)
private let refreshWindowStartHour = 0 // 12:00 AM
@@ -187,7 +187,7 @@ final class BackgroundTaskManager {
/// Force a background refresh for testing (only works in debug builds with Xcode)
/// Usage: In Xcode debugger console:
/// e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.tt.honeyDue.refresh"]
/// e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.myhoneydue.honeyDue.refresh"]
func debugInfo() -> String {
return """
Background Task Debug Info:
@@ -38,6 +38,7 @@ struct AuthenticatedImage: View {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: contentMode)
.accessibilityLabel("Image")
case .failure:
errorView
}
@@ -27,6 +27,7 @@ struct TaskSummaryCard: View {
.font(.headline)
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary)
.accessibilityAddTraits(.isHeader)
ForEach(filteredCategories, id: \.name) { category in
TaskCategoryRow(category: category)
@@ -80,6 +81,7 @@ struct TaskCategoryRow: View {
.padding(12)
.background(categoryColor.opacity(0.1))
.cornerRadius(8)
.accessibilityElement(children: .combine)
}
}
@@ -76,11 +76,13 @@ struct ContractorCard: View {
.foregroundColor(contractor.isFavorite ? Color.appAccent : Color.appTextSecondary.opacity(0.7))
}
.buttonStyle(PlainButtonStyle())
.accessibilityLabel(contractor.isFavorite ? "Remove \(contractor.name) from favorites" : "Add \(contractor.name) to favorites")
// Chevron
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(Color.appTextSecondary.opacity(0.7))
.accessibilityHidden(true)
}
.padding(AppSpacing.md)
.background(Color.appBackgroundSecondary)
@@ -64,6 +64,7 @@ struct ContractorDetailView: View {
.foregroundColor(Color.appPrimary)
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.menuButton)
}
.accessibilityLabel("Contractor actions")
}
}
}
@@ -175,6 +176,7 @@ struct ContractorDetailView: View {
Text(contractor.name)
.font(.title3.weight(.semibold))
.foregroundColor(Color.appTextPrimary)
.accessibilityAddTraits(.isHeader)
// Company
if let company = contractor.company {
@@ -79,6 +79,7 @@ struct ContractorFormSheet: View {
}
} header: {
Text(L10n.Contractors.basicInfoSection)
.accessibilityAddTraits(.isHeader)
} footer: {
Text(L10n.Contractors.basicInfoFooter)
.font(.caption)
@@ -155,6 +156,7 @@ struct ContractorFormSheet: View {
}
} header: {
Text(L10n.Contractors.contactInfoSection)
.accessibilityAddTraits(.isHeader)
}
.sectionBackground()
@@ -226,6 +228,7 @@ struct ContractorFormSheet: View {
}
} header: {
Text(L10n.Contractors.addressSection)
.accessibilityAddTraits(.isHeader)
}
.sectionBackground()
@@ -19,9 +19,10 @@ class ContractorViewModel: ObservableObject {
// MARK: - Private Properties
private var cancellables = Set<AnyCancellable>()
/// Guards against redundant detail reloads immediately after a mutation that already
/// set selectedContractor from its response.
private var suppressNextDetailReload = false
/// Timestamp of the last mutation that already set selectedContractor from its response.
/// Used to suppress redundant detail reloads within 1 second of a mutation.
/// Unlike a boolean flag, this naturally expires and can never get stuck.
private var lastMutationTime: Date?
// MARK: - Initialization
@@ -39,8 +40,10 @@ class ContractorViewModel: ObservableObject {
if let self = self,
let currentId = self.selectedContractor?.id,
contractors.contains(where: { $0.id == currentId }) {
if self.suppressNextDetailReload {
self.suppressNextDetailReload = false
// Skip reload if a mutation just updated selectedContractor within the last second
if let mutationTime = self.lastMutationTime,
Date().timeIntervalSince(mutationTime) < 1.0 {
// Mutation was recent, no need to reload
} else {
self.reloadSelectedContractorQuietly(id: currentId)
}
@@ -120,7 +123,7 @@ class ContractorViewModel: ObservableObject {
self.successMessage = "Contractor added successfully"
self.isCreating = false
// Update selectedContractor with the newly created contractor
self.suppressNextDetailReload = true
self.lastMutationTime = Date()
self.selectedContractor = success.data
completion(true)
} else if let error = ApiResultBridge.error(from: result) {
@@ -152,7 +155,7 @@ class ContractorViewModel: ObservableObject {
self.successMessage = "Contractor updated successfully"
self.isUpdating = false
// Update selectedContractor immediately so detail views stay fresh
self.suppressNextDetailReload = true
self.lastMutationTime = Date()
self.selectedContractor = success.data
completion(true)
} else if let error = ApiResultBridge.error(from: result) {
@@ -209,7 +212,7 @@ class ContractorViewModel: ObservableObject {
if let success = result as? ApiResultSuccess<Contractor> {
// Update selectedContractor immediately so detail views stay fresh
self.suppressNextDetailReload = true
self.lastMutationTime = Date()
self.selectedContractor = success.data
completion(true)
} else if let error = ApiResultBridge.error(from: result) {
@@ -119,6 +119,7 @@ struct ContractorsListView: View {
.font(.system(size: 16, weight: .medium))
.foregroundColor(showFavoritesOnly ? Color.appAccent : Color.appTextSecondary)
}
.accessibilityLabel(showFavoritesOnly ? "Show all contractors" : "Show favorites only")
// Specialty Filter
Menu {
@@ -142,6 +143,7 @@ struct ContractorsListView: View {
.font(.system(size: 16, weight: .medium))
.foregroundColor(selectedSpecialty != nil ? Color.appPrimary : Color.appTextSecondary)
}
.accessibilityLabel("Filter by specialty")
// Add Button
Button(action: {
@@ -156,6 +158,7 @@ struct ContractorsListView: View {
OrganicToolbarButton(systemName: "plus", isPrimary: true)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.addButton)
.accessibilityLabel("Add contractor")
}
}
}
@@ -354,10 +357,12 @@ private struct OrganicContractorCard: View {
.foregroundColor(contractor.isFavorite ? Color.appAccent : Color.appTextSecondary)
}
.buttonStyle(.plain)
.accessibilityLabel(contractor.isFavorite ? "Remove \(contractor.name) from favorites" : "Add \(contractor.name) to favorites")
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.appTextSecondary.opacity(0.5))
.accessibilityHidden(true)
}
.padding(16)
.background(
+38 -12
View File
@@ -41,7 +41,6 @@ class DataManagerObservable: ObservableObject {
// MARK: - Tasks
@Published var allTasks: TaskColumnsResponse?
@Published var tasksByResidence: [Int32: TaskColumnsResponse] = [:]
// MARK: - Documents
@@ -191,15 +190,6 @@ class DataManagerObservable: ObservableObject {
}
observationTasks.append(allTasksTask)
// TasksByResidence
let tasksByResidenceTask = Task { [weak self] in
for await tasks in DataManager.shared.tasksByResidence {
guard let self else { return }
self.tasksByResidence = self.convertIntMap(tasks)
}
}
observationTasks.append(tasksByResidenceTask)
// Documents
let documentsTask = Task { [weak self] in
for await docs in DataManager.shared.documents {
@@ -389,6 +379,8 @@ class DataManagerObservable: ObservableObject {
}
var result: [Int32: V] = [:]
var failedKeys = 0
let totalKeys = nsDict.allKeys.count
for key in nsDict.allKeys {
guard let value = nsDict[key], let typedValue = value as? V else { continue }
@@ -398,9 +390,16 @@ class DataManagerObservable: ObservableObject {
result[kotlinKey.int32Value] = typedValue
} else if let nsNumberKey = key as? NSNumber {
result[nsNumberKey.int32Value] = typedValue
} else {
failedKeys += 1
print("DataManagerObservable: convertIntMap failed to convert key of type \(type(of: key)): \(key)")
}
}
if failedKeys > 0 {
print("DataManagerObservable: Warning: \(failedKeys) of \(totalKeys) keys failed to convert in convertIntMap")
}
return result
}
@@ -416,6 +415,8 @@ class DataManagerObservable: ObservableObject {
}
var result: [Int32: [V]] = [:]
var failedKeys = 0
let totalKeys = nsDict.allKeys.count
for key in nsDict.allKeys {
guard let value = nsDict[key] else { continue }
@@ -435,9 +436,16 @@ class DataManagerObservable: ObservableObject {
result[kotlinKey.int32Value] = typedValue
} else if let nsNumberKey = key as? NSNumber {
result[nsNumberKey.int32Value] = typedValue
} else {
failedKeys += 1
print("DataManagerObservable: convertIntArrayMap failed to convert key of type \(type(of: key)): \(key)")
}
}
if failedKeys > 0 {
print("DataManagerObservable: Warning: \(failedKeys) of \(totalKeys) keys failed to convert in convertIntArrayMap")
}
return result
}
@@ -501,9 +509,27 @@ class DataManagerObservable: ObservableObject {
// MARK: - Task Helpers
/// Get tasks for a specific residence
/// Get tasks for a specific residence derived from `_allTasks`
/// (single source of truth) by filtering in-memory.
func tasks(for residenceId: Int32) -> TaskColumnsResponse? {
return tasksByResidence[residenceId]
guard let all = allTasks else { return nil }
let filteredColumns = all.columns.map { column -> TaskColumn in
let filtered = column.tasks.filter { Int32($0.residenceId) == residenceId }
return TaskColumn(
name: column.name,
displayName: column.displayName,
buttonTypes: column.buttonTypes,
icons: column.icons,
color: column.color,
tasks: filtered,
count: Int32(filtered.count)
)
}
return TaskColumnsResponse(
columns: filteredColumns,
daysThreshold: all.daysThreshold,
residenceId: String(residenceId)
)
}
/// Get documents for a specific residence
@@ -37,6 +37,7 @@ struct DocumentCard: View {
.frame(width: 56, height: 56)
})
.padding(AppSpacing.md)
.accessibilityHidden(true)
Spacer()
}
@@ -77,11 +78,13 @@ struct DocumentCard: View {
Image(systemName: "chevron.right")
.foregroundColor(Color.appTextSecondary)
.font(.system(size: 14))
.accessibilityHidden(true)
}
.padding(AppSpacing.md)
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.md)
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
.accessibilityElement(children: .combine)
}
private func getDocTypeDisplayName(_ type: String) -> String {
@@ -115,6 +115,7 @@ struct WarrantyCard: View {
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.md)
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
.accessibilityElement(children: .combine)
}
private func getCategoryDisplayName(_ category: String) -> String {
@@ -68,6 +68,7 @@ struct DocumentDetailView: View {
Image(systemName: "ellipsis.circle")
.accessibilityIdentifier(AccessibilityIdentifiers.Document.menuButton)
}
.accessibilityLabel("Document actions")
}
}
}
@@ -483,6 +484,7 @@ struct DocumentDetailView: View {
Text(title)
.font(.system(size: 16, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.accessibilityAddTraits(.isHeader)
}
@ViewBuilder
@@ -163,6 +163,7 @@ struct DocumentFormView: View {
.accessibilityIdentifier(AccessibilityIdentifiers.Document.providerContactField)
} header: {
Text(L10n.Documents.warrantyDetails)
.accessibilityAddTraits(.isHeader)
} footer: {
Text(L10n.Documents.requiredWarrantyFields)
.font(.caption)
@@ -363,6 +364,7 @@ struct DocumentFormView: View {
.keyboardDismissToolbar()
} header: {
Text(L10n.Documents.basicInformation)
.accessibilityAddTraits(.isHeader)
} footer: {
Text(L10n.Documents.requiredTitle)
.font(.caption)
@@ -130,14 +130,23 @@ class DocumentViewModel: ObservableObject {
if let success = result as? ApiResultSuccess<Document> {
if !images.isEmpty {
guard let documentId = success.data?.id?.int32Value else {
self.errorMessage = "Document created, but image upload could not start"
guard let document = success.data,
let documentId = document.id?.int32Value,
document.title != nil else {
let missingFields = [
success.data == nil ? "data" : nil,
success.data?.id == nil ? "id" : nil,
success.data?.title == nil ? "title" : nil
].compactMap { $0 }.joined(separator: ", ")
print("DocumentViewModel: Document creation returned incomplete data (missing: \(missingFields)), skipping image upload")
self.errorMessage = "Document created with incomplete data — images were not uploaded"
self.isLoading = false
completion(false, self.errorMessage)
return
}
if let uploadError = await self.uploadImages(documentId: documentId, images: images) {
print("DocumentViewModel: Image upload failed for document \(documentId): \(uploadError)")
self.errorMessage = uploadError
self.isLoading = false
completion(false, self.errorMessage)
@@ -118,6 +118,7 @@ struct DocumentsWarrantiesView: View {
.font(.system(size: 16, weight: .medium))
.foregroundColor(showActiveOnly ? Color.appPrimary : Color.appTextSecondary)
}
.accessibilityLabel(showActiveOnly ? "Show all warranties" : "Show active warranties only")
}
// Filter Menu
@@ -160,6 +161,7 @@ struct DocumentsWarrantiesView: View {
.font(.system(size: 16, weight: .medium))
.foregroundColor((selectedCategory != nil || selectedDocType != nil) ? Color.appPrimary : Color.appTextSecondary)
}
.accessibilityLabel("Filter documents")
// Add Button
Button(action: {
@@ -174,6 +176,7 @@ struct DocumentsWarrantiesView: View {
OrganicDocToolbarButton()
}
.accessibilityIdentifier(AccessibilityIdentifiers.Document.addButton)
.accessibilityLabel("Add document")
}
}
}
@@ -287,6 +290,8 @@ private struct OrganicSegmentButton: View {
.background(isSelected ? Color.appPrimary : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
.accessibilityLabel(title)
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}
@@ -22,11 +22,13 @@ struct AccessibilityIdentifiers {
static let registerConfirmPasswordField = "Register.ConfirmPasswordField"
static let registerButton = "Register.RegisterButton"
static let registerCancelButton = "Register.CancelButton"
static let registerErrorMessage = "Register.ErrorMessage"
// Verification
static let verificationCodeField = "Verification.CodeField"
static let verifyButton = "Verification.VerifyButton"
static let resendCodeButton = "Verification.ResendButton"
static let verificationLogoutButton = "Verification.LogoutButton"
}
// MARK: - Navigation
@@ -46,6 +48,9 @@ struct AccessibilityIdentifiers {
static let addButton = "Residence.AddButton"
static let residencesList = "Residence.List"
static let residenceCard = "Residence.Card"
/// Prefix for individual residence cells in the list. Suffix with the
/// residence id to address a specific cell (e.g. "Residence.Cell.42").
static let cellPrefix = "Residence.Cell"
static let emptyStateView = "Residence.EmptyState"
static let emptyStateButton = "Residence.EmptyState.AddButton"
@@ -85,7 +90,15 @@ struct AccessibilityIdentifiers {
static let refreshButton = "Task.RefreshButton"
static let tasksList = "Task.List"
static let taskCard = "Task.Card"
/// Prefix for individual task rows. Suffix with the task id to
/// address a specific row (e.g. "Task.Row.42"). Use `BEGINSWITH`
/// in tests to detect "any task row exists".
static let rowPrefix = "Task.Row"
static let emptyStateView = "Task.EmptyState"
/// Label rendered when a residence-detail tasks section has no tasks
/// in any kanban column. Asserted ABSENT after onboarding bulk-create
/// in the gitea#2 regression test.
static let noTasksLabel = "Task.NoTasksLabel"
static let kanbanView = "Task.KanbanView"
static let overdueColumn = "Task.Column.Overdue"
static let upcomingColumn = "Task.Column.Upcoming"
@@ -227,8 +240,24 @@ struct AccessibilityIdentifiers {
static let taskSelectionCounter = "Onboarding.TaskSelectionCounter"
static let addPopularTasksButton = "Onboarding.AddPopularTasksButton"
static let addTasksContinueButton = "Onboarding.AddTasksContinueButton"
/// Submit/continue button at the bottom of the First-Task screen.
/// Triggers `POST /api/tasks/bulk/` for the selected templates.
static let submitTasksButton = "Onboarding.SubmitTasksButton"
/// Tab bar control above the task list. The "Browse All" segment is
/// addressed via `app.buttons["Browse All"]` from the segmented
/// picker once this identifier is set.
static let firstTaskTabBar = "Onboarding.FirstTaskTabBar"
/// Tab segment that shows the full template catalog.
/// Tap from a test by addressing the Picker's segment label
/// "Browse All" within the element identified above.
static let browseAllTab = "Onboarding.BrowseAllTab"
static let taskCategorySection = "Onboarding.TaskCategorySection"
static let taskTemplateRow = "Onboarding.TaskTemplateRow"
/// Prefix for individual template rows on the First-Task screen
/// (Browse All tab). Suffix with the backend template id
/// e.g. `"Onboarding.TemplateRow.123"`. Tests use `BEGINSWITH` to
/// pick the first N rows deterministically without knowing ids.
static let templateRowPrefix = "Onboarding.TemplateRow"
// Subscription Screen
static let subscriptionTitle = "Onboarding.SubscriptionTitle"
@@ -0,0 +1,91 @@
import Foundation
/// Centralized accessibility labels for VoiceOver
/// These labels provide human-readable descriptions for screen reader users
struct A11y {
// MARK: - Authentication
struct Auth {
static let loginButton = "Sign in"
static let appleSignIn = "Sign in with Apple"
static let googleSignIn = "Sign in with Google"
static let forgotPassword = "Forgot password"
static let signUp = "Create account"
static let passwordToggle = "Toggle password visibility"
static let appLogo = "honeyDue app logo"
}
// MARK: - Navigation
struct Navigation {
static let residencesTab = "Properties"
static let tasksTab = "Tasks"
static let contractorsTab = "Contractors"
static let documentsTab = "Documents"
static let settingsButton = "Settings"
static let addButton = "Add"
static let backButton = "Back"
static let closeButton = "Close"
static let editButton = "Edit"
static let deleteButton = "Delete"
static let saveButton = "Save"
static let cancelButton = "Cancel"
}
// MARK: - Residence
struct Residence {
static func card(name: String, taskCount: Int, overdueCount: Int) -> String {
"\(name), \(taskCount) tasks, \(overdueCount) overdue"
}
static let addProperty = "Add new property"
static let primaryBadge = "Primary property"
static func openInMaps(address: String) -> String { "Open \(address) in Maps" }
static func shareCode(code: String) -> String { "Share code: \(code)" }
static let copyShareCode = "Copy share code"
static let generateShareCode = "Generate new share code"
static func removeUser(name: String) -> String { "Remove \(name) from property" }
}
// MARK: - Task
struct Task {
static func card(title: String, priority: String, dueDate: String) -> String {
"\(title), \(priority) priority, due \(dueDate)"
}
static let addTask = "Add new task"
static let taskActions = "Task actions"
static func priorityBadge(level: String) -> String { "Priority: \(level)" }
static func statusBadge(status: String) -> String { "Status: \(status)" }
static func completionCount(count: Int) -> String { "View \(count) completions" }
static func rating(value: Int) -> String { "Rated \(value) out of 5" }
static let markInProgress = "Mark as in progress"
static let completeTask = "Complete task"
static let archiveTask = "Archive task"
static let cancelTask = "Cancel task"
}
// MARK: - Contractor
struct Contractor {
static func card(name: String, company: String?, specialty: String) -> String {
[name, company, specialty].compactMap { $0 }.joined(separator: ", ")
}
static let addContractor = "Add new contractor"
static func toggleFavorite(name: String, isFavorite: Bool) -> String {
isFavorite ? "Remove \(name) from favorites" : "Add \(name) to favorites"
}
}
// MARK: - Document
struct Document {
static func card(title: String, type: String) -> String { "\(title), \(type)" }
static let addDocument = "Add new document"
}
// MARK: - Common
struct Common {
static func stat(value: String, label: String) -> String { "\(value) \(label)" }
static let decorative = "" // For .accessibilityHidden(true)
static let retryButton = "Try again"
static let dismissError = "Dismiss error"
static func photo(index: Int) -> String { "Photo \(index)" }
static let removePhoto = "Remove photo"
}
}
@@ -0,0 +1,100 @@
import Foundation
import ImageIO
import UIKit
import UniformTypeIdentifiers
/// Memory-efficient image resizer for upload preprocessing.
///
/// Why not `UIImage.jpegData(compressionQuality:)` directly? UIImage decodes
/// the entire source bitmap into RAM before re-encoding a 12 MP iPhone
/// photo decompresses to ~50 MB regardless of how big the JPEG is. With
/// multiple selected images this can blow up memory on older devices.
///
/// `CGImageSourceCreateThumbnailAtIndex` reads the source incrementally and
/// only allocates the *resized* bitmap, paying memory proportional to the
/// output size (a 2048×1536 thumbnail is ~12 MB, but the source is never
/// fully decoded).
///
/// Reference: https://nshipster.com/image-resizing/ section "Image I/O".
enum ImageDownsampler {
/// Settings tuned per upload category. Edit here, not at call sites.
struct Profile {
/// Largest dimension (in points-after-scale, i.e. pixels) of the
/// downsampled image. The shorter edge is set proportionally.
let maxPixelEdge: CGFloat
/// JPEG quality, 0...1. 0.85 is the WhatsApp / Slack default
/// visually indistinguishable from quality 1.0 at typical viewing
/// sizes; cuts file size by ~3x.
let jpegQuality: CGFloat
static let completion = Profile(maxPixelEdge: 2048, jpegQuality: 0.85)
static let documentImage = Profile(maxPixelEdge: 2560, jpegQuality: 0.90)
}
/// Downsample raw image bytes (e.g. from a `PHPickerResult`'s
/// `loadDataRepresentation`) into a JPEG `Data` ready for upload.
///
/// - Returns: encoded JPEG bytes, or nil if decoding failed.
static func downsample(data: Data, profile: Profile) -> Data? {
let options: [CFString: Any] = [
kCGImageSourceShouldCache: false, // don't keep the full image around
kCGImageSourceTypeIdentifierHint: UTType.jpeg.identifier as CFString, // best-effort hint
]
guard let source = CGImageSourceCreateWithData(data as CFData, options as CFDictionary) else {
return nil
}
return downsample(source: source, profile: profile)
}
/// Downsample from a file URL (e.g. PhotosPicker's
/// `loadFileRepresentation`). Avoids materializing the full image in
/// memory before resize.
static func downsample(url: URL, profile: Profile) -> Data? {
let options: [CFString: Any] = [
kCGImageSourceShouldCache: false,
]
guard let source = CGImageSourceCreateWithURL(url as CFURL, options as CFDictionary) else {
return nil
}
return downsample(source: source, profile: profile)
}
/// Convenience for callers that already have a `UIImage` (e.g. from
/// `UIImagePickerController`). We round-trip through PNG to get raw
/// data, then use the data path. Slightly less efficient than starting
/// from URL/Data, but still avoids the JPEG re-encode penalty for the
/// resize step itself.
static func downsample(uiImage: UIImage, profile: Profile) -> Data? {
// Use PNG for the intermediate to avoid double-JPEG quality loss.
// Even though PNG is larger, this stays in memory only briefly.
guard let intermediate = uiImage.pngData() else { return nil }
return downsample(data: intermediate, profile: profile)
}
// MARK: - Internal
private static func downsample(source: CGImageSource, profile: Profile) -> Data? {
// Compute the max pixel size in screen-resolution-aware units. We
// use a fixed pixel cap because uploads are about bytes, not display.
let scale: CGFloat = 1.0
let maxDimensionInPixels = profile.maxPixelEdge * scale
let downsampleOptions: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true, // decode on the calling thread
kCGImageSourceCreateThumbnailWithTransform: true, // honor EXIF orientation
kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels,
]
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(
source, 0, downsampleOptions as CFDictionary
) else {
return nil
}
let uiImage = UIImage(cgImage: cgImage)
return uiImage.jpegData(compressionQuality: profile.jpegQuality)
}
}
@@ -0,0 +1,274 @@
import Foundation
import ComposeApp
/// Three-step direct-to-B2 image upload.
///
/// Flow:
/// 1. POST /api/uploads/presign server returns a B2 POST policy + form
/// fields scoped to a single object key with a content-length-range
/// condition that B2 enforces at the protocol level.
/// 2. Multipart POST the bytes directly to B2, no API server in the data
/// path. B2 rejects the upload if the bytes don't match the policy.
/// 3. Caller passes the returned `uploadId` to /api/task-completions/ or
/// /api/documents/ via `upload_ids[]`. The server HEADs the object,
/// confirms the size, and creates the linked entity rows.
///
/// All errors map to `PresignedUploaderError` the Swift call site can
/// translate to user-facing copy without parsing nested HTTP details.
enum PresignedUploaderError: Error, LocalizedError {
case notAuthenticated
case presignFailed(status: Int, body: String)
case uploadFailed(status: Int, body: String)
case sessionError(Error)
var errorDescription: String? {
switch self {
case .notAuthenticated:
return "You're not signed in."
case .presignFailed(let status, _):
switch status {
case 413: return "That photo is too large after resizing. Try a different one."
case 422: return "That image format isn't supported."
case 429: return "You're uploading too many photos. Try again in a few minutes."
default: return "Couldn't start upload (server returned \(status))."
}
case .uploadFailed(let status, _):
return "Upload failed (B2 returned \(status))."
case .sessionError(let err):
return err.localizedDescription
}
}
}
/// Category passed to the presign endpoint. Matches the Go server's
/// `UploadCategory` constants in `internal/models/pending_upload.go`.
enum UploadCategory: String {
case completion = "completion"
case documentImage = "document_image"
case documentFile = "document_file"
}
/// Presigned-URL upload helper. Stateless instantiate freely.
///
/// Concurrency: each `upload(...)` call runs to completion sequentially.
/// For multiple images the caller can run several uploads in parallel via
/// `withTaskGroup`; the server's per-user concurrency cap (10 in-flight
/// presigns) is enforced server-side.
final class PresignedUploader {
/// API base URL read from KMP's ApiConfig so iOS and Android stay
/// in sync (LOCAL vs DEV vs PROD without divergent constants).
private let apiBaseURL: String
/// Bearer token. Read once at init; if the user re-auths mid-session,
/// the caller should construct a fresh PresignedUploader.
private let authToken: String
private let session: URLSession
init?(session: URLSession = .shared) {
// ApiConfig.shared.getBaseUrl() resolves Environment (LOCAL/DEV/PROD).
// DataManager.shared.authToken is a StateFlow<String?> read the
// current value via .value (SKIE-exposed property).
let baseUrl = ApiConfig.shared.getBaseUrl()
guard let token = DataManager.shared.authToken.value as String? else {
return nil
}
self.apiBaseURL = baseUrl
self.authToken = token
self.session = session
}
/// Upload `data` to B2 in the named category. Returns the
/// pending_uploads.id the caller passes via `upload_ids[]` to attach
/// the object to a real entity.
func upload(
data: Data,
category: UploadCategory,
contentType: String = "image/jpeg",
fileName: String = "image.jpg"
) async throws -> Int32 {
// Step 1: presign
let presigned = try await requestPresign(
category: category,
contentType: contentType,
contentLength: Int64(data.count)
)
// Step 2: direct POST to B2
try await postToStorage(
uploadURL: presigned.uploadUrl,
fields: presigned.fields,
data: data,
contentType: contentType,
fileName: fileName
)
return Int32(presigned.id)
}
/// Upload several images in parallel, returning their upload_ids in
/// input order. Stops at the first failure and surfaces it.
func uploadAll(
items: [(Data, String)],
category: UploadCategory,
contentType: String = "image/jpeg"
) async throws -> [Int32] {
try await withThrowingTaskGroup(of: (Int, Int32).self) { group in
for (idx, item) in items.enumerated() {
let (data, name) = item
group.addTask { [self] in
let id = try await upload(
data: data,
category: category,
contentType: contentType,
fileName: name
)
return (idx, id)
}
}
var pairs: [(Int, Int32)] = []
for try await pair in group {
pairs.append(pair)
}
return pairs.sorted { $0.0 < $1.0 }.map { $0.1 }
}
}
// MARK: - Step 1: presign
private struct PresignBody: Encodable {
let category: String
let content_type: String
let content_length: Int64
}
private struct PresignResponse: Decodable {
let id: Int
let upload_url: String
let fields: [String: String]
let key: String
let expires_at: String
// Map snake_case to nicer Swift names at the call site.
var uploadUrl: String { upload_url }
}
private func requestPresign(
category: UploadCategory,
contentType: String,
contentLength: Int64
) async throws -> PresignResponse {
guard var url = URL(string: apiBaseURL) else {
throw PresignedUploaderError.presignFailed(status: 0, body: "invalid base url")
}
url.appendPathComponent("uploads/presign/")
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("Token \(authToken)", forHTTPHeaderField: "Authorization")
req.httpBody = try JSONEncoder().encode(PresignBody(
category: category.rawValue,
content_type: contentType,
content_length: contentLength
))
let (body, response): (Data, URLResponse)
do {
(body, response) = try await session.data(for: req)
} catch {
throw PresignedUploaderError.sessionError(error)
}
guard let http = response as? HTTPURLResponse else {
throw PresignedUploaderError.presignFailed(status: 0, body: "no response")
}
guard (200..<300).contains(http.statusCode) else {
throw PresignedUploaderError.presignFailed(
status: http.statusCode,
body: String(data: body, encoding: .utf8) ?? ""
)
}
do {
return try JSONDecoder().decode(PresignResponse.self, from: body)
} catch {
throw PresignedUploaderError.presignFailed(status: http.statusCode, body: "decode failed: \(error)")
}
}
// MARK: - Step 2: POST to B2
private func postToStorage(
uploadURL: String,
fields: [String: String],
data: Data,
contentType: String,
fileName: String
) async throws {
guard let url = URL(string: uploadURL) else {
throw PresignedUploaderError.uploadFailed(status: 0, body: "invalid upload url")
}
// Build a multipart/form-data body with all policy fields followed
// by a single "file" part (S3 POST policy mandates the file part
// come last).
let boundary = "Boundary-\(UUID().uuidString)"
var body = Data()
let crlf = "\r\n"
let appendString: (String) -> Void = { s in
body.append(s.data(using: .utf8) ?? Data())
}
// Stable order: ensure "key" and "Content-Type" appear before the
// file part so the policy signature validates. Unspecified order
// for the rest S3 accepts any.
let orderedKeys = ["key", "Content-Type", "policy", "x-amz-algorithm",
"x-amz-credential", "x-amz-date", "x-amz-signature",
"x-amz-meta-uid"]
var emitted = Set<String>()
for k in orderedKeys {
if let v = fields[k] {
appendString("--\(boundary)\(crlf)")
appendString("Content-Disposition: form-data; name=\"\(k)\"\(crlf)\(crlf)")
appendString(v)
appendString(crlf)
emitted.insert(k)
}
}
for (k, v) in fields where !emitted.contains(k) {
appendString("--\(boundary)\(crlf)")
appendString("Content-Disposition: form-data; name=\"\(k)\"\(crlf)\(crlf)")
appendString(v)
appendString(crlf)
}
// file part must be last
appendString("--\(boundary)\(crlf)")
appendString("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\(crlf)")
appendString("Content-Type: \(contentType)\(crlf)\(crlf)")
body.append(data)
appendString(crlf)
appendString("--\(boundary)--\(crlf)")
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
req.httpBody = body
let (respBody, response): (Data, URLResponse)
do {
(respBody, response) = try await session.data(for: req)
} catch {
throw PresignedUploaderError.sessionError(error)
}
guard let http = response as? HTTPURLResponse else {
throw PresignedUploaderError.uploadFailed(status: 0, body: "no response")
}
guard (200..<300).contains(http.statusCode) else {
throw PresignedUploaderError.uploadFailed(
status: http.statusCode,
body: String(data: respBody, encoding: .utf8) ?? ""
)
}
}
}
+1 -1
View File
@@ -61,7 +61,7 @@ enum ThemeID: String, CaseIterable, Codable {
// MARK: - Shared App Group UserDefaults
private let appGroupID: String = {
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.tt.honeyDue.dev"
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev"
}()
private let sharedDefaults: UserDefaults = {
guard let defaults = UserDefaults(suiteName: appGroupID) else {
+5 -3
View File
@@ -56,11 +56,13 @@ enum UITestRuntime {
DataManager.shared.clear()
OnboardingState.shared.reset()
ThemeManager.shared.currentTheme = .bright
UserDefaults.standard.removeObject(forKey: "ui_test_user_verified")
// Re-apply onboarding completion after reset so tests that need
// both --reset-state and --complete-onboarding work correctly.
// Re-apply onboarding completion after reset. Set the flag directly
// because completeOnboarding() has an auth guard that fails here
// (DataManager was just cleared, so isAuthenticated is false).
if shouldCompleteOnboarding {
OnboardingState.shared.completeOnboarding()
OnboardingState.shared.hasCompletedOnboarding = true
}
}
@@ -64,7 +64,7 @@ final class WidgetActionProcessor {
notes: "Completed from widget",
actualCost: nil,
rating: nil,
imageUrls: nil
uploadIds: nil
)
let result = try await APILayer.shared.createTaskCompletion(request: request)
@@ -21,7 +21,7 @@ final class WidgetDataManager {
static let cancelledColumn = "cancelled_tasks"
private let appGroupIdentifier: String = {
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.tt.honeyDue.dev"
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev"
}()
private let tasksFileName = "widget_tasks.json"
private let actionsFileName = "widget_pending_actions.json"
+7 -13
View File
@@ -6,14 +6,8 @@
<string>$(APP_GROUP_IDENTIFIER)</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.tt.honeyDue.refresh</string>
<string>com.myhoneydue.honeyDue.refresh</string>
</array>
<key>HONEYDUE_IAP_ANNUAL_PRODUCT_ID</key>
<string>com.tt.honeyDue.pro.annual</string>
<key>HONEYDUE_IAP_MONTHLY_PRODUCT_ID</key>
<string>com.tt.honeyDue.pro.monthly</string>
<key>HONEYDUE_GOOGLE_WEB_CLIENT_ID</key>
<string></string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
@@ -40,17 +34,17 @@
</array>
</dict>
</array>
<key>HONEYDUE_GOOGLE_WEB_CLIENT_ID</key>
<string></string>
<key>HONEYDUE_IAP_ANNUAL_PRODUCT_ID</key>
<string>com.myhoneydue.honeyDue.pro.annual</string>
<key>HONEYDUE_IAP_MONTHLY_PRODUCT_ID</key>
<string>com.myhoneydue.honeyDue.pro.monthly</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>honeyDue needs camera access to take photos of tasks, documents, and receipts.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>honeyDue needs photo library access to attach photos to tasks and documents.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>honeyDue needs permission to save photos to your library.</string>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
+424 -6
View File
@@ -3,6 +3,10 @@
"strings" : {
"" : {
},
" %@" : {
"comment" : "A chevron up and down symbol. The argument is the “chevron.up.chevron.down” symbol.",
"isCommentAutoGenerated" : true
},
"*" : {
@@ -46,6 +50,54 @@
"comment" : "A message displayed when a contractor is successfully imported to the user's contacts. The placeholder is replaced with the name of the imported contractor.",
"isCommentAutoGenerated" : true
},
"%@ plan, %@%@%@" : {
"comment" : "A label describing a subscription plan. The first argument is the plan title. The second argument is the price of the plan. The third argument is the billing period. The fourth argument is the savings information, if available.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$@ plan, %2$@%3$@%4$@"
}
}
}
},
"%@, %@" : {
"comment" : "Accessibility label and value that describe the task and its selection state.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$@, %2$@"
}
}
}
},
"%@, %@, %lld%% match" : {
"comment" : "A row that displays a suggestion with a title, frequency, and relevance percentage.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$@, %2$@, %3$lld%% match"
}
}
}
},
"%@, %@%@" : {
"comment" : "A button that displays the name of a product and its price.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$@, %2$@%3$@"
}
}
}
},
"%@: %@" : {
"comment" : "An error message displayed when there was an issue loading tasks for a residence.",
"isCommentAutoGenerated" : true,
@@ -58,6 +110,18 @@
}
}
},
"%@: Free: %@, Pro: %@" : {
"comment" : "A label that describes a comparison between a free and a pro feature.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$@: Free: %2$@, Pro: %3$@"
}
}
}
},
"%d" : {
"comment" : "A badge displaying the number of tasks in a category. The argument is the count of tasks in the category.",
"isCommentAutoGenerated" : true
@@ -92,16 +156,20 @@
"%lld common tasks" : {
},
"%lld/%lld tasks selected" : {
"%lld task%@ selected" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$lld/%2$lld tasks selected"
"value" : "%1$lld task%2$@ selected"
}
}
}
},
"%lld%%" : {
"comment" : "A badge that shows the relevance of a suggestion. The argument is the relevance percentage.",
"isCommentAutoGenerated" : true
},
"•" : {
"comment" : "A separator between different pieces of information in a text.",
"isCommentAutoGenerated" : true
@@ -149,6 +217,10 @@
"comment" : "A label for the actions menu in the task card.",
"isCommentAutoGenerated" : true
},
"Add %@ to favorites" : {
"comment" : "A label for the favorite button. The argument is the name of the contractor.",
"isCommentAutoGenerated" : true
},
"Add %lld Task%@ & Continue" : {
"localizations" : {
"en" : {
@@ -159,8 +231,24 @@
}
}
},
"Add Most Popular" : {
"Add contractor" : {
"comment" : "A label for the button that adds a new contractor.",
"isCommentAutoGenerated" : true
},
"Add document" : {
},
"Add new property" : {
"comment" : "A label displayed as a button in the toolbar.",
"isCommentAutoGenerated" : true
},
"Add new task" : {
"comment" : "A label for a button that adds a new task.",
"isCommentAutoGenerated" : true
},
"Add task" : {
"comment" : "A button that adds a task.",
"isCommentAutoGenerated" : true
},
"Add your first property to get started!" : {
"comment" : "A description below the image in the \"No properties yet\" view, encouraging the user to add their first property.",
@@ -169,6 +257,9 @@
"Adds a subtle hexagonal grid overlay" : {
"comment" : "A description of the Honeycomb Pattern feature.",
"isCommentAutoGenerated" : true
},
"All optional -- helps us personalize your plan" : {
},
"Already have an account?" : {
@@ -315,6 +406,10 @@
"comment" : "The text of a button that archives a task.",
"isCommentAutoGenerated" : true
},
"Archive task" : {
"comment" : "A label displayed as a button that archives a task.",
"isCommentAutoGenerated" : true
},
"Archive Task" : {
"comment" : "A button that archives a task. The text \"Archive Task\" is a placeholder and should be replaced with the actual translation.",
"isCommentAutoGenerated" : true
@@ -333,6 +428,10 @@
},
"Are you sure you want to remove %@ from this residence?" : {
},
"Attached photo" : {
"comment" : "A label for an attached photo.",
"isCommentAutoGenerated" : true
},
"auth_account_info" : {
"extractionState" : "manual",
@@ -4270,6 +4369,10 @@
},
"Back" : {
},
"Back to all photos" : {
"comment" : "A button that dismisses a sheet and returns to the previous screen.",
"isCommentAutoGenerated" : true
},
"Back to Login" : {
"comment" : "A button label that takes the user back to the login screen.",
@@ -4288,6 +4391,10 @@
},
"Cancel anytime in Settings • No commitment" : {
},
"Cancel task" : {
"comment" : "A button that cancels a task.",
"isCommentAutoGenerated" : true
},
"Cancel Task" : {
@@ -5330,6 +5437,10 @@
"comment" : "A button label that indicates a task has been completed.",
"isCommentAutoGenerated" : true
},
"Complete task" : {
"comment" : "A label displayed as a button that completes a task.",
"isCommentAutoGenerated" : true
},
"Complete Task" : {
"comment" : "A button label that says \"Complete Task\".",
"isCommentAutoGenerated" : true
@@ -5338,9 +5449,23 @@
"comment" : "The title of the view.",
"isCommentAutoGenerated" : true
},
"Completion Animation, %@" : {
},
"Completion history for %@" : {
"comment" : "A sheet that shows a user's completions history for a task. The argument is the name of the task.",
"isCommentAutoGenerated" : true
},
"Completion photo%@" : {
"comment" : "A label for the photo view. The argument is the caption of the photo.",
"isCommentAutoGenerated" : true
},
"Completion Photos" : {
"comment" : "The title for the view that shows a user's photo submissions.",
"isCommentAutoGenerated" : true
},
"Completions (%lld)" : {
},
"completions at %@" : {
"comment" : "A subheading describing the content of the honeycomb view.",
@@ -5354,6 +5479,10 @@
},
"Continue with Free" : {
},
"Contractor actions" : {
"comment" : "A label for the menu button that appears in the navigation bar.",
"isCommentAutoGenerated" : true
},
"Contractor Imported" : {
@@ -9002,6 +9131,10 @@
}
}
},
"Copy share code" : {
"comment" : "A button that copies the share code to the user's clipboard.",
"isCommentAutoGenerated" : true
},
"Cost: %@" : {
"comment" : "A label displaying the cost of a task completion. The argument is the cost of the completion.",
"isCommentAutoGenerated" : true
@@ -9017,6 +9150,10 @@
},
"Creating Account..." : {
},
"Delete property" : {
"comment" : "A button that deletes a residence.",
"isCommentAutoGenerated" : true
},
"Didn't receive a code? Check your spam folder or re-register" : {
"comment" : "A hint instructing the user to check their spam folder if they haven't received the verification code.",
@@ -9030,6 +9167,10 @@
"comment" : "A label displayed above the documents tab in the main tab view.",
"isCommentAutoGenerated" : true
},
"Document actions" : {
"comment" : "A label for the menu button that appears in the navigation bar.",
"isCommentAutoGenerated" : true
},
"documents_active" : {
"extractionState" : "manual",
"localizations" : {
@@ -17032,6 +17173,63 @@
"comment" : "A button that dismisses an image viewer sheet.",
"isCommentAutoGenerated" : true
},
"Double tap to %@ completions" : {
},
"Double tap to archive this task" : {
"comment" : "A hint for the user to double tap a task to archive it.",
"isCommentAutoGenerated" : true
},
"Double tap to cancel this task" : {
"comment" : "A hint for the user to double tap a task to cancel it.",
"isCommentAutoGenerated" : true
},
"Double tap to complete this task" : {
"comment" : "A hint for the user to double tap a task to complete it.",
"isCommentAutoGenerated" : true
},
"Double tap to continue to the next step" : {
"comment" : "A hint that describes the action to be taken.",
"isCommentAutoGenerated" : true
},
"Double tap to create account" : {
},
"Double tap to edit this task" : {
"comment" : "A hint for the user to double tap a task to edit it.",
"isCommentAutoGenerated" : true
},
"Double tap to join an existing property with a share code" : {
},
"Double tap to log in to your existing account" : {
},
"Double tap to mark this task as in progress" : {
"comment" : "A hint for the user to double tap a task to mark it as in progress.",
"isCommentAutoGenerated" : true
},
"Double tap to restore this task" : {
"comment" : "A hint for the user to double tap a task to restore it.",
"isCommentAutoGenerated" : true
},
"Double tap to send a verification code to your email" : {
},
"Double tap to sign in" : {
},
"Double tap to start setting up your property" : {
},
"Double tap to unarchive this task" : {
"comment" : "A hint for unarchive task buttons.",
"isCommentAutoGenerated" : true
},
"Double tap to use this template" : {
"comment" : "A hint for using a task template.",
"isCommentAutoGenerated" : true
},
"Downloading..." : {
},
@@ -17043,6 +17241,14 @@
"comment" : "A label for an edit action.",
"isCommentAutoGenerated" : true
},
"Edit property" : {
"comment" : "A label for the edit button in the residences list.",
"isCommentAutoGenerated" : true
},
"Edit task" : {
"comment" : "A label for a button that edits a task.",
"isCommentAutoGenerated" : true
},
"Edit Task" : {
"comment" : "A label for an \"Edit Task\" button.",
"isCommentAutoGenerated" : true
@@ -17053,10 +17259,25 @@
"Email Address" : {
"comment" : "A label for the user to input their email address.",
"isCommentAutoGenerated" : true
},
"Enter 6-character share code" : {
},
"Enter 6-digit code" : {
"comment" : "A placeholder text for a text field where a user can enter a 6-digit code.",
"isCommentAutoGenerated" : true
},
"Enter 6-digit verification code" : {
},
"Enter 6-digit verification code from your email" : {
},
"Enter a password with at least 8 characters" : {
},
"Enter a unique username" : {
},
"Enter new password" : {
@@ -17072,13 +17293,31 @@
"Enter the 6-digit code from your email" : {
"comment" : "A footer label explaining that users should enter the 6-digit code they received in their email.",
"isCommentAutoGenerated" : true
},
"Enter the name of your property" : {
},
"Enter the number of days between each occurrence" : {
},
"Enter your account email address" : {
},
"Enter your email address" : {
},
"Enter your email address and we'll send you a verification code" : {
"comment" : "A description below the email input field, instructing the user to enter their email address to receive a password reset code.",
"isCommentAutoGenerated" : true
},
"Enter your first name" : {
},
"Enter your last name" : {
},
"Enter your ZIP code" : {
},
"Enter your ZIP code so we can suggest\nmaintenance tasks for your climate region." : {
@@ -17423,6 +17662,13 @@
"Feature" : {
"comment" : "The header for a feature in the feature comparison table.",
"isCommentAutoGenerated" : true
},
"Filter by specialty" : {
"comment" : "A button that filters the list of contractors by specialty.",
"isCommentAutoGenerated" : true
},
"Filter documents" : {
},
"Forgot Password?" : {
"comment" : "A title for the \"Forgot Password?\" screen.",
@@ -17439,18 +17685,45 @@
"comment" : "A button label that generates a new invitation code.",
"isCommentAutoGenerated" : true
},
"Generate maintenance report" : {
"comment" : "A button that generates a maintenance report.",
"isCommentAutoGenerated" : true
},
"Generate New Code" : {
"comment" : "A button label that appears when a user wants to generate a new invitation code.",
"isCommentAutoGenerated" : true
},
"Generate new share code" : {
"comment" : "A button that generates a new share code.",
"isCommentAutoGenerated" : true
},
"Get notified when someone joins your property" : {
},
"Get notified when tasks are assigned to you" : {
},
"Get notified when tasks are completed by others" : {
},
"Get notified when tasks are due soon" : {
},
"Get notified when tasks are overdue" : {
},
"Get notified when warranties are about to expire" : {
},
"Go back" : {
"comment" : "A label for the back button.",
"isCommentAutoGenerated" : true
},
"Google Sign-In Error" : {
},
"Help improve honeyDue by sharing anonymous usage data" : {
},
"Here are tasks recommended for your area.\nPick the ones you'd like to track!" : {
},
"Honeycomb Pattern" : {
"comment" : "A feature that adds a subtle hexagonal grid overlay to the app's interface.",
@@ -17475,6 +17748,10 @@
"comment" : "A button label that indicates the user is ready to use honeyDue.",
"isCommentAutoGenerated" : true
},
"Image" : {
"comment" : "A label describing an image.",
"isCommentAutoGenerated" : true
},
"Image %lld of %lld" : {
"comment" : "A navigation title that shows the current image index and the total number of images.",
"isCommentAutoGenerated" : true,
@@ -17503,6 +17780,10 @@
"comment" : "A header that suggests inviting others to join the app.",
"isCommentAutoGenerated" : true
},
"Join a property" : {
"comment" : "A button that opens a sheet for joining a residence.",
"isCommentAutoGenerated" : true
},
"Join a Residence" : {
"comment" : "A button label that instructs the user to join an existing residence.",
"isCommentAutoGenerated" : true
@@ -17534,6 +17815,9 @@
},
"Let's give your place a name!" : {
},
"Loading" : {
},
"Loading..." : {
"comment" : "A placeholder text indicating that content is loading.",
@@ -17547,6 +17831,10 @@
},
"Manage at honeyDue.treytartt.com" : {
},
"Manage users" : {
"comment" : "A button that opens a sheet for managing users in a residence.",
"isCommentAutoGenerated" : true
},
"Manage your subscription at honeyDue.treytartt.com" : {
"comment" : "A description of how to manage a subscription on a third-party platform.",
@@ -17554,6 +17842,10 @@
},
"Manage your subscription on your Android device" : {
},
"Mark as in progress" : {
"comment" : "A hint for the user to mark a task as in progress.",
"isCommentAutoGenerated" : true
},
"Mark Task In Progress" : {
"comment" : "A button label that says \"Mark Task In Progress\".",
@@ -17594,6 +17886,14 @@
"comment" : "A message displayed when no task templates match a search query.",
"isCommentAutoGenerated" : true
},
"None" : {
"comment" : "A button that clears the selection.",
"isCommentAutoGenerated" : true
},
"not selected" : {
"comment" : "A label that describes the selection state of a task.",
"isCommentAutoGenerated" : true
},
"Notification Time" : {
"comment" : "The title of the sheet where a user can select the time for receiving notifications.",
"isCommentAutoGenerated" : true
@@ -17610,9 +17910,16 @@
"comment" : "A button that dismisses the success dialog.",
"isCommentAutoGenerated" : true
},
"Open %@ in Maps" : {
"comment" : "A label for the accessibility element that opens the address in Maps.",
"isCommentAutoGenerated" : true
},
"Open honeyDue.treytartt.com" : {
"comment" : "A button label that opens the user's subscription management page in a web browser.",
"isCommentAutoGenerated" : true
},
"Opens email to contact support" : {
},
"or" : {
@@ -17632,10 +17939,18 @@
"comment" : "A title for a view that displays a single photo.",
"isCommentAutoGenerated" : true
},
"Photo%@" : {
"comment" : "A label for a photo. The argument is the caption of the photo.",
"isCommentAutoGenerated" : true
},
"Primary" : {
"comment" : "A label indicating that a residence is the user's primary residence.",
"isCommentAutoGenerated" : true
},
"Primary property" : {
"comment" : "A label for the star icon.",
"isCommentAutoGenerated" : true
},
"Privacy" : {
},
@@ -21692,12 +22007,43 @@
},
"Quick Start" : {
},
"Rated %@ out of 5" : {
"comment" : "A label that describes the rating of a task completion. The argument is the rating.",
"isCommentAutoGenerated" : true
},
"Rated 4.9 stars by 10K+ homeowners" : {
},
"Rating: %lld out of 5 stars" : {
},
"Re-enter new password" : {
},
"Re-enter your password to confirm" : {
},
"Receive a daily summary of upcoming tasks" : {
},
"Receive email notifications when tasks are completed" : {
},
"Refresh tasks" : {
"comment" : "A button that refreshes the tasks.",
"isCommentAutoGenerated" : true
},
"Remove" : {
},
"Remove %@ from favorites" : {
"comment" : "A label for the favorite button. The argument is the name of the contractor.",
"isCommentAutoGenerated" : true
},
"Remove %@ from property" : {
"comment" : "A button that removes a user from a property. The argument is the username of the user to be removed.",
"isCommentAutoGenerated" : true
},
"Remove User" : {
@@ -24845,6 +25191,10 @@
"comment" : "A button label that allows users to restore previous purchases.",
"isCommentAutoGenerated" : true
},
"Restore task" : {
"comment" : "A button that restores a task.",
"isCommentAutoGenerated" : true
},
"Restore Task" : {
"comment" : "A button that restores a cancelled or archived task.",
"isCommentAutoGenerated" : true
@@ -24866,6 +25216,10 @@
},
"Save your home to your account" : {
},
"Search task templates by name" : {
"comment" : "A hint for the search bar in the task templates browser.",
"isCommentAutoGenerated" : true
},
"Search templates..." : {
"comment" : "A placeholder text for a search bar in the task templates browser.",
@@ -24875,6 +25229,14 @@
"comment" : "A label displayed above the picker for selecting the notification time.",
"isCommentAutoGenerated" : true
},
"selected" : {
"comment" : "A label that describes the selection state of a task.",
"isCommentAutoGenerated" : true
},
"Selected" : {
"comment" : "A label that describes a selected option.",
"isCommentAutoGenerated" : true
},
"Send a .honeydue file via Messages, Email, or AirDrop. They just tap to join." : {
},
@@ -24899,6 +25261,10 @@
},
"Set New Password" : {
},
"Settings" : {
"comment" : "A button that opens a settings screen.",
"isCommentAutoGenerated" : true
},
"settings_language" : {
"extractionState" : "manual",
@@ -25037,12 +25403,33 @@
"comment" : "A label displayed above the share code section of the view.",
"isCommentAutoGenerated" : true
},
"Share code: %@" : {
"comment" : "A label for the share code, with the share code as the value.",
"isCommentAutoGenerated" : true
},
"Share this 6-character code. They can enter it in the app to join." : {
"comment" : "A description of how to share the invitation code with others.",
"isCommentAutoGenerated" : true
},
"Shared Users (%lld)" : {
},
"Show active warranties only" : {
},
"Show all contractors" : {
"comment" : "A label for a button that shows all contractors.",
"isCommentAutoGenerated" : true
},
"Show all warranties" : {
},
"Show favorites only" : {
"comment" : "A label for a button that filters contractors to show only the ones that the user has marked as favorites.",
"isCommentAutoGenerated" : true
},
"Sign in with Apple" : {
},
"Sign in with Google" : {
@@ -25054,6 +25441,10 @@
"comment" : "A button label that allows users to skip the current onboarding step.",
"isCommentAutoGenerated" : true
},
"Skip for now" : {
"comment" : "A button label that skips onboarding.",
"isCommentAutoGenerated" : true
},
"Skip for Now" : {
},
@@ -25072,6 +25463,10 @@
"comment" : "A description below the title of the screen.",
"isCommentAutoGenerated" : true
},
"Step %lld of 5" : {
"comment" : "A label that describes the current step in the onboarding flow. The argument is the step number.",
"isCommentAutoGenerated" : true
},
"Subscription Active" : {
"comment" : "The title of an alert that appears when a user successfully upgrades to a premium subscription.",
"isCommentAutoGenerated" : true
@@ -25087,6 +25482,10 @@
"comment" : "A description of an action a user can take to add a property.",
"isCommentAutoGenerated" : true
},
"Task actions" : {
"comment" : "A label displayed as a button in the task card that opens a menu of task actions.",
"isCommentAutoGenerated" : true
},
"Task Templates" : {
"comment" : "The title of the view that lists all predefined task templates.",
"isCommentAutoGenerated" : true
@@ -30193,6 +30592,9 @@
}
}
}
},
"Tell us about your home" : {
},
"Templates will appear here once loaded" : {
"comment" : "A description text displayed when there are no task templates available.",
@@ -30203,6 +30605,10 @@
},
"The Smith Residence" : {
},
"Toggle password visibility" : {
"comment" : "A button that toggles the visibility of a password.",
"isCommentAutoGenerated" : true
},
"Try a different search term" : {
"comment" : "A description below the \"No Templates Found\" message in the search results section of the task templates browser.",
@@ -30216,6 +30622,10 @@
"comment" : "A button that unarchives a task.",
"isCommentAutoGenerated" : true
},
"Unarchive task" : {
"comment" : "A button that unarchives a task.",
"isCommentAutoGenerated" : true
},
"Unarchive Task" : {
},
@@ -30249,6 +30659,14 @@
"comment" : "A message displayed while waiting for the email verification process to complete.",
"isCommentAutoGenerated" : true
},
"View %d completions" : {
"comment" : "A label that describes the action of viewing the completions of a task. The argument is the number of completions.",
"isCommentAutoGenerated" : true
},
"View %lld photos" : {
"comment" : "A button that opens a new screen showing a list of photos. The number in parentheses is replaced with the actual number of photos.",
"isCommentAutoGenerated" : true
},
"View Photos (%lld)" : {
"comment" : "A button that, when tapped, opens a view displaying photos taken during a task completion. The number in parentheses is replaced with the actual number of photos.",
"isCommentAutoGenerated" : true
+17 -2
View File
@@ -6,6 +6,7 @@ struct LoginView: View {
@StateObject private var appleSignInViewModel = AppleSignInViewModel()
@FocusState private var focusedField: Field?
@State private var showingRegister = false
@State private var registrationVerified = false
@State private var showVerification = false
@State private var showPasswordReset = false
@State private var isPasswordVisible = false
@@ -69,6 +70,7 @@ struct LoginView: View {
Text(L10n.Auth.welcomeBack)
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.accessibilityAddTraits(.isHeader)
Text(L10n.Auth.signInSubtitle)
.font(.system(size: 15, weight: .medium))
@@ -148,6 +150,7 @@ struct LoginView: View {
action: viewModel.login
)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.loginButton)
.accessibilityHint("Double tap to sign in")
// Divider
HStack(spacing: 12) {
@@ -180,6 +183,7 @@ struct LoginView: View {
}
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.appleSignInButton)
.accessibilityLabel("Sign in with Apple")
// Apple Sign In loading indicator
if appleSignInViewModel.isLoading {
@@ -216,6 +220,7 @@ struct LoginView: View {
)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.googleSignInButton)
.accessibilityLabel("Sign in with Google")
// Apple Sign In Error
if let appleError = appleSignInViewModel.errorMessage {
@@ -310,8 +315,18 @@ struct LoginView: View {
}
)
}
.sheet(isPresented: $showingRegister) {
RegisterView()
.sheet(isPresented: $showingRegister, onDismiss: {
// Sheet is fully removed from the UIKit presentation stack.
// Set auth state now that no UIKit presentations block the
// RootView hierarchy swap.
if registrationVerified {
registrationVerified = false
AuthenticationManager.shared.login(verified: true)
}
}) {
RegisterView(isPresented: $showingRegister, onVerified: {
registrationVerified = true
})
}
.sheet(isPresented: $showPasswordReset) {
PasswordResetFlow(resetToken: activeResetToken, onLoginSuccess: { isVerified in
@@ -64,11 +64,20 @@ struct OnboardingCoordinator: View {
return
}
let postalCode = onboardingState.pendingPostalCode.isEmpty ? nil : onboardingState.pendingPostalCode
print("🏠 ONBOARDING: Creating residence with name: \(onboardingState.pendingResidenceName), zip: \(postalCode ?? "none")")
let postalCode: String? = nil
print("🏠 ONBOARDING: Creating residence with name: \(onboardingState.pendingResidenceName)")
isCreatingResidence = true
// Collect home profile booleans only send true values
let hasPool = onboardingState.pendingHasPool ? KotlinBoolean(bool: true) : nil
let hasSprinkler = onboardingState.pendingHasSprinklerSystem ? KotlinBoolean(bool: true) : nil
let hasSeptic = onboardingState.pendingHasSeptic ? KotlinBoolean(bool: true) : nil
let hasFireplace = onboardingState.pendingHasFireplace ? KotlinBoolean(bool: true) : nil
let hasGarage = onboardingState.pendingHasGarage ? KotlinBoolean(bool: true) : nil
let hasBasement = onboardingState.pendingHasBasement ? KotlinBoolean(bool: true) : nil
let hasAttic = onboardingState.pendingHasAttic ? KotlinBoolean(bool: true) : nil
let request = ResidenceCreateRequest(
name: onboardingState.pendingResidenceName,
propertyTypeId: nil,
@@ -86,7 +95,21 @@ struct OnboardingCoordinator: View {
description: nil,
purchaseDate: nil,
purchasePrice: nil,
isPrimary: KotlinBoolean(bool: true)
isPrimary: KotlinBoolean(bool: true),
heatingType: onboardingState.pendingHeatingType,
coolingType: onboardingState.pendingCoolingType,
waterHeaterType: onboardingState.pendingWaterHeaterType,
roofType: onboardingState.pendingRoofType,
hasPool: hasPool,
hasSprinklerSystem: hasSprinkler,
hasSeptic: hasSeptic,
hasFireplace: hasFireplace,
hasGarage: hasGarage,
hasBasement: hasBasement,
hasAttic: hasAttic,
exteriorType: onboardingState.pendingExteriorType,
flooringPrimary: onboardingState.pendingFlooringPrimary,
landscapingType: onboardingState.pendingLandscapingType
)
residenceViewModel.createResidence(request: request) { (residence: ResidenceResponse?) in
@@ -103,7 +126,7 @@ struct OnboardingCoordinator: View {
}
/// Current step index for progress indicator (0-based)
/// Flow: Welcome Features Name Account Verify Location Tasks Upsell
/// Flow: Welcome Features Name Account Verify Home Profile Tasks Upsell
private var currentProgressStep: Int {
switch onboardingState.currentStep {
case .welcome: return 0
@@ -113,6 +136,7 @@ struct OnboardingCoordinator: View {
case .verifyEmail: return 4
case .joinResidence: return 4
case .residenceLocation: return 4
case .homeProfile: return 4
case .firstTask: return 4
case .subscriptionUpsell: return 4
}
@@ -121,7 +145,7 @@ struct OnboardingCoordinator: View {
/// Whether to show the back button
private var showBackButton: Bool {
switch onboardingState.currentStep {
case .welcome, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
case .welcome, .joinResidence, .residenceLocation, .homeProfile, .firstTask, .subscriptionUpsell:
return false
default:
return true
@@ -131,7 +155,7 @@ struct OnboardingCoordinator: View {
/// Whether to show the skip button
private var showSkipButton: Bool {
switch onboardingState.currentStep {
case .valueProps, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
case .valueProps, .joinResidence, .homeProfile, .firstTask, .subscriptionUpsell:
return true
default:
return false
@@ -141,7 +165,7 @@ struct OnboardingCoordinator: View {
/// Whether to show the progress indicator
private var showProgressIndicator: Bool {
switch onboardingState.currentStep {
case .welcome, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
case .welcome, .joinResidence, .residenceLocation, .homeProfile, .firstTask, .subscriptionUpsell:
return false
default:
return true
@@ -173,12 +197,13 @@ struct OnboardingCoordinator: View {
switch onboardingState.currentStep {
case .valueProps:
goForward()
case .residenceLocation:
// Skipping location still need to create residence (without postal code)
case .homeProfile:
// Skipping home profile create residence without profile data, go to tasks
createResidenceIfNeeded(thenNavigateTo: .firstTask)
case .joinResidence, .firstTask:
goForward()
case .subscriptionUpsell:
case .joinResidence:
onboardingState.completeOnboarding()
onComplete()
case .firstTask, .subscriptionUpsell:
onboardingState.completeOnboarding()
onComplete()
default:
@@ -197,6 +222,7 @@ struct OnboardingCoordinator: View {
.foregroundColor(Color.appPrimary)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.backButton)
.accessibilityLabel("Go back")
.frame(width: 44, alignment: .leading)
.opacity(showBackButton ? 1 : 0)
.disabled(!showBackButton)
@@ -207,6 +233,7 @@ struct OnboardingCoordinator: View {
if showProgressIndicator {
OnboardingProgressIndicator(currentStep: currentProgressStep, totalSteps: 5)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.progressIndicator)
.accessibilityLabel("Step \(currentProgressStep + 1) of 5")
}
Spacer()
@@ -219,6 +246,7 @@ struct OnboardingCoordinator: View {
.foregroundColor(Color.appTextSecondary)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.skipButton)
.accessibilityLabel("Skip")
.frame(width: 44, alignment: .trailing)
.opacity(showSkipButton ? 1 : 0)
.disabled(!showSkipButton)
@@ -273,7 +301,7 @@ struct OnboardingCoordinator: View {
if onboardingState.userIntent == .joinExisting {
goForward(to: .joinResidence)
} else {
goForward(to: .residenceLocation)
goForward(to: .homeProfile)
}
} else {
goForward()
@@ -289,7 +317,7 @@ struct OnboardingCoordinator: View {
if onboardingState.userIntent == .joinExisting {
goForward(to: .joinResidence)
} else {
goForward(to: .residenceLocation)
goForward(to: .homeProfile)
}
}
)
@@ -298,17 +326,22 @@ struct OnboardingCoordinator: View {
case .joinResidence:
OnboardingJoinResidenceContent(
onJoined: {
goForward()
onboardingState.completeOnboarding()
onComplete()
}
)
.transition(navigationTransition)
case .residenceLocation:
OnboardingLocationContent(
onLocationDetected: { zip in
// Load regional templates in background while creating residence
onboardingState.loadRegionalTemplates(zip: zip)
// Create residence with postal code, then go to first task
// Location step removed skip to home profile if we land here
EmptyView()
.onAppear { goForward(to: .homeProfile) }
.transition(navigationTransition)
case .homeProfile:
OnboardingHomeProfileContent(
onContinue: {
// Create residence with all collected data, then go to tasks
createResidenceIfNeeded(thenNavigateTo: .firstTask)
},
onSkip: {
@@ -321,19 +354,20 @@ struct OnboardingCoordinator: View {
OnboardingFirstTaskContent(
residenceName: onboardingState.pendingResidenceName,
onTaskAdded: {
goForward()
onboardingState.completeOnboarding()
onComplete()
}
)
.transition(navigationTransition)
case .subscriptionUpsell:
OnboardingSubscriptionContent(
onSubscribe: {
// Handle subscription flow
// Subscription removed from onboarding app is free
// Immediately complete if we somehow land here
EmptyView()
.onAppear {
onboardingState.completeOnboarding()
onComplete()
}
)
.transition(navigationTransition)
}
}
@@ -36,6 +36,7 @@ struct OnboardingCreateAccountContent: View {
var body: some View {
ZStack {
WarmGradientBackground()
.a11yDecorative()
// Decorative blobs
GeometryReader { geo in
@@ -56,6 +57,7 @@ struct OnboardingCreateAccountContent: View {
.offset(x: geo.size.width * 0.55, y: geo.size.height * 0.05)
.blur(radius: 20)
}
.a11yDecorative()
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.comfortable) {
@@ -108,6 +110,7 @@ struct OnboardingCreateAccountContent: View {
.foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.createAccountTitle)
.a11yHeader()
Text("Your data will be synced across devices")
.font(.system(size: 15, weight: .medium))
@@ -226,6 +229,7 @@ struct OnboardingCreateAccountContent: View {
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.textContentType(.username)
.accessibilityHint("Enter a unique username")
OrganicOnboardingTextField(
icon: "envelope.fill",
@@ -239,6 +243,7 @@ struct OnboardingCreateAccountContent: View {
.autocorrectionDisabled()
.keyboardType(.emailAddress)
.textContentType(.emailAddress)
.accessibilityHint("Enter your email address")
OrganicOnboardingSecureField(
icon: "lock.fill",
@@ -248,6 +253,7 @@ struct OnboardingCreateAccountContent: View {
accessibilityIdentifier: AccessibilityIdentifiers.Onboarding.passwordField
)
.focused($focusedField, equals: .password)
.accessibilityHint("Enter a password with at least 8 characters")
OrganicOnboardingSecureField(
icon: "lock.fill",
@@ -257,6 +263,7 @@ struct OnboardingCreateAccountContent: View {
accessibilityIdentifier: AccessibilityIdentifiers.Onboarding.confirmPasswordField
)
.focused($focusedField, equals: .confirmPassword)
.accessibilityHint("Re-enter your password to confirm")
// Password Requirements
if !viewModel.password.isEmpty {
@@ -359,7 +366,12 @@ struct OnboardingCreateAccountContent: View {
}
.onChange(of: viewModel.isRegistered) { _, isRegistered in
if isRegistered {
// Registration successful - user is authenticated but not verified
// Registration successful server gave us a token, so we ARE
// authenticated (just not verified yet). Mark the iOS-side auth
// state to match, otherwise OnboardingState.completeOnboarding's
// auth guard silently no-ops at the end of the flow and the
// user gets stuck on the firstTask screen.
AuthenticationManager.shared.login(verified: false)
onAccountCreated(false)
}
}
@@ -444,7 +456,13 @@ private struct OrganicOnboardingSecureField: View {
@Binding var text: String
var isFocused: Bool = false
var accessibilityIdentifier: String? = nil
@State private var showPassword = false
// iOS 26 has a known bug where tapping a SwiftUI SecureField with
// `.textContentType(.password)` doesn't reliably bring up the keyboard
// the strong-password autofill panel steals focus. Under UI tests
// we force the visibility toggle ON, rendering as a plain TextField,
// which has reliable focus behavior. The plaintext isn't a security
// concern in test mode (test creds are throwaway).
@State private var showPassword = UITestRuntime.isEnabled
var body: some View {
HStack(spacing: 14) {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,515 @@
import SwiftUI
/// Screen: Home profile systems, features, exterior, interior
struct OnboardingHomeProfileContent: View {
var onContinue: () -> Void
var onSkip: () -> Void
@ObservedObject private var onboardingState = OnboardingState.shared
@State private var isAnimating = false
@Environment(\.colorScheme) var colorScheme
var body: some View {
ZStack {
WarmGradientBackground()
.a11yDecorative()
// Decorative blobs
GeometryReader { geo in
OrganicBlobShape(variation: 2)
.fill(
RadialGradient(
colors: [
Color.appAccent.opacity(0.08),
Color.appAccent.opacity(0.02),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: geo.size.width * 0.35
)
)
.frame(width: geo.size.width * 0.6, height: geo.size.height * 0.35)
.offset(x: -geo.size.width * 0.15, y: geo.size.height * 0.05)
.blur(radius: 25)
OrganicBlobShape(variation: 0)
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.06),
Color.appPrimary.opacity(0.01),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: geo.size.width * 0.3
)
)
.frame(width: geo.size.width * 0.5, height: geo.size.height * 0.3)
.offset(x: geo.size.width * 0.55, y: geo.size.height * 0.6)
.blur(radius: 20)
}
.a11yDecorative()
VStack(spacing: 0) {
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.comfortable) {
// Header
VStack(spacing: 16) {
ZStack {
Circle()
.fill(
RadialGradient(
colors: [Color.appPrimary.opacity(0.15), Color.clear],
center: .center,
startRadius: 30,
endRadius: 80
)
)
.frame(width: 160, height: 160)
.offset(x: -20, y: -20)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
isAnimating
? Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true)
: .default,
value: isAnimating
)
Circle()
.fill(
RadialGradient(
colors: [Color.appAccent.opacity(0.15), Color.clear],
center: .center,
startRadius: 30,
endRadius: 80
)
)
.frame(width: 160, height: 160)
.offset(x: 20, y: 20)
.scaleEffect(isAnimating ? 0.95 : 1.05)
.animation(
isAnimating
? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true).delay(0.5)
: .default,
value: isAnimating
)
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.appPrimary, Color.appSecondary],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 90, height: 90)
Image(systemName: "house.lodge.fill")
.font(.system(size: 40))
.foregroundColor(.white)
}
.naturalShadow(.pronounced)
}
Text("Tell us about your home")
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center)
.a11yHeader()
Text("All optional -- helps us personalize your plan")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.lineSpacing(4)
}
.padding(.top, OrganicSpacing.cozy)
// Systems section
ProfileSection(title: "Systems", icon: "gearshape.2.fill", color: .appPrimary) {
VStack(spacing: 12) {
ProfilePicker(
label: "Heating",
icon: "flame.fill",
selection: $onboardingState.pendingHeatingType,
options: HomeProfileOptions.heatingTypes
)
ProfilePicker(
label: "Cooling",
icon: "snowflake",
selection: $onboardingState.pendingCoolingType,
options: HomeProfileOptions.coolingTypes
)
ProfilePicker(
label: "Water Heater",
icon: "drop.fill",
selection: $onboardingState.pendingWaterHeaterType,
options: HomeProfileOptions.waterHeaterTypes
)
}
}
// Features section
ProfileSection(title: "Features", icon: "star.fill", color: .appAccent) {
HomeFeatureChipGrid(
features: [
FeatureToggle(label: "Pool", icon: "figure.pool.swim", isOn: $onboardingState.pendingHasPool),
FeatureToggle(label: "Sprinklers", icon: "sprinkler.and.droplets.fill", isOn: $onboardingState.pendingHasSprinklerSystem),
FeatureToggle(label: "Fireplace", icon: "fireplace.fill", isOn: $onboardingState.pendingHasFireplace),
FeatureToggle(label: "Garage", icon: "car.fill", isOn: $onboardingState.pendingHasGarage),
FeatureToggle(label: "Basement", icon: "arrow.down.square.fill", isOn: $onboardingState.pendingHasBasement),
FeatureToggle(label: "Attic", icon: "arrow.up.square.fill", isOn: $onboardingState.pendingHasAttic),
FeatureToggle(label: "Septic", icon: "drop.triangle.fill", isOn: $onboardingState.pendingHasSeptic),
]
)
}
// Exterior section
ProfileSection(title: "Exterior", icon: "house.fill", color: Color(hex: "#34C759") ?? .green) {
VStack(spacing: 12) {
ProfilePicker(
label: "Roof Type",
icon: "triangle.fill",
selection: $onboardingState.pendingRoofType,
options: HomeProfileOptions.roofTypes
)
ProfilePicker(
label: "Exterior",
icon: "square.stack.3d.up.fill",
selection: $onboardingState.pendingExteriorType,
options: HomeProfileOptions.exteriorTypes
)
ProfilePicker(
label: "Landscaping",
icon: "leaf.fill",
selection: $onboardingState.pendingLandscapingType,
options: HomeProfileOptions.landscapingTypes
)
}
}
// Interior section
ProfileSection(title: "Interior", icon: "sofa.fill", color: Color(hex: "#AF52DE") ?? .purple) {
ProfilePicker(
label: "Primary Flooring",
icon: "square.grid.3x3.fill",
selection: $onboardingState.pendingFlooringPrimary,
options: HomeProfileOptions.flooringTypes
)
}
}
.padding(.bottom, 140) // Space for button
}
// Bottom action area
VStack(spacing: 14) {
Button(action: onContinue) {
HStack(spacing: 10) {
Text("Continue")
.font(.system(size: 17, weight: .bold))
Image(systemName: "arrow.right")
.font(.system(size: 16, weight: .bold))
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(
LinearGradient(
colors: [Color.appPrimary, Color.appSecondary],
startPoint: .leading,
endPoint: .trailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.naturalShadow(.medium)
}
}
.padding(.horizontal, OrganicSpacing.comfortable)
.padding(.bottom, OrganicSpacing.airy)
.background(
LinearGradient(
colors: [Color.appBackgroundPrimary.opacity(0), Color.appBackgroundPrimary],
startPoint: .top,
endPoint: .center
)
.frame(height: 60)
.offset(y: -60)
, alignment: .top
)
}
}
.onAppear { isAnimating = true }
.onDisappear { isAnimating = false }
}
}
// MARK: - Profile Section Card
private struct ProfileSection<Content: View>: View {
let title: String
let icon: String
let color: Color
@ViewBuilder var content: Content
@Environment(\.colorScheme) var colorScheme
var body: some View {
VStack(alignment: .leading, spacing: 14) {
// Section header
HStack(spacing: 10) {
ZStack {
Circle()
.fill(
LinearGradient(
colors: [color, color.opacity(0.7)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 32, height: 32)
Image(systemName: icon)
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.white)
}
Text(title)
.font(.system(size: 16, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
}
content
}
.padding(16)
.background(
ZStack {
Color.appBackgroundSecondary
GrainTexture(opacity: 0.01)
}
)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.naturalShadow(.subtle)
.padding(.horizontal, OrganicSpacing.comfortable)
}
}
// MARK: - Profile Picker (compact dropdown)
private struct ProfilePicker: View {
let label: String
let icon: String
@Binding var selection: String?
let options: [HomeProfileOption]
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 16, weight: .medium))
.foregroundColor(Color.appPrimary)
.frame(width: 24)
Text(label)
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextPrimary)
Spacer()
Menu {
Button("None") {
withAnimation(.spring(response: 0.3)) {
selection = nil
}
}
ForEach(options, id: \.value) { option in
Button(option.display) {
withAnimation(.spring(response: 0.3)) {
selection = option.value
}
}
}
} label: {
Text(displayValue)
.font(.system(size: 14, weight: .semibold))
.foregroundColor(selection != nil ? Color.appPrimary : Color.appTextSecondary)
+
Text(" \(Image(systemName: "chevron.up.chevron.down"))")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(Color.appTextSecondary)
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(
(selection != nil ? Color.appPrimary : Color.appTextSecondary).opacity(0.1)
)
.clipShape(Capsule())
.fixedSize()
}
}
private var displayValue: String {
guard let selection = selection else { return "Select" }
return options.first { $0.value == selection }?.display ?? selection
}
}
// MARK: - Feature Chip Toggle Grid
private struct FeatureToggle: Identifiable {
let id = UUID()
let label: String
let icon: String
@Binding var isOn: Bool
}
private struct HomeFeatureChipGrid: View {
let features: [FeatureToggle]
var body: some View {
FlowLayout(spacing: 10) {
ForEach(features) { feature in
HomeFeatureChip(
label: feature.label,
icon: feature.icon,
isSelected: feature.isOn,
onTap: {
withAnimation(.spring(response: 0.2)) {
feature.isOn.toggle()
}
}
)
}
}
}
}
private struct HomeFeatureChip: View {
let label: String
let icon: String
let isSelected: Bool
var onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: 14, weight: .medium))
Text(label)
.font(.system(size: 14, weight: .semibold))
}
.foregroundColor(isSelected ? .white : Color.appTextPrimary)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(
isSelected
? AnyShapeStyle(LinearGradient(
colors: [Color.appPrimary, Color.appSecondary],
startPoint: .topLeading,
endPoint: .bottomTrailing
))
: AnyShapeStyle(Color.appTextSecondary.opacity(0.1))
)
.clipShape(Capsule())
.overlay(
Capsule()
.stroke(
isSelected ? Color.clear : Color.appTextSecondary.opacity(0.2),
lineWidth: 1
)
)
}
.buttonStyle(.plain)
.accessibilityLabel(label)
.accessibilityValue(isSelected ? "selected" : "not selected")
}
}
// MARK: - Home Profile Options
struct HomeProfileOption {
let value: String
let display: String
}
enum HomeProfileOptions {
static let heatingTypes: [HomeProfileOption] = [
HomeProfileOption(value: "gas_furnace", display: "Gas Furnace"),
HomeProfileOption(value: "electric_furnace", display: "Electric Furnace"),
HomeProfileOption(value: "heat_pump", display: "Heat Pump"),
HomeProfileOption(value: "boiler", display: "Boiler"),
HomeProfileOption(value: "radiant", display: "Radiant"),
HomeProfileOption(value: "other", display: "Other"),
]
static let coolingTypes: [HomeProfileOption] = [
HomeProfileOption(value: "central_ac", display: "Central AC"),
HomeProfileOption(value: "window_ac", display: "Window AC"),
HomeProfileOption(value: "heat_pump", display: "Heat Pump"),
HomeProfileOption(value: "evaporative", display: "Evaporative"),
HomeProfileOption(value: "none", display: "None"),
HomeProfileOption(value: "other", display: "Other"),
]
static let waterHeaterTypes: [HomeProfileOption] = [
HomeProfileOption(value: "tank_gas", display: "Tank (Gas)"),
HomeProfileOption(value: "tank_electric", display: "Tank (Electric)"),
HomeProfileOption(value: "tankless_gas", display: "Tankless (Gas)"),
HomeProfileOption(value: "tankless_electric", display: "Tankless (Electric)"),
HomeProfileOption(value: "heat_pump", display: "Heat Pump"),
HomeProfileOption(value: "solar", display: "Solar"),
HomeProfileOption(value: "other", display: "Other"),
]
static let roofTypes: [HomeProfileOption] = [
HomeProfileOption(value: "asphalt_shingle", display: "Asphalt Shingle"),
HomeProfileOption(value: "metal", display: "Metal"),
HomeProfileOption(value: "tile", display: "Tile"),
HomeProfileOption(value: "slate", display: "Slate"),
HomeProfileOption(value: "wood_shake", display: "Wood Shake"),
HomeProfileOption(value: "flat", display: "Flat"),
HomeProfileOption(value: "other", display: "Other"),
]
static let exteriorTypes: [HomeProfileOption] = [
HomeProfileOption(value: "brick", display: "Brick"),
HomeProfileOption(value: "vinyl_siding", display: "Vinyl Siding"),
HomeProfileOption(value: "wood_siding", display: "Wood Siding"),
HomeProfileOption(value: "stucco", display: "Stucco"),
HomeProfileOption(value: "stone", display: "Stone"),
HomeProfileOption(value: "fiber_cement", display: "Fiber Cement"),
HomeProfileOption(value: "other", display: "Other"),
]
static let flooringTypes: [HomeProfileOption] = [
HomeProfileOption(value: "hardwood", display: "Hardwood"),
HomeProfileOption(value: "laminate", display: "Laminate"),
HomeProfileOption(value: "tile", display: "Tile"),
HomeProfileOption(value: "carpet", display: "Carpet"),
HomeProfileOption(value: "vinyl", display: "Vinyl"),
HomeProfileOption(value: "concrete", display: "Concrete"),
HomeProfileOption(value: "other", display: "Other"),
]
static let landscapingTypes: [HomeProfileOption] = [
HomeProfileOption(value: "lawn", display: "Lawn"),
HomeProfileOption(value: "desert", display: "Desert"),
HomeProfileOption(value: "xeriscape", display: "Xeriscape"),
HomeProfileOption(value: "garden", display: "Garden"),
HomeProfileOption(value: "mixed", display: "Mixed"),
HomeProfileOption(value: "none", display: "None"),
HomeProfileOption(value: "other", display: "Other"),
]
}
// MARK: - Preview
#Preview {
OnboardingHomeProfileContent(
onContinue: {},
onSkip: {}
)
}
@@ -20,6 +20,7 @@ struct OnboardingJoinResidenceContent: View {
var body: some View {
ZStack {
WarmGradientBackground()
.a11yDecorative()
// Decorative blobs
GeometryReader { geo in
@@ -57,6 +58,7 @@ struct OnboardingJoinResidenceContent: View {
.offset(x: geo.size.width * 0.65, y: geo.size.height * 0.6)
.blur(radius: 20)
}
.a11yDecorative()
VStack(spacing: 0) {
Spacer()
@@ -110,6 +112,7 @@ struct OnboardingJoinResidenceContent: View {
Text("Join a Residence")
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.a11yHeader()
Text("Enter the 6-character code shared with you to join an existing home.")
.font(.system(size: 15, weight: .medium))
@@ -137,6 +140,7 @@ struct OnboardingJoinResidenceContent: View {
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
.focused($isCodeFieldFocused)
.accessibilityHint("Enter 6-character share code")
.onChange(of: shareCode) { _, newValue in
// Limit to 6 characters
if newValue.count > 6 {
@@ -17,6 +17,7 @@ struct OnboardingLocationContent: View {
var body: some View {
ZStack {
WarmGradientBackground()
.a11yDecorative()
// Decorative blobs
GeometryReader { geo in
@@ -54,6 +55,7 @@ struct OnboardingLocationContent: View {
.offset(x: geo.size.width * 0.65, y: geo.size.height * 0.75)
.blur(radius: 15)
}
.a11yDecorative()
VStack(spacing: 0) {
Spacer()
@@ -125,6 +127,7 @@ struct OnboardingLocationContent: View {
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center)
.a11yHeader()
Text("Enter your ZIP code so we can suggest\nmaintenance tasks for your climate region.")
.font(.system(size: 15, weight: .medium))
@@ -163,6 +166,7 @@ struct OnboardingLocationContent: View {
.keyboardType(.numberPad)
.focused($isTextFieldFocused)
.multilineTextAlignment(.center)
.accessibilityHint("Enter your ZIP code")
.onChange(of: zipCode) { _, newValue in
// Only allow digits, max 5
let filtered = String(newValue.filter(\.isNumber).prefix(5))
@@ -24,6 +24,7 @@ struct OnboardingNameResidenceContent: View {
var body: some View {
ZStack {
WarmGradientBackground()
.a11yDecorative()
// Decorative blobs
GeometryReader { geo in
@@ -61,6 +62,7 @@ struct OnboardingNameResidenceContent: View {
.offset(x: geo.size.width * 0.55, y: geo.size.height * 0.6)
.blur(radius: 20)
}
.a11yDecorative()
VStack(spacing: 0) {
Spacer()
@@ -124,6 +126,7 @@ struct OnboardingNameResidenceContent: View {
.foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceTitle)
.a11yHeader()
Text("Don't worry, nothing's written in stone here.\nYou can always change it later in the app.")
.font(.system(size: 15, weight: .medium))
@@ -166,6 +169,7 @@ struct OnboardingNameResidenceContent: View {
.focused($isTextFieldFocused)
.submitLabel(.continue)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.residenceNameField)
.accessibilityHint("Enter the name of your property")
.onSubmit {
if isValid {
onContinue()
+52 -31
View File
@@ -36,14 +36,22 @@ class OnboardingState: ObservableObject {
/// The ID of the residence created during onboarding (used for task creation)
@Published var createdResidenceId: Int32? = nil
/// ZIP code entered during the location step (used for residence creation and regional templates)
@Published var pendingPostalCode: String = ""
// MARK: - Home Profile State (collected during onboarding)
/// Regional task templates loaded from API based on ZIP code
@Published var regionalTemplates: [TaskTemplate] = []
/// Whether regional templates are currently loading
@Published var isLoadingTemplates: Bool = false
@Published var pendingHeatingType: String? = nil
@Published var pendingCoolingType: String? = nil
@Published var pendingWaterHeaterType: String? = nil
@Published var pendingRoofType: String? = nil
@Published var pendingHasPool: Bool = false
@Published var pendingHasSprinklerSystem: Bool = false
@Published var pendingHasSeptic: Bool = false
@Published var pendingHasFireplace: Bool = false
@Published var pendingHasGarage: Bool = false
@Published var pendingHasBasement: Bool = false
@Published var pendingHasAttic: Bool = false
@Published var pendingExteriorType: String? = nil
@Published var pendingFlooringPrimary: String? = nil
@Published var pendingLandscapingType: String? = nil
/// The user's selected intent (start fresh or join existing).
/// Reads/writes the persisted @AppStorage value and notifies SwiftUI of the change.
@@ -63,20 +71,6 @@ class OnboardingState: ObservableObject {
private init() {}
/// Load regional task templates from the backend for the given ZIP code
func loadRegionalTemplates(zip: String) {
pendingPostalCode = zip
isLoadingTemplates = true
Task {
defer { self.isLoadingTemplates = false }
let result = try await APILayer.shared.getRegionalTemplates(state: nil, zip: zip)
if let success = result as? ApiResultSuccess<NSArray>,
let templates = success.data as? [TaskTemplate] {
self.regionalTemplates = templates
}
}
}
/// Start the onboarding flow
func startOnboarding() {
isOnboardingActive = true
@@ -85,7 +79,7 @@ class OnboardingState: ObservableObject {
}
/// Move to the next step in the flow
/// Order: Welcome Features Name Account Verify Location Tasks Upsell
/// Order: Welcome Features Name Account Verify Home Profile Tasks Upsell
func nextStep() {
switch currentStep {
case .welcome:
@@ -104,14 +98,17 @@ class OnboardingState: ObservableObject {
if userIntent == .joinExisting {
currentStep = .joinResidence
} else {
currentStep = .residenceLocation
currentStep = .homeProfile
}
case .joinResidence:
currentStep = .subscriptionUpsell
completeOnboarding()
case .residenceLocation:
// Skip past this step if we somehow land here
currentStep = .homeProfile
case .homeProfile:
currentStep = .firstTask
case .firstTask:
currentStep = .subscriptionUpsell
completeOnboarding()
case .subscriptionUpsell:
completeOnboarding()
}
@@ -124,14 +121,18 @@ class OnboardingState: ObservableObject {
/// Complete the onboarding flow.
/// Setting `hasCompletedOnboarding` also syncs with Kotlin DataManager via `didSet`.
/// Guards against completing without authentication to prevent broken state.
func completeOnboarding() {
guard AuthenticationManager.shared.isAuthenticated else {
// Don't complete onboarding without auth
return
}
hasCompletedOnboarding = true
isOnboardingActive = false
pendingResidenceName = ""
pendingPostalCode = ""
regionalTemplates = []
createdResidenceId = nil
userIntent = .unknown
resetHomeProfile()
}
/// Reset onboarding state (useful for testing or re-onboarding).
@@ -140,11 +141,28 @@ class OnboardingState: ObservableObject {
hasCompletedOnboarding = false
isOnboardingActive = false
pendingResidenceName = ""
pendingPostalCode = ""
regionalTemplates = []
createdResidenceId = nil
userIntent = .unknown
currentStep = .welcome
resetHomeProfile()
}
/// Reset all home profile fields
private func resetHomeProfile() {
pendingHeatingType = nil
pendingCoolingType = nil
pendingWaterHeaterType = nil
pendingRoofType = nil
pendingHasPool = false
pendingHasSprinklerSystem = false
pendingHasSeptic = false
pendingHasFireplace = false
pendingHasGarage = false
pendingHasBasement = false
pendingHasAttic = false
pendingExteriorType = nil
pendingFlooringPrimary = nil
pendingLandscapingType = nil
}
}
@@ -157,8 +175,9 @@ enum OnboardingStep: Int, CaseIterable {
case verifyEmail = 4
case joinResidence = 5 // Only for users joining with a code
case residenceLocation = 6 // ZIP code entry for regional templates
case firstTask = 7
case subscriptionUpsell = 8
case homeProfile = 7 // Home systems & features (optional)
case firstTask = 8
case subscriptionUpsell = 9
var title: String {
switch self {
@@ -176,6 +195,8 @@ enum OnboardingStep: Int, CaseIterable {
return "Join Residence"
case .residenceLocation:
return "Your Location"
case .homeProfile:
return "Home Profile"
case .firstTask:
return "First Task"
case .subscriptionUpsell:
@@ -54,6 +54,7 @@ struct OnboardingSubscriptionContent: View {
var body: some View {
ZStack {
WarmGradientBackground()
.a11yDecorative()
// Decorative blobs
GeometryReader { geo in
@@ -91,6 +92,7 @@ struct OnboardingSubscriptionContent: View {
.offset(x: geo.size.width * 0.65, y: geo.size.height * 0.7)
.blur(radius: 20)
}
.a11yDecorative()
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.comfortable) {
@@ -176,6 +178,7 @@ struct OnboardingSubscriptionContent: View {
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
.accessibilityLabel("Rated 4.9 stars by 10K+ homeowners")
}
.padding(.top, OrganicSpacing.comfortable)
@@ -272,6 +275,7 @@ struct OnboardingSubscriptionContent: View {
.naturalShadow(.medium)
}
.disabled(isLoading)
.a11yButton("Start free trial")
// Continue without
Button(action: {
@@ -281,6 +285,7 @@ struct OnboardingSubscriptionContent: View {
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Color.appTextSecondary)
}
.a11yButton("Continue with free plan")
// Legal text
VStack(spacing: 4) {
@@ -509,6 +514,8 @@ private struct OrganicPricingPlanCard: View {
}
.buttonStyle(.plain)
.animation(.easeInOut(duration: 0.2), value: isSelected)
.accessibilityLabel("\(plan.title) plan, \(displayPrice ?? plan.price)\(plan.period)\(plan.savings.map { ", \($0)" } ?? "")")
.accessibilityValue(isSelected ? "Selected" : "")
}
}
@@ -557,6 +564,7 @@ private struct OrganicSubscriptionBenefitRow: View {
.foregroundColor(.white)
}
.naturalShadow(.subtle)
.a11yDecorative()
VStack(alignment: .leading, spacing: 2) {
Text(benefit.title)
@@ -574,6 +582,7 @@ private struct OrganicSubscriptionBenefitRow: View {
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundColor(benefit.gradient[0])
.a11yDecorative()
}
.padding(.horizontal, 4)
.padding(.vertical, 6)
@@ -0,0 +1,123 @@
import Foundation
import ComposeApp
/// Backs the First-Task onboarding screen. Owns the network calls for
/// personalised suggestions and the full template catalog, plus the bulk
/// task-create submission. No hardcoded suggestion rules or fallback
/// catalog when the API fails the screen shows error+retry+skip.
///
/// Mirrors the Android `OnboardingViewModel.suggestionsState` /
/// `templatesGroupedState` / `createTasksState` flows in a purely Swift
/// shape: calling `APILayer.shared.*` directly is more idiomatic here than
/// observing Kotlin StateFlows, and matches the pattern used by
/// `TaskViewModel.swift`.
@MainActor
final class OnboardingTasksViewModel: ObservableObject {
// MARK: - Suggestions (For You tab)
@Published private(set) var suggestions: [TaskSuggestionResponse] = []
@Published private(set) var profileCompleteness: Double = 0
@Published private(set) var isLoadingSuggestions = false
@Published private(set) var suggestionsError: String?
/// True once `loadSuggestions` has returned any terminal state.
/// Used by the view to distinguish "haven't tried yet" from "tried and
/// returned empty".
@Published private(set) var suggestionsAttempted = false
// MARK: - Grouped catalog (Browse All tab)
@Published private(set) var grouped: TaskTemplatesGroupedResponse?
@Published private(set) var isLoadingGrouped = false
@Published private(set) var groupedError: String?
// MARK: - Submission
@Published private(set) var isSubmitting = false
@Published private(set) var submitError: String?
// MARK: - Loads
func loadSuggestions(residenceId: Int32) async {
if isLoadingSuggestions { return }
isLoadingSuggestions = true
suggestionsError = nil
do {
let result = try await APILayer.shared.getTaskSuggestions(residenceId: residenceId)
if let success = result as? ApiResultSuccess<TaskSuggestionsResponse>,
let data = success.data {
suggestions = data.suggestions
profileCompleteness = data.profileCompleteness
AnalyticsManager.shared.track(.onboardingSuggestionsLoaded(
count: data.suggestions.count,
profileCompleteness: data.profileCompleteness
))
} else if let error = ApiResultBridge.error(from: result) {
suggestionsError = ErrorMessageParser.parse(error.message)
} else {
suggestionsError = "Could not load suggestions."
}
} catch {
suggestionsError = ErrorMessageParser.parse(error.localizedDescription)
}
isLoadingSuggestions = false
suggestionsAttempted = true
}
func loadGrouped(forceRefresh: Bool = false) async {
if isLoadingGrouped { return }
isLoadingGrouped = true
groupedError = nil
do {
let result = try await APILayer.shared.getTaskTemplatesGrouped(forceRefresh: forceRefresh)
if let success = result as? ApiResultSuccess<TaskTemplatesGroupedResponse>,
let data = success.data {
grouped = data
} else if let error = ApiResultBridge.error(from: result) {
groupedError = ErrorMessageParser.parse(error.message)
} else {
groupedError = "Could not load templates."
}
} catch {
groupedError = ErrorMessageParser.parse(error.localizedDescription)
}
isLoadingGrouped = false
}
// MARK: - Submit
/// Posts the picked tasks in a single transaction via the bulk endpoint.
/// Returns true on any successful server response (including empty
/// selections, which short-circuit without a network call). False is
/// terminal the caller should show the stored `submitError`.
func submit(residenceId: Int32, requests: [TaskCreateRequest]) async -> Bool {
if requests.isEmpty {
AnalyticsManager.shared.track(.onboardingTaskStepSkipped(reason: "user_skip"))
return true
}
isSubmitting = true
submitError = nil
let request = BulkCreateTasksRequest(residenceId: residenceId, tasks: requests)
defer { isSubmitting = false }
do {
let result = try await APILayer.shared.bulkCreateTasks(request: request)
if result is ApiResultSuccess<BulkCreateTasksResponse> {
AnalyticsManager.shared.track(.onboardingTasksCreated(count: requests.count))
return true
} else if let error = ApiResultBridge.error(from: result) {
submitError = ErrorMessageParser.parse(error.message)
return false
} else {
submitError = "Could not create tasks."
return false
}
} catch {
submitError = ErrorMessageParser.parse(error.localizedDescription)
return false
}
}
}
@@ -57,6 +57,7 @@ struct OnboardingValuePropsContent: View {
var body: some View {
ZStack {
WarmGradientBackground()
.a11yDecorative()
VStack(spacing: 0) {
// Feature cards in a tab view
@@ -105,6 +106,7 @@ struct OnboardingValuePropsContent: View {
.naturalShadow(.medium)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.valuePropsNextButton)
.accessibilityHint("Double tap to continue to the next step")
.padding(.horizontal, OrganicSpacing.comfortable)
.padding(.bottom, OrganicSpacing.airy)
}
@@ -153,6 +155,7 @@ struct OrganicFeatureCard: View {
.frame(width: 180, height: 180)
.scaleEffect(appeared ? 1 : 0.8)
.opacity(appeared ? 1 : 0)
.a11yDecorative()
// Icon circle
ZStack {
@@ -181,6 +184,7 @@ struct OrganicFeatureCard: View {
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center)
.a11yHeader()
Text(feature.subtitle)
.font(.system(size: 15, weight: .semibold))
@@ -14,6 +14,7 @@ struct OnboardingVerifyEmailContent: View {
var body: some View {
ZStack {
WarmGradientBackground()
.a11yDecorative()
// Decorative blobs
GeometryReader { geo in
@@ -51,6 +52,7 @@ struct OnboardingVerifyEmailContent: View {
.offset(x: geo.size.width * 0.6, y: geo.size.height * 0.65)
.blur(radius: 15)
}
.a11yDecorative()
VStack(spacing: 0) {
Spacer()
@@ -105,6 +107,7 @@ struct OnboardingVerifyEmailContent: View {
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyEmailTitle)
.a11yHeader()
Text("We sent a 6-digit code to your email address. Enter it below to verify your account.")
.font(.system(size: 15, weight: .medium))
@@ -133,6 +136,7 @@ struct OnboardingVerifyEmailContent: View {
.textContentType(.oneTimeCode)
.focused($isCodeFieldFocused)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verificationCodeField)
.accessibilityHint("Enter 6-digit verification code")
.keyboardDismissToolbar()
.onChange(of: viewModel.code) { _, newValue in
// Filter to digits only and truncate to 6 in one pass to prevent re-triggering
@@ -14,6 +14,7 @@ struct OnboardingWelcomeView: View {
var body: some View {
ZStack {
WarmGradientBackground()
.a11yDecorative()
// Decorative blobs
GeometryReader { geo in
@@ -51,6 +52,7 @@ struct OnboardingWelcomeView: View {
.offset(x: geo.size.width * 0.6, y: geo.size.height * 0.65)
.blur(radius: 25)
}
.a11yDecorative()
VStack(spacing: 0) {
Spacer()
@@ -99,6 +101,7 @@ struct OnboardingWelcomeView: View {
.font(.system(size: 32, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.welcomeTitle)
.a11yHeader()
Text("Your home maintenance companion")
.font(.system(size: 17, weight: .medium))
@@ -136,6 +139,7 @@ struct OnboardingWelcomeView: View {
.naturalShadow(.medium)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.startFreshButton)
.accessibilityHint("Double tap to start setting up your property")
// Secondary CTA - Join Existing
Button(action: onJoinExisting) {
@@ -156,6 +160,7 @@ struct OnboardingWelcomeView: View {
)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.joinExistingButton)
.accessibilityHint("Double tap to join an existing property with a share code")
// Returning user login
Button(action: {
@@ -166,6 +171,7 @@ struct OnboardingWelcomeView: View {
.foregroundColor(Color.appTextSecondary)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.loginButton)
.accessibilityHint("Double tap to log in to your existing account")
.padding(.top, 8)
}
.padding(.horizontal, OrganicSpacing.comfortable)
@@ -179,6 +185,7 @@ struct OnboardingWelcomeView: View {
}
.opacity(0.5)
.padding(.bottom, 20)
.a11yDecorative()
}
}
@@ -41,6 +41,7 @@ struct ForgotPasswordView: View {
Text("Forgot Password?")
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.a11yHeader()
Text("Enter your email address and we'll send you a verification code")
.font(.system(size: 15, weight: .medium))
@@ -83,6 +84,7 @@ struct ForgotPasswordView: View {
viewModel.clearError()
}
.accessibilityIdentifier(AccessibilityIdentifiers.PasswordReset.emailField)
.accessibilityHint("Enter your account email address")
}
.padding(16)
.background(Color.appBackgroundPrimary.opacity(0.5))
@@ -160,6 +162,7 @@ struct ForgotPasswordView: View {
}
.disabled(viewModel.email.isEmpty || viewModel.isLoading)
.accessibilityIdentifier(AccessibilityIdentifiers.PasswordReset.sendCodeButton)
.accessibilityHint("Double tap to send a verification code to your email")
// Back to Login
Button(action: { dismiss() }) {
@@ -68,6 +68,7 @@ struct ResetPasswordView: View {
Text("Set New Password")
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.a11yHeader()
Text("Create a strong password to secure your account")
.font(.system(size: 15, weight: .medium))
@@ -41,6 +41,7 @@ struct VerifyResetCodeView: View {
Text("Check Your Email")
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.a11yHeader()
Text("We sent a 6-digit code to")
.font(.system(size: 15, weight: .medium))
@@ -89,6 +90,7 @@ struct VerifyResetCodeView: View {
.focused($isCodeFocused)
.keyboardDismissToolbar()
.accessibilityIdentifier(AccessibilityIdentifiers.PasswordReset.codeField)
.accessibilityHint("Enter 6-digit verification code from your email")
.padding(20)
.background(Color.appBackgroundPrimary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))

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