Compare commits

...

10 Commits

Author SHA1 Message Date
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
119 changed files with 4493 additions and 648 deletions

View File

@@ -0,0 +1,24 @@
{
"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:*)"
]
}
}

417
03_14_26_uiresults.md Normal file
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.

View File

@@ -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)

View File

@@ -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")
}
}

View File

@@ -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>

View File

@@ -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()
}
/**

View File

@@ -55,6 +55,9 @@ data class TaskResponse(
@SerialName("parent_task_id") val parentTaskId: 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

View File

@@ -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

View File

@@ -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"
)
}

View File

@@ -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
)
/**

View File

@@ -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
)

View File

@@ -20,7 +20,9 @@ data class TaskTemplate(
@SerialName("icon_android") val iconAndroid: String = "",
val tags: List<String> = emptyList(),
@SerialName("display_order") val displayOrder: Int = 0,
@SerialName("is_active") val isActive: Boolean = true
@SerialName("is_active") val isActive: Boolean = true,
@SerialName("region_id") val regionId: Int? = null,
@SerialName("region_name") val regionName: String? = null
) {
/**
* Human-readable frequency display

View File

@@ -1209,6 +1209,14 @@ object APILayer {
return taskTemplateApi.getTemplatesByRegion(state = state, zip = zip)
}
/**
* Get personalized task suggestions for a residence based on its home profile.
*/
suspend fun getTaskSuggestions(residenceId: Int): ApiResult<TaskSuggestionsResponse> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
return taskTemplateApi.getTaskSuggestions(token, residenceId)
}
// ==================== Auth Operations ====================
suspend fun login(request: LoginRequest): ApiResult<AuthResponse> {

View File

@@ -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.*
@@ -105,6 +106,27 @@ class TaskTemplateApi(private val client: HttpClient = ApiClient.httpClient) {
}
}
/**
* Get personalized task suggestions for a residence based on its home profile.
* Requires authentication.
*/
suspend fun getTaskSuggestions(token: String, residenceId: Int): ApiResult<TaskSuggestionsResponse> {
return try {
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 task suggestions", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
/**
* Get a single template by ID
*/

View File

@@ -23,6 +23,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.models.TaskCreateRequest
import com.tt.honeyDue.models.TaskSuggestionResponse
import com.tt.honeyDue.models.TaskSuggestionsResponse
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.ui.theme.*
import com.tt.honeyDue.viewmodel.OnboardingViewModel
@@ -54,12 +56,22 @@ fun OnboardingFirstTaskContent(
viewModel: OnboardingViewModel,
onTasksAdded: () -> Unit
) {
val maxTasksAllowed = 5
var selectedTaskIds by remember { mutableStateOf(setOf<String>()) }
var selectedBrowseIds by remember { mutableStateOf(setOf<String>()) }
var selectedSuggestionIds by remember { mutableStateOf(setOf<Int>()) }
var expandedCategoryId by remember { mutableStateOf<String?>(null) }
var isCreatingTasks by remember { mutableStateOf(false) }
var selectedTabIndex by remember { mutableStateOf(0) }
val createTasksState by viewModel.createTasksState.collectAsState()
val suggestionsState by viewModel.suggestionsState.collectAsState()
// Load suggestions on mount if a residence exists
LaunchedEffect(Unit) {
val residence = DataManager.residences.value.firstOrNull()
if (residence != null) {
viewModel.loadSuggestions(residence.id)
}
}
LaunchedEffect(createTasksState) {
when (createTasksState) {
@@ -69,7 +81,6 @@ fun OnboardingFirstTaskContent(
}
is ApiResult.Error -> {
isCreatingTasks = false
// Still proceed even if task creation fails
onTasksAdded()
}
is ApiResult.Loading -> {
@@ -148,159 +159,178 @@ fun OnboardingFirstTaskContent(
)
)
val allTasks = taskCategories.flatMap { it.tasks }
val selectedCount = selectedTaskIds.size
val isAtMaxSelection = selectedCount >= maxTasksAllowed
val allBrowseTasks = taskCategories.flatMap { it.tasks }
val totalSelectedCount = selectedBrowseIds.size + selectedSuggestionIds.size
val isAtMaxSelection = false // No task selection limit
// Set first category expanded by default
LaunchedEffect(Unit) {
expandedCategoryId = taskCategories.firstOrNull()?.id
}
// Determine if suggestions are available
val hasSuggestions = suggestionsState is ApiResult.Success &&
(suggestionsState as? ApiResult.Success<TaskSuggestionsResponse>)?.data?.suggestions?.isNotEmpty() == true
Column(modifier = Modifier.fillMaxSize()) {
LazyColumn(
// Header (shared across tabs)
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentPadding = PaddingValues(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md)
.padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Header
item {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
OrganicIconContainer(
icon = Icons.Default.Celebration,
size = 80.dp,
iconSize = 40.dp,
gradientColors = listOf(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.secondary
),
contentDescription = null
)
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
Text(
text = stringResource(Res.string.onboarding_tasks_title),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
Text(
text = stringResource(Res.string.onboarding_tasks_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
// Selection counter
Surface(
shape = RoundedCornerShape(OrganicRadius.xl),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
) {
Row(
modifier = Modifier.padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.sm),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
// Celebration icon using OrganicIconContainer
OrganicIconContainer(
icon = Icons.Default.Celebration,
size = 80.dp,
iconSize = 40.dp,
gradientColors = listOf(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.secondary
),
contentDescription = null
Icon(
imageVector = Icons.Default.CheckCircleOutline,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
Text(
text = stringResource(Res.string.onboarding_tasks_title),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
Text(
text = stringResource(Res.string.onboarding_tasks_subtitle),
text = "$totalSelectedCount task${if (totalSelectedCount == 1) "" else "s"} selected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
// Selection counter
Surface(
shape = RoundedCornerShape(OrganicRadius.xl),
color = if (isAtMaxSelection) {
MaterialTheme.colorScheme.tertiary.copy(alpha = 0.1f)
} else {
MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
}
) {
Row(
modifier = Modifier.padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.sm),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (isAtMaxSelection) Icons.Default.CheckCircle else Icons.Default.CheckCircleOutline,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (isAtMaxSelection) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.primary
)
Text(
text = "$selectedCount/$maxTasksAllowed tasks selected",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = if (isAtMaxSelection) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.primary
)
}
}
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
}
}
// Task categories
items(taskCategories) { category ->
TaskCategorySection(
category = category,
selectedTaskIds = selectedTaskIds,
isExpanded = expandedCategoryId == category.id,
isAtMaxSelection = isAtMaxSelection,
onToggleExpand = {
expandedCategoryId = if (expandedCategoryId == category.id) null else category.id
},
onToggleTask = { taskId ->
selectedTaskIds = if (taskId in selectedTaskIds) {
selectedTaskIds - taskId
} else if (!isAtMaxSelection) {
selectedTaskIds + taskId
} else {
selectedTaskIds
}
}
)
Spacer(modifier = Modifier.height(OrganicSpacing.md))
}
// Add popular tasks button
item {
OutlinedButton(
onClick = {
val popularTitles = listOf(
"Change HVAC Filter",
"Test Smoke Detectors",
"Check for Leaks",
"Clean Gutters",
"Clean Refrigerator Coils"
)
val popularIds = allTasks
.filter { it.title in popularTitles }
.take(maxTasksAllowed)
.map { it.id }
.toSet()
selectedTaskIds = popularIds
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(OrganicRadius.lg),
border = ButtonDefaults.outlinedButtonBorder.copy(
brush = Brush.linearGradient(
colors = listOf(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.tertiary
)
)
)
) {
Icon(Icons.Default.AutoAwesome, contentDescription = null)
Spacer(modifier = Modifier.width(OrganicSpacing.sm))
Text(
text = stringResource(Res.string.onboarding_tasks_add_popular),
fontWeight = FontWeight.Medium
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.height(100.dp)) // Space for bottom button
}
}
// Bottom action area
// Tab row (only show if we have suggestions)
if (hasSuggestions || suggestionsState is ApiResult.Loading) {
TabRow(
selectedTabIndex = selectedTabIndex,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.primary,
modifier = Modifier.fillMaxWidth()
) {
Tab(
selected = selectedTabIndex == 0,
onClick = { selectedTabIndex = 0 },
text = {
Text(
text = stringResource(Res.string.for_you_tab),
fontWeight = if (selectedTabIndex == 0) FontWeight.SemiBold else FontWeight.Normal
)
},
icon = {
Icon(
Icons.Default.AutoAwesome,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
)
Tab(
selected = selectedTabIndex == 1,
onClick = { selectedTabIndex = 1 },
text = {
Text(
text = stringResource(Res.string.browse_tab),
fontWeight = if (selectedTabIndex == 1) FontWeight.SemiBold else FontWeight.Normal
)
},
icon = {
Icon(
Icons.Default.ViewList,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
)
}
}
// Tab content
when {
(hasSuggestions || suggestionsState is ApiResult.Loading) && selectedTabIndex == 0 -> {
// For You tab
ForYouTabContent(
suggestionsState = suggestionsState,
selectedSuggestionIds = selectedSuggestionIds,
isAtMaxSelection = isAtMaxSelection,
onToggleSuggestion = { templateId ->
selectedSuggestionIds = if (templateId in selectedSuggestionIds) {
selectedSuggestionIds - templateId
} else if (!isAtMaxSelection) {
selectedSuggestionIds + templateId
} else {
selectedSuggestionIds
}
},
modifier = Modifier.weight(1f)
)
}
else -> {
// Browse tab (or default when no suggestions)
BrowseTabContent(
taskCategories = taskCategories,
allTasks = allBrowseTasks,
selectedTaskIds = selectedBrowseIds,
expandedCategoryId = expandedCategoryId,
isAtMaxSelection = isAtMaxSelection,
onToggleExpand = { catId ->
expandedCategoryId = if (expandedCategoryId == catId) null else catId
},
onToggleTask = { taskId ->
selectedBrowseIds = if (taskId in selectedBrowseIds) {
selectedBrowseIds - taskId
} else if (!isAtMaxSelection) {
selectedBrowseIds + taskId
} else {
selectedBrowseIds
}
},
onAddPopular = { popularIds ->
selectedBrowseIds = popularIds
},
modifier = Modifier.weight(1f)
)
}
}
// Bottom action area (shared)
Surface(
modifier = Modifier.fillMaxWidth(),
shadowElevation = 8.dp
@@ -309,30 +339,30 @@ fun OnboardingFirstTaskContent(
modifier = Modifier.padding(OrganicSpacing.lg)
) {
OrganicPrimaryButton(
text = if (selectedCount > 0) {
"Add $selectedCount Task${if (selectedCount == 1) "" else "s"} & Continue"
text = if (totalSelectedCount > 0) {
"Add $totalSelectedCount Task${if (totalSelectedCount == 1) "" else "s"} & Continue"
} else {
stringResource(Res.string.onboarding_tasks_skip)
},
onClick = {
if (selectedTaskIds.isEmpty()) {
if (selectedBrowseIds.isEmpty() && selectedSuggestionIds.isEmpty()) {
onTasksAdded()
} else {
val residences = DataManager.residences.value
val residence = residences.firstOrNull()
if (residence != null) {
val today = DateUtils.getTodayString()
val taskRequests = mutableListOf<TaskCreateRequest>()
val selectedTemplates = allTasks.filter { it.id in selectedTaskIds }
val taskRequests = selectedTemplates.map { template ->
// Browse tab selections
val selectedBrowseTemplates = allBrowseTasks.filter { it.id in selectedBrowseIds }
taskRequests.addAll(selectedBrowseTemplates.map { template ->
val categoryId = DataManager.taskCategories.value
.find { cat -> cat.name.lowercase() == template.category.lowercase() }
?.id
val frequencyId = DataManager.taskFrequencies.value
.find { freq -> freq.name.lowercase() == template.frequency.lowercase() }
?.id
TaskCreateRequest(
residenceId = residence.id,
title = template.title,
@@ -346,7 +376,29 @@ fun OnboardingFirstTaskContent(
estimatedCost = null,
contractorId = null
)
})
// For You tab selections
val suggestions = (suggestionsState as? ApiResult.Success<TaskSuggestionsResponse>)?.data?.suggestions
suggestions?.filter { it.template.id in selectedSuggestionIds }?.forEach { suggestion ->
val tmpl = suggestion.template
taskRequests.add(
TaskCreateRequest(
residenceId = residence.id,
title = tmpl.title,
description = tmpl.description.takeIf { it.isNotBlank() },
categoryId = tmpl.categoryId,
priorityId = null,
inProgress = false,
frequencyId = tmpl.frequencyId,
assignedToId = null,
dueDate = today,
estimatedCost = null,
contractorId = null
)
)
}
viewModel.createTasks(taskRequests)
} else {
onTasksAdded()
@@ -363,6 +415,237 @@ fun OnboardingFirstTaskContent(
}
}
// ==================== For You Tab ====================
@Composable
private fun ForYouTabContent(
suggestionsState: ApiResult<TaskSuggestionsResponse>,
selectedSuggestionIds: Set<Int>,
isAtMaxSelection: Boolean,
onToggleSuggestion: (Int) -> Unit,
modifier: Modifier = Modifier
) {
when (suggestionsState) {
is ApiResult.Loading -> {
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.height(OrganicSpacing.md))
Text(
text = "Finding tasks for your home...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
is ApiResult.Success -> {
val suggestions = suggestionsState.data.suggestions
LazyColumn(
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md)
) {
items(suggestions) { suggestion ->
SuggestionRow(
suggestion = suggestion,
isSelected = suggestion.template.id in selectedSuggestionIds,
isDisabled = isAtMaxSelection && suggestion.template.id !in selectedSuggestionIds,
onToggle = { onToggleSuggestion(suggestion.template.id) }
)
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
}
item {
Spacer(modifier = Modifier.height(24.dp))
}
}
}
is ApiResult.Error -> {
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(
text = "Could not load suggestions. Try the Browse tab.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
else -> {}
}
}
@Composable
private fun SuggestionRow(
suggestion: TaskSuggestionResponse,
isSelected: Boolean,
isDisabled: Boolean,
onToggle: () -> Unit
) {
val template = suggestion.template
val relevancePercent = (suggestion.relevanceScore * 100).toInt()
OrganicCard(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = !isDisabled) { onToggle() },
accentColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
showBlob = false
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(OrganicSpacing.md),
verticalAlignment = Alignment.CenterVertically
) {
// Checkbox
Box(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.background(
if (isSelected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.outline.copy(alpha = if (isDisabled) 0.15f else 0.3f)
),
contentAlignment = Alignment.Center
) {
if (isSelected) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
)
}
}
Spacer(modifier = Modifier.width(OrganicSpacing.md))
Column(modifier = Modifier.weight(1f)) {
Text(
text = template.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = if (isDisabled) {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
} else {
MaterialTheme.colorScheme.onSurface
}
)
Text(
text = template.frequencyDisplay,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = if (isDisabled) 0.5f else 1f
)
)
if (suggestion.matchReasons.isNotEmpty()) {
Text(
text = suggestion.matchReasons.first(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
)
}
}
// Relevance indicator
Surface(
shape = RoundedCornerShape(OrganicRadius.lg),
color = MaterialTheme.colorScheme.primary.copy(
alpha = (suggestion.relevanceScore * 0.2f).toFloat().coerceIn(0.05f, 0.2f)
)
) {
Text(
text = "$relevancePercent%",
modifier = Modifier.padding(horizontal = OrganicSpacing.sm, vertical = OrganicSpacing.xs),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
// ==================== Browse Tab ====================
@Composable
private fun BrowseTabContent(
taskCategories: List<OnboardingTaskCategory>,
allTasks: List<OnboardingTaskTemplate>,
selectedTaskIds: Set<String>,
expandedCategoryId: String?,
isAtMaxSelection: Boolean,
onToggleExpand: (String) -> Unit,
onToggleTask: (String) -> Unit,
onAddPopular: (Set<String>) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md)
) {
// Task categories
items(taskCategories) { category ->
TaskCategorySection(
category = category,
selectedTaskIds = selectedTaskIds,
isExpanded = expandedCategoryId == category.id,
isAtMaxSelection = isAtMaxSelection,
onToggleExpand = { onToggleExpand(category.id) },
onToggleTask = onToggleTask
)
Spacer(modifier = Modifier.height(OrganicSpacing.md))
}
// Add popular tasks button
item {
OutlinedButton(
onClick = {
val popularTitles = listOf(
"Change HVAC Filter",
"Test Smoke Detectors",
"Check for Leaks",
"Clean Gutters",
"Clean Refrigerator Coils"
)
val popularIds = allTasks
.filter { it.title in popularTitles }
.map { it.id }
.toSet()
onAddPopular(popularIds)
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(OrganicRadius.lg),
border = ButtonDefaults.outlinedButtonBorder.copy(
brush = Brush.linearGradient(
colors = listOf(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.tertiary
)
)
)
) {
Icon(Icons.Default.AutoAwesome, contentDescription = null)
Spacer(modifier = Modifier.width(OrganicSpacing.sm))
Text(
text = stringResource(Res.string.onboarding_tasks_add_popular),
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
// ==================== Category / Row Components ====================
@Composable
private fun TaskCategorySection(
category: OnboardingTaskCategory,

View File

@@ -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
)
)
}

View File

@@ -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
}

View File

@@ -8,6 +8,7 @@ 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.TaskSuggestionsResponse
import com.tt.honeyDue.models.TaskTemplate
import com.tt.honeyDue.models.VerifyEmailRequest
import com.tt.honeyDue.network.ApiResult
@@ -37,6 +38,7 @@ enum class OnboardingStep {
VERIFY_EMAIL,
JOIN_RESIDENCE,
RESIDENCE_LOCATION,
HOME_PROFILE,
FIRST_TASK,
SUBSCRIPTION_UPSELL
}
@@ -90,6 +92,53 @@ class OnboardingViewModel : ViewModel() {
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 +155,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 +201,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 +253,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 +354,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
)
)
@@ -362,6 +458,21 @@ class OnboardingViewModel : ViewModel() {
_createTasksState.value = ApiResult.Idle
_regionalTemplates.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
}

View File

@@ -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()
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

32
iosApp/CITests.xctestplan Normal file
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
}

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
}

View File

@@ -42,7 +42,9 @@ extension DataLayerTests {
iconAndroid: "",
tags: tags,
displayOrder: 0,
isActive: true
isActive: true,
regionId: nil,
regionName: nil
)
}

View File

@@ -15,8 +15,6 @@ class AuthenticatedUITestCase: BaseUITestCase {
("admin", "test1234")
}
override var includeResetStateLaunchArgument: Bool { false }
// MARK: - API Session
private(set) var session: TestSession!
@@ -24,11 +22,21 @@ 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)")
}
guard TestAccountAPIClient.isBackendReachable() else {
throw XCTSkip("Backend not reachable at \(TestAccountAPIClient.baseURL)")
}
try super.setUpWithError()

View File

@@ -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
}

View File

@@ -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()
}
}
usernameField.waitForExistenceOrFail(timeout: 10)
// 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)
}

View File

@@ -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
}
}

View File

@@ -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)
@@ -517,7 +479,7 @@ final class Suite1_RegistrationTests: BaseUITestCase {
func test09_registrationWithInvalidVerificationCode() {
let username = testUsername
let email = testEmail
navigateToRegistration()
fillRegistrationForm(
username: username,
@@ -525,26 +487,24 @@ final class Suite1_RegistrationTests: BaseUITestCase {
password: testPassword,
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
let codeField = verificationCodeField()
XCTAssertTrue(codeField.waitForExistence(timeout: 5) && codeField.isHittable)
codeField.focusAndType("000000", app: app) // Wrong code
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'")
// 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.tap()
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
codeField.typeText("000000") // Wrong code auto-submit API error
// 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

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

View File

@@ -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,26 +291,31 @@ final class Suite6_ComprehensiveTaskTests: AuthenticatedUITestCase {
let task = findTask(title: originalTitle)
XCTAssertTrue(task.waitForExistence(timeout: defaultTimeout), "Task should exist")
task.tap()
let editButton = app.buttons[AccessibilityIdentifiers.Task.editButton].firstMatch
if editButton.waitForExistence(timeout: defaultTimeout) {
editButton.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 titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch
if titleField.waitForExistence(timeout: defaultTimeout) {
titleField.clearAndEnterText(newTitle, app: app)
let editButton = app.buttons[AccessibilityIdentifiers.Task.editButton].firstMatch
if editButton.waitForExistence(timeout: defaultTimeout) {
editButton.tap()
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch
if saveButton.exists {
saveButton.tap()
_ = saveButton.waitForNonExistence(timeout: defaultTimeout)
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch
if titleField.waitForExistence(timeout: defaultTimeout) {
titleField.clearAndEnterText(newTitle, app: app)
createdTaskTitles.append(newTitle)
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch
if saveButton.exists {
saveButton.tap()
_ = saveButton.waitForNonExistence(timeout: defaultTimeout)
navigateToTasks()
let updatedTask = findTask(title: newTitle)
XCTAssertTrue(updatedTask.exists, "Task should show updated title")
createdTaskTitles.append(newTitle)
navigateToTasks()
let updatedTask = findTask(title: newTitle)
XCTAssertTrue(updatedTask.exists, "Task should show updated title")
}
}
}
}

View File

@@ -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")
}
}

View File

@@ -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)
submitButton.tap()
// Wait for form to dismiss after submit
submitButton.waitForNonExistence(timeout: navigationTimeout, file: file, line: line)
// First tap attempt
if submitButton.isHittable {
submitButton.tap()
} 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() {

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
}

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
}

View File

@@ -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() {

View File

@@ -38,6 +38,7 @@ struct AuthenticatedImage: View {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: contentMode)
.accessibilityLabel("Image")
case .failure:
errorView
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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) {

View File

@@ -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(

View File

@@ -389,6 +389,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 +400,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 +425,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 +446,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
}

View File

@@ -37,7 +37,8 @@ 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 {

View File

@@ -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 {

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 : [])
}
}

View File

@@ -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

View File

@@ -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"
}
}

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
}
}

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,42 @@
"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$@"
}
}
}
},
"%@, %@%@" : {
"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 +98,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,12 +144,12 @@
"%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"
}
}
}
@@ -149,6 +201,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" : {
@@ -158,9 +214,28 @@
}
}
}
},
"Add contractor" : {
"comment" : "A label for the button that adds a new contractor.",
"isCommentAutoGenerated" : true
},
"Add document" : {
},
"Add Most Popular" : {
},
"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 +244,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 +393,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 +415,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 +4356,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 +4378,10 @@
},
"Cancel anytime in Settings • No commitment" : {
},
"Cancel task" : {
"comment" : "A button that cancels a task.",
"isCommentAutoGenerated" : true
},
"Cancel Task" : {
@@ -5330,6 +5424,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 +5436,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 +5466,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 +9118,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 +9137,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 +9154,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 +17160,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 +17228,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 +17246,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 +17280,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 +17649,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,17 +17672,56 @@
"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
},
"Generating suggestions..." : {
"comment" : "Text displayed while the app is generating personalized task suggestions.",
"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
},
"Good match" : {
"comment" : "A label describing a task's relevance.",
"isCommentAutoGenerated" : true
},
"Google Sign-In Error" : {
},
"Help improve honeyDue by sharing anonymous usage data" : {
"Great match" : {
"comment" : "A label describing a high-relevance task.",
"isCommentAutoGenerated" : true
},
"Here are tasks recommended for your area.\nPick the ones you'd like to track!" : {
"Help improve honeyDue by sharing anonymous usage data" : {
},
"Honeycomb Pattern" : {
@@ -17475,6 +17747,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 +17779,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 +17814,9 @@
},
"Let's give your place a name!" : {
},
"Loading" : {
},
"Loading..." : {
"comment" : "A placeholder text indicating that content is loading.",
@@ -17547,6 +17830,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 +17841,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\".",
@@ -17571,6 +17862,10 @@
},
"No personal data is collected. Analytics are fully anonymous." : {
},
"No personalized suggestions yet" : {
"comment" : "A message displayed when the user has not yet been personalized.",
"isCommentAutoGenerated" : true
},
"No properties yet" : {
@@ -17594,6 +17889,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 +17913,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 +17942,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 +22010,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 +25194,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 +25219,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 +25232,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 +25264,10 @@
},
"Set New Password" : {
},
"Settings" : {
"comment" : "A button that opens a settings screen.",
"isCommentAutoGenerated" : true
},
"settings_language" : {
"extractionState" : "manual",
@@ -25037,12 +25406,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" : {
@@ -25072,6 +25462,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 +25481,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 +30591,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 +30604,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.",
@@ -30212,10 +30617,18 @@
"comment" : "A button label that says \"Try Again\".",
"isCommentAutoGenerated" : true
},
"Try the Browse tab to explore tasks by category,\nor add home details for better suggestions." : {
"comment" : "A description of the benefits of using the",
"isCommentAutoGenerated" : true
},
"Unarchive" : {
"comment" : "A button that unarchives a task.",
"isCommentAutoGenerated" : true
},
"Unarchive task" : {
"comment" : "A button that unarchives a task.",
"isCommentAutoGenerated" : true
},
"Unarchive Task" : {
},
@@ -30249,6 +30662,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

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

View File

@@ -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,20 +354,21 @@ struct OnboardingCoordinator: View {
OnboardingFirstTaskContent(
residenceName: onboardingState.pendingResidenceName,
onTaskAdded: {
goForward()
}
)
.transition(navigationTransition)
case .subscriptionUpsell:
OnboardingSubscriptionContent(
onSubscribe: {
// Handle subscription flow
onboardingState.completeOnboarding()
onComplete()
}
)
.transition(navigationTransition)
case .subscriptionUpsell:
// Subscription removed from onboarding app is free
// Immediately complete if we somehow land here
EmptyView()
.onAppear {
onboardingState.completeOnboarding()
onComplete()
}
.transition(navigationTransition)
}
}
.animation(.easeInOut(duration: 0.3), value: onboardingState.currentStep)

View File

@@ -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 {

View File

@@ -1,6 +1,12 @@
import SwiftUI
import ComposeApp
/// Tab selection for task browsing
enum OnboardingTaskTab: String, CaseIterable {
case forYou = "For You"
case browse = "Browse All"
}
/// Screen 6: First task prompt with suggested templates - Content only (no navigation bar)
struct OnboardingFirstTaskContent: View {
var residenceName: String
@@ -13,10 +19,12 @@ struct OnboardingFirstTaskContent: View {
@State private var isCreatingTasks = false
@State private var expandedCategories: Set<String> = []
@State private var isAnimating = false
@State private var selectedTab: OnboardingTaskTab = .forYou
@State private var forYouTemplates: [OnboardingTaskTemplate] = []
@State private var isLoadingSuggestions = false
@Environment(\.colorScheme) var colorScheme
/// Maximum tasks allowed for free tier (matches API TierLimits)
private let maxTasksAllowed = 5
// No task selection limit users can add as many as they want
/// Category colors by name (used for both API and fallback templates)
private static let categoryColors: [String: Color] = [
@@ -45,12 +53,9 @@ struct OnboardingFirstTaskContent: View {
/// Cached categories computed once and stored to preserve stable UUIDs
@State private var taskCategoriesCache: [OnboardingTaskCategory]? = nil
/// Uses API-driven regional templates when available, falls back to hardcoded defaults
/// Task categories for the Browse tab
private var taskCategories: [OnboardingTaskCategory] {
if let cached = taskCategoriesCache { return cached }
if !onboardingState.regionalTemplates.isEmpty {
return categoriesFromAPI(onboardingState.regionalTemplates)
}
return fallbackCategories
}
@@ -173,12 +178,13 @@ struct OnboardingFirstTaskContent: View {
}
private var isAtMaxSelection: Bool {
selectedTasks.count >= maxTasksAllowed
false
}
var body: some View {
ZStack {
WarmGradientBackground()
.a11yDecorative()
// Decorative blobs
GeometryReader { geo in
@@ -216,6 +222,7 @@ struct OnboardingFirstTaskContent: View {
.offset(x: geo.size.width * 0.65, y: geo.size.height * 0.75)
.blur(radius: 15)
}
.a11yDecorative()
VStack(spacing: 0) {
ScrollViewReader { proxy in
@@ -285,10 +292,9 @@ struct OnboardingFirstTaskContent: View {
Text("You're all set up!")
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.a11yHeader()
Text(onboardingState.regionalTemplates.isEmpty
? "Let's get you started with some tasks.\nThe more you pick, the more we'll help you remember!"
: "Here are tasks recommended for your area.\nPick the ones you'd like to track!")
Text("Let's get you started with some tasks.\nThe more you pick, the more we'll help you remember!")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
@@ -301,86 +307,107 @@ struct OnboardingFirstTaskContent: View {
Image(systemName: isAtMaxSelection ? "checkmark.seal.fill" : "checkmark.circle.fill")
.foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary)
Text("\(selectedCount)/\(maxTasksAllowed) tasks selected")
Text("\(selectedCount) task\(selectedCount == 1 ? "" : "s") selected")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary)
.foregroundColor(Color.appPrimary)
}
.padding(.horizontal, 18)
.padding(.vertical, 10)
.background((isAtMaxSelection ? Color.appAccent : Color.appPrimary).opacity(0.1))
.background(Color.appPrimary.opacity(0.1))
.clipShape(Capsule())
.animation(.spring(response: 0.3), value: selectedCount)
.accessibilityLabel("\(selectedCount) task\(selectedCount == 1 ? "" : "s") selected")
// Task categories
VStack(spacing: 12) {
ForEach(taskCategories) { category in
OrganicTaskCategorySection(
category: category,
selectedTasks: $selectedTasks,
isExpanded: expandedCategories.contains(category.name),
isAtMaxSelection: isAtMaxSelection,
onToggleExpand: {
let isExpanding = !expandedCategories.contains(category.name)
withAnimation(.spring(response: 0.3)) {
if expandedCategories.contains(category.name) {
expandedCategories.remove(category.name)
} else {
expandedCategories.insert(category.name)
// Tab bar
OnboardingTaskTabBar(selectedTab: $selectedTab)
.padding(.horizontal, OrganicSpacing.comfortable)
// Tab content
switch selectedTab {
case .forYou:
// For You tab personalized suggestions
ForYouTasksTab(
forYouTemplates: forYouTemplates,
isLoading: isLoadingSuggestions,
selectedTasks: $selectedTasks,
isAtMaxSelection: isAtMaxSelection,
hasResidence: onboardingState.createdResidenceId != nil
)
.padding(.horizontal, OrganicSpacing.comfortable)
case .browse:
// Browse tab existing category browser
VStack(spacing: 12) {
ForEach(taskCategories) { category in
OrganicTaskCategorySection(
category: category,
selectedTasks: $selectedTasks,
isExpanded: expandedCategories.contains(category.name),
isAtMaxSelection: isAtMaxSelection,
onToggleExpand: {
let isExpanding = !expandedCategories.contains(category.name)
withAnimation(.spring(response: 0.3)) {
if expandedCategories.contains(category.name) {
expandedCategories.remove(category.name)
} else {
expandedCategories.insert(category.name)
}
}
}
if isExpanding {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
withAnimation {
proxy.scrollTo(category.name, anchor: .top)
if isExpanding {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
withAnimation {
proxy.scrollTo(category.name, anchor: .top)
}
}
}
}
}
)
.id(category.name)
}
}
.padding(.horizontal, OrganicSpacing.comfortable)
// Quick add all popular
Button(action: selectPopularTasks) {
HStack(spacing: 8) {
Image(systemName: "sparkles")
.font(.system(size: 16, weight: .semibold))
Text("Add Most Popular")
.font(.system(size: 16, weight: .semibold))
}
.foregroundStyle(
LinearGradient(
colors: [Color.appPrimary, Color.appAccent],
startPoint: .leading,
endPoint: .trailing
)
)
.frame(maxWidth: .infinity)
.frame(height: 56)
.background(
LinearGradient(
colors: [Color.appPrimary.opacity(0.1), Color.appAccent.opacity(0.1)],
startPoint: .leading,
endPoint: .trailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(
LinearGradient(
colors: [Color.appPrimary.opacity(0.3), Color.appAccent.opacity(0.3)],
startPoint: .leading,
endPoint: .trailing
),
lineWidth: 1.5
)
)
.id(category.name)
}
}
.padding(.horizontal, OrganicSpacing.comfortable)
// Quick add all popular
Button(action: selectPopularTasks) {
HStack(spacing: 8) {
Image(systemName: "sparkles")
.font(.system(size: 16, weight: .semibold))
Text("Add Most Popular")
.font(.system(size: 16, weight: .semibold))
}
.foregroundStyle(
LinearGradient(
colors: [Color.appPrimary, Color.appAccent],
startPoint: .leading,
endPoint: .trailing
)
)
.frame(maxWidth: .infinity)
.frame(height: 56)
.background(
LinearGradient(
colors: [Color.appPrimary.opacity(0.1), Color.appAccent.opacity(0.1)],
startPoint: .leading,
endPoint: .trailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(
LinearGradient(
colors: [Color.appPrimary.opacity(0.3), Color.appAccent.opacity(0.3)],
startPoint: .leading,
endPoint: .trailing
),
lineWidth: 1.5
)
)
}
.padding(.horizontal, OrganicSpacing.comfortable)
.a11yButton("Add popular tasks")
}
.padding(.horizontal, OrganicSpacing.comfortable)
}
.padding(.bottom, 140) // Space for button
}
@@ -433,16 +460,14 @@ struct OnboardingFirstTaskContent: View {
isAnimating = true
// Build and cache categories once to preserve stable UUIDs
if taskCategoriesCache == nil {
if !onboardingState.regionalTemplates.isEmpty {
taskCategoriesCache = categoriesFromAPI(onboardingState.regionalTemplates)
} else {
taskCategoriesCache = fallbackCategories
}
taskCategoriesCache = fallbackCategories
}
// Expand first category by default
if let first = taskCategories.first?.name {
expandedCategories.insert(first)
}
// Build "For You" suggestions based on home profile
buildForYouSuggestions()
}
.onDisappear {
isAnimating = false
@@ -451,31 +476,171 @@ struct OnboardingFirstTaskContent: View {
private func selectPopularTasks() {
withAnimation(.spring(response: 0.3)) {
if !onboardingState.regionalTemplates.isEmpty {
// API templates: select the first N tasks (they're ordered by display_order)
for task in allTasks {
if selectedTasks.count < maxTasksAllowed {
selectedTasks.insert(task.id)
}
}
} else {
// Fallback: select hardcoded popular tasks
let popularTaskTitles = [
"Change HVAC Filter",
"Test Smoke Detectors",
"Check for Leaks",
"Clean Gutters",
"Clean Refrigerator Coils"
]
for task in allTasks where popularTaskTitles.contains(task.title) {
if selectedTasks.count < maxTasksAllowed {
selectedTasks.insert(task.id)
}
}
let popularTaskTitles = [
"Change HVAC Filter",
"Test Smoke Detectors",
"Check for Leaks",
"Clean Gutters",
"Clean Refrigerator Coils"
]
for task in allTasks where popularTaskTitles.contains(task.title) {
selectedTasks.insert(task.id)
}
}
}
/// Build personalized "For You" suggestions based on the home profile selections
private func buildForYouSuggestions() {
var suggestions: [ForYouSuggestion] = []
let state = onboardingState
// HVAC-related suggestions based on heating/cooling type
if state.pendingHeatingType != nil || state.pendingCoolingType != nil {
suggestions.append(ForYouSuggestion(
template: OnboardingTaskTemplate(icon: "fanblades.fill", title: "Change HVAC Filter", category: "hvac", frequency: "monthly", color: .appPrimary),
relevance: .great, reason: "Based on your HVAC system"
))
}
if state.pendingHeatingType == "gas_furnace" || state.pendingHeatingType == "boiler" {
suggestions.append(ForYouSuggestion(
template: OnboardingTaskTemplate(icon: "flame.fill", title: "Inspect Furnace", category: "hvac", frequency: "yearly", color: Color(hex: "#FF6B35") ?? .orange),
relevance: .great, reason: "You have a gas system"
))
}
if state.pendingCoolingType == "central_ac" || state.pendingCoolingType == "heat_pump" {
suggestions.append(ForYouSuggestion(
template: OnboardingTaskTemplate(icon: "air.conditioner.horizontal.fill", title: "Schedule AC Tune-Up", category: "hvac", frequency: "yearly", color: .appPrimary),
relevance: .great, reason: "Central cooling needs annual service"
))
}
// Water heater
if state.pendingWaterHeaterType != nil {
suggestions.append(ForYouSuggestion(
template: OnboardingTaskTemplate(icon: "bolt.horizontal.fill", title: "Flush Water Heater", category: "plumbing", frequency: "yearly", color: Color(hex: "#FF9500") ?? .orange),
relevance: state.pendingWaterHeaterType?.contains("tank") == true ? .great : .good,
reason: "Extends water heater life"
))
}
// Pool
if state.pendingHasPool {
suggestions.append(ForYouSuggestion(
template: OnboardingTaskTemplate(icon: "figure.pool.swim", title: "Check Pool Chemistry", category: "exterior", frequency: "weekly", color: .appSecondary),
relevance: .great, reason: "You have a pool"
))
}
// Sprinklers
if state.pendingHasSprinklerSystem {
suggestions.append(ForYouSuggestion(
template: OnboardingTaskTemplate(icon: "sprinkler.and.droplets.fill", title: "Check Sprinkler System", category: "landscaping", frequency: "monthly", color: Color(hex: "#34C759") ?? .green),
relevance: .great, reason: "You have sprinklers"
))
}
// Fireplace
if state.pendingHasFireplace {
suggestions.append(ForYouSuggestion(
template: OnboardingTaskTemplate(icon: "fireplace.fill", title: "Inspect Chimney & Fireplace", category: "interior", frequency: "yearly", color: Color(hex: "#FF6B35") ?? .orange),
relevance: .great, reason: "You have a fireplace"
))
}
// Garage
if state.pendingHasGarage {
suggestions.append(ForYouSuggestion(
template: OnboardingTaskTemplate(icon: "lock.fill", title: "Test Garage Door Safety", category: "safety", frequency: "monthly", color: .appSecondary),
relevance: .good, reason: "You have a garage"
))
}
// Basement
if state.pendingHasBasement {
suggestions.append(ForYouSuggestion(
template: OnboardingTaskTemplate(icon: "drop.fill", title: "Check Basement for Moisture", category: "interior", frequency: "monthly", color: .appSecondary),
relevance: .good, reason: "You have a basement"
))
}
// Septic
if state.pendingHasSeptic {
suggestions.append(ForYouSuggestion(
template: OnboardingTaskTemplate(icon: "drop.triangle.fill", title: "Schedule Septic Inspection", category: "plumbing", frequency: "yearly", color: .appPrimary),
relevance: .great, reason: "You have a septic system"
))
}
// Attic
if state.pendingHasAttic {
suggestions.append(ForYouSuggestion(
template: OnboardingTaskTemplate(icon: "arrow.up.square.fill", title: "Inspect Attic Insulation", category: "interior", frequency: "yearly", color: Color(hex: "#AF52DE") ?? .purple),
relevance: .good, reason: "You have an attic"
))
}
// Roof-based
if state.pendingRoofType != nil {
suggestions.append(ForYouSuggestion(
template: OnboardingTaskTemplate(icon: "cloud.rain.fill", title: "Clean Gutters", category: "exterior", frequency: "semiannually", color: .appSecondary),
relevance: .great, reason: "Protects your roof"
))
}
// Always-recommended essentials (lower priority)
suggestions.append(ForYouSuggestion(
template: OnboardingTaskTemplate(icon: "smoke.fill", title: "Test Smoke Detectors", category: "safety", frequency: "monthly", color: .appError),
relevance: .good, reason: "Essential safety task"
))
suggestions.append(ForYouSuggestion(
template: OnboardingTaskTemplate(icon: "drop.fill", title: "Check for Leaks", category: "plumbing", frequency: "monthly", color: .appSecondary),
relevance: .good, reason: "Prevents water damage"
))
// Landscaping
if state.pendingLandscapingType == "lawn" || state.pendingLandscapingType == "garden" || state.pendingLandscapingType == "mixed" {
suggestions.append(ForYouSuggestion(
template: OnboardingTaskTemplate(icon: "leaf.fill", title: "Lawn Care", category: "landscaping", frequency: "weekly", color: Color(hex: "#34C759") ?? .green),
relevance: .good, reason: "Based on your landscaping"
))
}
// Sort: great first, then good; deduplicate by title
var seen = Set<String>()
let sorted = suggestions
.sorted { $0.relevance.rawValue > $1.relevance.rawValue }
.filter { seen.insert($0.template.title).inserted }
forYouTemplates = sorted.map { $0.template }
// If we have personalized suggestions, default to For You tab
if !forYouTemplates.isEmpty && hasAnyHomeProfileData() {
selectedTab = .forYou
} else {
selectedTab = .browse
}
}
/// Check if user filled in any home profile data
private func hasAnyHomeProfileData() -> Bool {
let s = onboardingState
return s.pendingHeatingType != nil ||
s.pendingCoolingType != nil ||
s.pendingWaterHeaterType != nil ||
s.pendingRoofType != nil ||
s.pendingHasPool ||
s.pendingHasSprinklerSystem ||
s.pendingHasSeptic ||
s.pendingHasFireplace ||
s.pendingHasGarage ||
s.pendingHasBasement ||
s.pendingHasAttic ||
s.pendingExteriorType != nil ||
s.pendingFlooringPrimary != nil ||
s.pendingLandscapingType != nil
}
private func addSelectedTasks() {
// If no tasks selected, just skip
if selectedTasks.isEmpty {
@@ -492,9 +657,14 @@ struct OnboardingFirstTaskContent: View {
isCreatingTasks = true
let selectedTemplates = allTasks.filter { selectedTasks.contains($0.id) }
// Collect from both browse and For You templates
let allAvailable = allTasks + forYouTemplates
let selectedTemplates = allAvailable.filter { selectedTasks.contains($0.id) }
// Deduplicate by title (same task might exist in both tabs)
var seenTitles = Set<String>()
let uniqueTemplates = selectedTemplates.filter { seenTitles.insert($0.title).inserted }
var completedCount = 0
let totalCount = selectedTemplates.count
let totalCount = uniqueTemplates.count
// Safety: if no templates matched (shouldn't happen), skip
if totalCount == 0 {
@@ -511,7 +681,7 @@ struct OnboardingFirstTaskContent: View {
print("🏠 ONBOARDING: Creating \(totalCount) tasks for residence \(residenceId)")
for template in selectedTemplates {
for template in uniqueTemplates {
// Look up category ID from DataManager
let categoryId: Int32? = {
return dataManager.taskCategories.first { $0.name.caseInsensitiveCompare(template.category) == .orderedSame }?.id
@@ -606,6 +776,7 @@ private struct OrganicTaskCategorySection: View {
Text(category.name)
.font(.system(size: 16, weight: .semibold))
.foregroundColor(Color.appTextPrimary)
.a11yHeader()
Spacer()
@@ -738,6 +909,8 @@ private struct OrganicTaskTemplateRow: View {
}
.buttonStyle(.plain)
.disabled(isDisabled)
.accessibilityLabel("\(template.title), \(template.frequency.capitalized)")
.accessibilityValue(isSelected ? "selected" : "not selected")
}
}
@@ -752,6 +925,197 @@ struct OnboardingTaskTemplate: Identifiable {
let color: Color
}
// MARK: - For You Suggestion Model
enum SuggestionRelevance: Int {
case good = 1
case great = 2
}
struct ForYouSuggestion {
let template: OnboardingTaskTemplate
let relevance: SuggestionRelevance
let reason: String
}
// MARK: - Tab Bar
private struct OnboardingTaskTabBar: View {
@Binding var selectedTab: OnboardingTaskTab
var body: some View {
Picker("", selection: $selectedTab) {
ForEach(OnboardingTaskTab.allCases, id: \.self) { tab in
Text(tab.rawValue).tag(tab)
}
}
.pickerStyle(.segmented)
}
}
// MARK: - For You Tasks Tab
private struct ForYouTasksTab: View {
let forYouTemplates: [OnboardingTaskTemplate]
let isLoading: Bool
@Binding var selectedTasks: Set<UUID>
let isAtMaxSelection: Bool
let hasResidence: Bool
var body: some View {
if isLoading {
VStack(spacing: 16) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Color.appPrimary))
.scaleEffect(1.2)
Text("Generating suggestions...")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
} else if forYouTemplates.isEmpty {
VStack(spacing: 16) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 64, height: 64)
Image(systemName: "sparkles")
.font(.system(size: 28))
.foregroundColor(Color.appPrimary)
}
Text("No personalized suggestions yet")
.font(.system(size: 17, weight: .semibold))
.foregroundColor(Color.appTextPrimary)
Text("Try the Browse tab to explore tasks by category,\nor add home details for better suggestions.")
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.lineSpacing(4)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 30)
.padding(.horizontal, 16)
.background(
ZStack {
Color.appBackgroundSecondary
GrainTexture(opacity: 0.01)
}
)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.naturalShadow(.subtle)
} else {
VStack(spacing: 0) {
ForEach(Array(forYouTemplates.enumerated()), id: \.element.id) { index, template in
let isSelected = selectedTasks.contains(template.id)
let isDisabled = isAtMaxSelection && !isSelected
ForYouSuggestionRow(
template: template,
isSelected: isSelected,
isDisabled: isDisabled,
relevance: index < 3 ? .great : .good,
onTap: {
withAnimation(.spring(response: 0.2)) {
if isSelected {
selectedTasks.remove(template.id)
} else if !isAtMaxSelection {
selectedTasks.insert(template.id)
}
}
}
)
if index < forYouTemplates.count - 1 {
Divider()
.padding(.leading, 60)
}
}
}
.background(
ZStack {
Color.appBackgroundSecondary
GrainTexture(opacity: 0.01)
}
)
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
.naturalShadow(.subtle)
}
}
}
// MARK: - For You Suggestion Row
private struct ForYouSuggestionRow: View {
let template: OnboardingTaskTemplate
let isSelected: Bool
let isDisabled: Bool
let relevance: SuggestionRelevance
var onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 14) {
// Checkbox
ZStack {
Circle()
.stroke(isSelected ? template.color : Color.appTextSecondary.opacity(isDisabled ? 0.15 : 0.3), lineWidth: 2)
.frame(width: 28, height: 28)
if isSelected {
Circle()
.fill(template.color)
.frame(width: 28, height: 28)
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundColor(.white)
}
}
// Task icon
Image(systemName: template.icon)
.font(.system(size: 18, weight: .medium))
.foregroundColor(template.color.opacity(isDisabled ? 0.3 : 0.8))
.frame(width: 24)
// Task info
VStack(alignment: .leading, spacing: 2) {
Text(template.title)
.font(.system(size: 15, weight: .medium))
.foregroundColor(isDisabled ? Color.appTextSecondary.opacity(0.5) : Color.appTextPrimary)
Text(template.frequency.capitalized)
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary.opacity(isDisabled ? 0.5 : 1))
}
Spacer()
// Relevance badge
Text(relevance == .great ? "Great match" : "Good match")
.font(.system(size: 10, weight: .bold))
.foregroundColor(relevance == .great ? Color.appPrimary : Color.appTextSecondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
(relevance == .great ? Color.appPrimary : Color.appTextSecondary).opacity(0.1)
)
.clipShape(Capsule())
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(isDisabled)
.accessibilityLabel("\(template.title), \(template.frequency.capitalized)")
.accessibilityValue(isSelected ? "selected" : "not selected")
}
}
// MARK: - Legacy wrapper with navigation bar (for backwards compatibility)
struct OnboardingFirstTaskView: View {

View File

@@ -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: {}
)
}

View File

@@ -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 {

View File

@@ -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))

View File

@@ -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()

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:

View File

@@ -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)

View File

@@ -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))

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -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() }) {

View File

@@ -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))

View File

@@ -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))

View File

@@ -40,6 +40,7 @@ struct NotificationPreferencesView: View {
Text(L10n.Profile.notificationPreferences)
.font(.system(size: 22, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.a11yHeader()
Text(L10n.Profile.notificationPreferencesSubtitle)
.font(.system(size: 14, weight: .medium))
@@ -97,6 +98,7 @@ struct NotificationPreferencesView: View {
}
.tint(Color.appPrimary)
.accessibilityIdentifier("Notifications.TaskDueSoon")
.accessibilityHint("Get notified when tasks are due soon")
.onChange(of: viewModel.taskDueSoon) { _, newValue in
guard !isInitialLoad else { return }
viewModel.updatePreference(taskDueSoon: newValue)
@@ -133,6 +135,7 @@ struct NotificationPreferencesView: View {
}
.tint(Color.appPrimary)
.accessibilityIdentifier("Notifications.TaskOverdue")
.accessibilityHint("Get notified when tasks are overdue")
.onChange(of: viewModel.taskOverdue) { _, newValue in
guard !isInitialLoad else { return }
viewModel.updatePreference(taskOverdue: newValue)
@@ -169,6 +172,7 @@ struct NotificationPreferencesView: View {
}
.tint(Color.appPrimary)
.accessibilityIdentifier("Notifications.TaskCompleted")
.accessibilityHint("Get notified when tasks are completed by others")
.onChange(of: viewModel.taskCompleted) { _, newValue in
guard !isInitialLoad else { return }
viewModel.updatePreference(taskCompleted: newValue)
@@ -190,6 +194,7 @@ struct NotificationPreferencesView: View {
}
.tint(Color.appPrimary)
.accessibilityIdentifier("Notifications.TaskAssigned")
.accessibilityHint("Get notified when tasks are assigned to you")
.onChange(of: viewModel.taskAssigned) { _, newValue in
guard !isInitialLoad else { return }
viewModel.updatePreference(taskAssigned: newValue)
@@ -225,6 +230,7 @@ struct NotificationPreferencesView: View {
}
.tint(Color.appPrimary)
.accessibilityIdentifier("Notifications.ResidenceShared")
.accessibilityHint("Get notified when someone joins your property")
.onChange(of: viewModel.residenceShared) { _, newValue in
guard !isInitialLoad else { return }
viewModel.updatePreference(residenceShared: newValue)
@@ -246,6 +252,7 @@ struct NotificationPreferencesView: View {
}
.tint(Color.appPrimary)
.accessibilityIdentifier("Notifications.WarrantyExpiring")
.accessibilityHint("Get notified when warranties are about to expire")
.onChange(of: viewModel.warrantyExpiring) { _, newValue in
guard !isInitialLoad else { return }
viewModel.updatePreference(warrantyExpiring: newValue)
@@ -267,6 +274,7 @@ struct NotificationPreferencesView: View {
}
.tint(Color.appPrimary)
.accessibilityIdentifier("Notifications.DailyDigest")
.accessibilityHint("Receive a daily summary of upcoming tasks")
.onChange(of: viewModel.dailyDigest) { _, newValue in
guard !isInitialLoad else { return }
viewModel.updatePreference(dailyDigest: newValue)
@@ -309,6 +317,7 @@ struct NotificationPreferencesView: View {
}
.tint(Color.appPrimary)
.accessibilityIdentifier("Notifications.EmailTaskCompleted")
.accessibilityHint("Receive email notifications when tasks are completed")
.onChange(of: viewModel.emailTaskCompleted) { _, newValue in
guard !isInitialLoad else { return }
viewModel.updatePreference(emailTaskCompleted: newValue)

View File

@@ -56,8 +56,10 @@ struct ProfileTabView: View {
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
.a11yDecorative()
}
}
.accessibilityLabel(L10n.Profile.notifications)
NavigationLink(destination: Text(L10n.Profile.privacy)) {
Label(L10n.Profile.privacy, systemImage: "lock.shield")
@@ -191,8 +193,10 @@ struct ProfileTabView: View {
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
.a11yDecorative()
}
}
.accessibilityLabel("\(L10n.Profile.theme), \(themeManager.currentTheme.displayName)")
Button(action: {
showingAnimationTesting = true
@@ -210,8 +214,10 @@ struct ProfileTabView: View {
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
.a11yDecorative()
}
}
.accessibilityLabel("Completion Animation, \(animationPreference.selectedAnimation.rawValue)")
}
.sectionBackground()
@@ -259,8 +265,11 @@ struct ProfileTabView: View {
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
.a11yDecorative()
}
}
.accessibilityLabel(L10n.Profile.contactSupport)
.accessibilityHint("Opens email to contact support")
}
.sectionBackground()

View File

@@ -57,6 +57,7 @@ struct ProfileView: View {
Text(L10n.Profile.profileSettings)
.font(.system(size: 22, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.a11yHeader()
}
.frame(maxWidth: .infinity)
.padding(.vertical)
@@ -73,6 +74,7 @@ struct ProfileView: View {
focusedField = .lastName
}
.accessibilityIdentifier("Profile.FirstNameField")
.accessibilityHint("Enter your first name")
TextField(L10n.Profile.lastName, text: $viewModel.lastName)
.textInputAutocapitalization(.words)
@@ -83,6 +85,7 @@ struct ProfileView: View {
focusedField = .email
}
.accessibilityIdentifier("Profile.LastNameField")
.accessibilityHint("Enter your last name")
} header: {
Text(L10n.Profile.personalInformation)
}
@@ -99,6 +102,7 @@ struct ProfileView: View {
viewModel.updateProfile()
}
.accessibilityIdentifier("Profile.EmailField")
.accessibilityHint("Enter your email address")
} header: {
Text(L10n.Profile.contact)
} footer: {

View File

@@ -147,6 +147,8 @@ struct ThemeRow: View {
}
.padding(.vertical, 6)
.contentShape(Rectangle())
.accessibilityLabel(theme.displayName)
.accessibilityValue(isSelected ? "Selected" : "")
}
}

View File

@@ -2,12 +2,13 @@ import SwiftUI
import ComposeApp
struct RegisterView: View {
@Binding var isPresented: Bool
var onVerified: (() -> Void)?
@StateObject private var viewModel = RegisterViewModel()
@Environment(\.dismiss) var dismiss
@FocusState private var focusedField: Field?
@State private var showVerifyEmail = false
@State private var isPasswordVisible = false
@State private var isConfirmPasswordVisible = false
@State private var verificationCompleted = false
enum Field {
case username, email, password, confirmPassword
@@ -64,6 +65,7 @@ struct RegisterView: View {
Text(L10n.Auth.joinhoneyDue)
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.accessibilityAddTraits(.isHeader)
Text(L10n.Auth.startManaging)
.font(.system(size: 15, weight: .medium))
@@ -119,7 +121,7 @@ struct RegisterView: View {
accessibilityId: AccessibilityIdentifiers.Authentication.registerPasswordField
)
.focused($focusedField, equals: .password)
.textContentType(.newPassword)
.textContentType(UITestRuntime.isEnabled ? nil : .newPassword)
.submitLabel(.next)
.onSubmit { focusedField = .confirmPassword }
@@ -133,7 +135,7 @@ struct RegisterView: View {
accessibilityId: AccessibilityIdentifiers.Authentication.registerConfirmPasswordField
)
.focused($focusedField, equals: .confirmPassword)
.textContentType(.newPassword)
.textContentType(UITestRuntime.isEnabled ? nil : .newPassword)
.submitLabel(.go)
.onSubmit { viewModel.register() }
@@ -171,6 +173,7 @@ struct RegisterView: View {
.padding(16)
.background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerErrorMessage)
}
// Register Button
@@ -201,6 +204,7 @@ struct RegisterView: View {
}
.disabled(!isFormValid || viewModel.isLoading)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerButton)
.accessibilityHint("Double tap to create account")
// Login Link
HStack(spacing: 6) {
@@ -209,7 +213,7 @@ struct RegisterView: View {
.foregroundColor(Color.appTextSecondary)
Button(L10n.Auth.signIn) {
dismiss()
isPresented = false
}
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundColor(Color.appPrimary)
@@ -229,7 +233,7 @@ struct RegisterView: View {
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: { dismiss() }) {
Button(action: { isPresented = false }) {
Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appTextSecondary)
@@ -240,16 +244,24 @@ struct RegisterView: View {
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerCancelButton)
}
}
.fullScreenCover(isPresented: $viewModel.isRegistered) {
.fullScreenCover(isPresented: $viewModel.isRegistered, onDismiss: {
// fullScreenCover is fully removed from the UIKit presentation stack.
// Now safe to dismiss the RegisterView sheet. Auth state is set in
// LoginView's sheet onDismiss after this sheet also finishes dismissing.
if verificationCompleted {
onVerified?()
isPresented = false
}
}) {
VerifyEmailView(
onVerifySuccess: {
AuthenticationManager.shared.markVerified()
showVerifyEmail = false
dismiss()
verificationCompleted = true
viewModel.isRegistered = false
},
onLogout: {
AuthenticationManager.shared.logout()
dismiss()
viewModel.isRegistered = false
isPresented = false
}
)
}
@@ -350,6 +362,7 @@ private struct OrganicSecureField: View {
.font(.system(size: 16, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
.accessibilityLabel("Toggle password visibility")
}
.padding(16)
.background(Color.appBackgroundPrimary.opacity(0.5))
@@ -415,5 +428,5 @@ private struct OrganicFormBackground: View {
}
#Preview {
RegisterView()
RegisterView(isPresented: .constant(true))
}

View File

@@ -67,8 +67,11 @@ class RegisterViewModel: ObservableObject {
// Track successful registration
AnalyticsManager.shared.track(.userRegistered(method: "email"))
// Update AuthenticationManager - user is authenticated but NOT verified
AuthenticationManager.shared.login(verified: false)
// Auth state is set AFTER sheets dismiss (via LoginView's
// sheet onDismiss callback). Setting isAuthenticated here while
// the RegisterView sheet is still presented would cause RootView
// to swap LoginViewMainTabView behind the UIKit sheet, leaving
// a stale view hierarchy.
self.isRegistered = true
self.isLoading = false

View File

@@ -295,6 +295,7 @@ private extension ResidenceDetailView {
Text(L10n.Residences.contractors)
.font(.system(size: 20, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.accessibilityAddTraits(.isHeader)
Spacer()
}
@@ -351,10 +352,11 @@ private extension ResidenceDetailView {
showEditResidence = true
}
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.editButton)
.accessibilityLabel("Edit property")
}
}
}
@ToolbarContentBuilder
var trailingToolbar: some ToolbarContent {
ToolbarItemGroup(placement: .navigationBarTrailing) {
@@ -369,6 +371,7 @@ private extension ResidenceDetailView {
}
}
.disabled(viewModel.isGeneratingReport)
.accessibilityLabel("Generate maintenance report")
}
// Manage Users button (owner only) - includes share code generation and easy share
@@ -383,6 +386,7 @@ private extension ResidenceDetailView {
} label: {
Image(systemName: "person.2")
}
.accessibilityLabel("Manage users")
}
Button {
@@ -398,6 +402,7 @@ private extension ResidenceDetailView {
Image(systemName: "plus")
}
.accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton)
.accessibilityLabel("Add task")
if let residence = viewModel.selectedResidence, isCurrentUserOwner(of: residence) {
Button {
@@ -407,6 +412,7 @@ private extension ResidenceDetailView {
.foregroundStyle(Color.appError)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.deleteButton)
.accessibilityLabel("Delete property")
}
}
}

View File

@@ -96,6 +96,13 @@ class ResidenceViewModel: ObservableObject {
/// Load my residences - checks cache first, then fetches if needed
func loadMyResidences(forceRefresh: Bool = false) {
// Ensure lookups are initialized (may not be during onboarding)
if !DataManagerObservable.shared.lookupsInitialized {
Task {
_ = try? await APILayer.shared.initializeLookups()
}
}
if UITestRuntime.shouldMockAuth {
if Self.uiTestMockResidences.isEmpty || forceRefresh {
if Self.uiTestMockResidences.isEmpty {
@@ -355,6 +362,20 @@ class ResidenceViewModel: ObservableObject {
isActive: true,
overdueCount: 0,
completionSummary: nil,
heatingType: nil,
coolingType: nil,
waterHeaterType: nil,
roofType: nil,
hasPool: false,
hasSprinklerSystem: false,
hasSeptic: false,
hasFireplace: false,
hasGarage: false,
hasBasement: false,
hasAttic: false,
exteriorType: nil,
flooringPrimary: nil,
landscapingType: nil,
createdAt: now,
updatedAt: now
)

View File

@@ -55,6 +55,7 @@ struct ResidencesListView: View {
OrganicToolbarButton(systemName: "gearshape.fill")
}
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.settingsButton)
.accessibilityLabel("Settings")
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
@@ -69,6 +70,7 @@ struct ResidencesListView: View {
}) {
OrganicToolbarButton(systemName: "person.badge.plus")
}
.accessibilityLabel("Join a property")
Button(action: {
// Check if we should show upgrade prompt before adding
@@ -82,19 +84,20 @@ struct ResidencesListView: View {
OrganicToolbarButton(systemName: "plus", isPrimary: true)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.addButton)
.accessibilityLabel("Add new property")
}
}
.sheet(isPresented: $showingAddResidence) {
AddResidenceView(
isPresented: $showingAddResidence,
onResidenceCreated: {
viewModel.loadMyResidences(forceRefresh: true)
refreshWithTimeout()
}
)
}
.sheet(isPresented: $showingJoinResidence) {
JoinResidenceView(onJoined: {
viewModel.loadMyResidences(forceRefresh: true)
refreshWithTimeout()
})
}
.sheet(isPresented: $showingUpgradePrompt) {
@@ -139,6 +142,17 @@ struct ResidencesListView: View {
}
}
/// Refresh residences with a 10-second timeout to prevent indefinite loading
private func refreshWithTimeout() {
viewModel.loadMyResidences(forceRefresh: true)
// Safety timeout: if the API hangs, clear loading state after 10 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
if viewModel.isLoading {
viewModel.isLoading = false
}
}
}
private func navigateToResidenceFromPush(residenceId: Int) {
pushTargetResidenceId = Int32(residenceId)
PushNotificationManager.shared.pendingNavigationResidenceId = nil

View File

@@ -80,6 +80,7 @@ struct ResidenceFormView: View {
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.propertyTypePicker)
} header: {
Text(L10n.Residences.propertyDetails)
.accessibilityAddTraits(.isHeader)
} footer: {
Text(L10n.Residences.nameRequired)
.font(.caption)
@@ -131,6 +132,7 @@ struct ResidenceFormView: View {
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.countryField)
} header: {
Text(L10n.Residences.address)
.accessibilityAddTraits(.isHeader)
}
.sectionBackground()
@@ -162,6 +164,7 @@ struct ResidenceFormView: View {
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.yearBuiltField)
} header: {
Text(L10n.Residences.propertyFeatures)
.accessibilityAddTraits(.isHeader)
}
.sectionBackground()
@@ -175,6 +178,7 @@ struct ResidenceFormView: View {
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.isPrimaryToggle)
} header: {
Text(L10n.Residences.additionalDetails)
.accessibilityAddTraits(.isHeader)
}
.sectionBackground()
@@ -391,7 +395,21 @@ struct ResidenceFormView: View {
description: description.isEmpty ? nil : description,
purchaseDate: nil,
purchasePrice: nil,
isPrimary: KotlinBoolean(bool: isPrimary)
isPrimary: KotlinBoolean(bool: isPrimary),
heatingType: nil,
coolingType: nil,
waterHeaterType: nil,
roofType: nil,
hasPool: nil,
hasSprinklerSystem: nil,
hasSeptic: nil,
hasFireplace: nil,
hasGarage: nil,
hasBasement: nil,
hasAttic: nil,
exteriorType: nil,
flooringPrimary: nil,
landscapingType: nil
)
if let residence = existingResidence {

View File

@@ -11,7 +11,10 @@ class AuthenticationManager: ObservableObject {
@Published var isCheckingAuth: Bool = true
private init() {
checkAuthenticationStatus()
// NOTE: Do NOT call checkAuthenticationStatus() here.
// AuthenticationManager.shared may be initialized before DataManager.initialize()
// completes in iOSApp.init(), causing a race condition. Instead, RootView
// triggers the auth check via .task {} after the view appears.
}
func checkAuthenticationStatus() {
@@ -200,6 +203,14 @@ struct RootView: View {
.frame(width: 1, height: 1)
.accessibilityIdentifier("ui.app.ready")
}
.task {
// Trigger auth check here, after iOSApp.init() has completed
// DataManager.initialize(). This avoids the race condition where
// checkAuthenticationStatus() runs before DataManager is ready.
if authManager.isCheckingAuth {
authManager.checkAuthenticationStatus()
}
}
}
private var loadingView: some View {

View File

@@ -50,6 +50,7 @@ struct PrimaryButton: View {
.cornerRadius(AppRadius.md)
}
.disabled(isDisabled || isLoading)
.accessibilityValue(isLoading ? "Loading" : "")
}
}
@@ -296,5 +297,6 @@ struct OrganicPrimaryButton: View {
)
}
.disabled(isDisabled || isLoading)
.accessibilityValue(isLoading ? "Loading" : "")
}
}

View File

@@ -257,6 +257,7 @@ struct IconTextField: View {
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appPrimary)
}
.accessibilityHidden(true)
if isSecure {
SecureField(placeholder, text: $text)
@@ -311,6 +312,7 @@ struct SecureIconTextField: View {
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appPrimary)
}
.accessibilityHidden(true)
Group {
if isVisible {
@@ -332,6 +334,7 @@ struct SecureIconTextField: View {
.font(.system(size: 16, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
.accessibilityLabel(A11y.Auth.passwordToggle)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.passwordVisibilityToggle)
}
.padding(16)
@@ -375,5 +378,6 @@ struct FieldError: View {
Text(message)
.font(.caption)
.foregroundColor(Color.appError)
.accessibilityLabel(message)
}
}

View File

@@ -28,6 +28,7 @@ struct StandardEmptyStateView: View {
Image(systemName: icon)
.font(.system(size: 60))
.foregroundColor(Color.appTextSecondary.opacity(0.5))
.accessibilityHidden(true)
VStack(spacing: 8) {
Text(title)
@@ -55,6 +56,7 @@ struct StandardEmptyStateView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.accessibilityElement(children: .combine)
}
}
@@ -108,6 +110,7 @@ struct OrganicEmptyState: View {
.font(.system(size: 32, weight: .medium))
.foregroundColor(accentColor.opacity(0.6))
}
.accessibilityHidden(true)
VStack(spacing: 8) {
Text(title)
@@ -140,6 +143,7 @@ struct OrganicEmptyState: View {
.background(OrganicCardBackground(showBlob: true, blobVariation: blobVariation))
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
.naturalShadow(.subtle)
.accessibilityElement(children: .combine)
}
}
@@ -154,6 +158,7 @@ struct ListEmptyState: View {
Image(systemName: icon)
.font(.system(size: 48))
.foregroundColor(Color.appTextSecondary.opacity(0.4))
.accessibilityHidden(true)
Text(message)
.font(.subheadline)
@@ -162,5 +167,6 @@ struct ListEmptyState: View {
}
.padding(.vertical, 40)
.frame(maxWidth: .infinity)
.accessibilityElement(children: .combine)
}
}

View File

@@ -0,0 +1,40 @@
import SwiftUI
extension View {
func a11yHeader(_ label: String) -> some View {
self.accessibilityLabel(label)
.accessibilityAddTraits(.isHeader)
}
func a11yHeader() -> some View {
self.accessibilityAddTraits(.isHeader)
}
func a11yDecorative() -> some View {
self.accessibilityHidden(true)
}
func a11yButton(_ label: String, hint: String? = nil) -> some View {
let view = self.accessibilityLabel(label)
.accessibilityAddTraits(.isButton)
if let hint = hint {
return AnyView(view.accessibilityHint(hint))
}
return AnyView(view)
}
func a11yImage(_ description: String) -> some View {
self.accessibilityLabel(description)
.accessibilityAddTraits(.isImage)
}
func a11yCard(label: String) -> some View {
self.accessibilityElement(children: .combine)
.accessibilityLabel(label)
}
func a11yStatValue(_ value: String, label: String) -> some View {
self.accessibilityElement(children: .combine)
.accessibilityLabel(A11y.Common.stat(value: value, label: label))
}
}

View File

@@ -49,6 +49,7 @@ struct FeatureComparisonView: View {
Text("Choose Your Plan")
.font(.title.weight(.bold))
.foregroundColor(Color.appTextPrimary)
.a11yHeader()
Text("Upgrade to Pro for unlimited access")
.font(.subheadline)
@@ -64,16 +65,19 @@ struct FeatureComparisonView: View {
.font(.headline)
.foregroundColor(Color.appTextPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.a11yHeader()
Text("Free")
.font(.headline)
.foregroundColor(Color.appTextSecondary)
.frame(width: 80)
.a11yHeader()
Text("Pro")
.font(.headline)
.foregroundColor(Color.appPrimary)
.frame(width: 80)
.a11yHeader()
}
.padding()
.background(Color.appBackgroundSecondary)
@@ -181,6 +185,7 @@ struct FeatureComparisonView: View {
Button("Close") {
isPresented = false
}
.accessibilityLabel("Close")
}
}
.alert("Subscription Active", isPresented: $purchaseHelper.showSuccessAlert) {
@@ -253,6 +258,7 @@ struct SubscriptionButton: View {
)
}
.disabled(isProcessing)
.accessibilityLabel("\(product.displayName), \(product.displayPrice)\(savingsText.map { ", \($0)" } ?? "")")
}
}
@@ -260,20 +266,20 @@ struct ComparisonRow: View {
let featureName: String
let freeText: String
let proText: String
var body: some View {
HStack {
Text(featureName)
.font(.body)
.foregroundColor(Color.appTextPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
Text(freeText)
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
.frame(width: 80)
.multilineTextAlignment(.center)
Text(proText)
.font(.subheadline.weight(.medium))
.foregroundColor(Color.appPrimary)
@@ -281,6 +287,8 @@ struct ComparisonRow: View {
.multilineTextAlignment(.center)
}
.padding()
.accessibilityElement(children: .combine)
.accessibilityLabel("\(featureName): Free: \(freeText), Pro: \(proText)")
}
}

View File

@@ -108,6 +108,7 @@ struct UpgradeFeatureView: View {
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center)
.a11yHeader()
Text(message)
.font(.system(size: 15, weight: .medium))
@@ -219,7 +220,7 @@ struct UpgradeFeatureView: View {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(WarmGradientBackground())
.background(WarmGradientBackground().a11yDecorative())
.sheet(isPresented: $showFeatureComparison) {
FeatureComparisonView(isPresented: $showFeatureComparison)
}

View File

@@ -147,6 +147,7 @@ struct UpgradePromptView: View {
NavigationStack {
ZStack {
WarmGradientBackground()
.a11yDecorative()
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.comfortable) {
@@ -338,6 +339,7 @@ struct UpgradePromptView: View {
.background(Color.appBackgroundSecondary.opacity(0.8))
.clipShape(Circle())
}
.a11yButton("Close")
}
}
.sheet(isPresented: $showFeatureComparison) {
@@ -474,6 +476,7 @@ private struct OrganicSubscriptionButton: View {
)
}
.disabled(isProcessing)
.accessibilityLabel("\(product.displayName), \(product.displayPrice)\(savingsText.map { ", \($0)" } ?? "")")
}
}

View File

@@ -8,6 +8,7 @@ struct ErrorMessageView: View {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(Color.appError)
.accessibilityHidden(true)
Text(message)
.font(.caption)
@@ -19,6 +20,7 @@ struct ErrorMessageView: View {
Image(systemName: "xmark.circle.fill")
.foregroundColor(Color.appError)
}
.accessibilityLabel(A11y.Common.dismissError)
}
.padding()
.background(Color.appError.opacity(0.1))

View File

@@ -15,6 +15,7 @@ struct ErrorView: View {
.font(.system(size: 44, weight: .medium))
.foregroundColor(Color.appError)
}
.accessibilityHidden(true)
Text("Error: \(message)")
.font(.system(size: 15, weight: .medium))
@@ -31,6 +32,7 @@ struct ErrorView: View {
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.naturalShadow(.subtle)
}
.accessibilityLabel(A11y.Common.retryButton)
}
.padding(OrganicSpacing.comfortable)
}

View File

@@ -36,10 +36,12 @@ struct HomeNavigationCard: View {
Image(systemName: "chevron.right")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(Color.appTextSecondary.opacity(0.7))
.accessibilityHidden(true)
}
.padding(AppSpacing.lg)
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.lg)
.shadow(color: AppShadow.md.color, radius: AppShadow.md.radius, x: AppShadow.md.x, y: AppShadow.md.y)
.accessibilityElement(children: .combine)
}
}

View File

@@ -233,6 +233,7 @@ struct HoneyDueIconView: View {
.offset(x: offsetX, y: offsetY)
}
.aspectRatio(1, contentMode: .fit)
.accessibilityHidden(true)
}
}
@@ -257,6 +258,7 @@ struct AnimatedHoneyDueIconView: View {
showBackground: showBackground,
backgroundOpacity: backgroundOpacity
)
.accessibilityHidden(true)
.onAppear {
animateIn()
}

View File

@@ -15,6 +15,8 @@ struct ImageThumbnailView: View {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(.quaternary, lineWidth: 1)
}
.accessibilityLabel("Attached photo")
.accessibilityAddTraits(.isImage)
Button(action: onRemove) {
Image(systemName: "xmark.circle.fill")
@@ -26,6 +28,7 @@ struct ImageThumbnailView: View {
.padding(4)
}
}
.accessibilityLabel(A11y.Common.removePhoto)
.offset(x: 8, y: -8)
}
}

View File

@@ -63,5 +63,7 @@ struct StatView: View {
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.accessibilityElement(children: .combine)
.accessibilityLabel(A11y.Common.stat(value: value, label: label))
}
}

View File

@@ -20,6 +20,8 @@ struct PropertyDetailItem: View {
.font(.caption2)
.foregroundColor(Color.appTextSecondary)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(A11y.Common.stat(value: value, label: label))
}
}

View File

@@ -17,6 +17,7 @@ struct PropertyHeaderCard: View {
Text(residence.name)
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.accessibilityAddTraits(.isHeader)
if let propertyTypeName = residence.propertyTypeName {
Text(propertyTypeName.uppercased())
@@ -142,6 +143,7 @@ struct PropertyHeaderCard: View {
}
.padding(.horizontal, 12)
.padding(.vertical, 16)
.accessibilityElement(children: .combine)
}
}
.background(PropertyHeaderBackground())
@@ -218,6 +220,7 @@ private struct PropertyDetailIcon: View {
.foregroundColor(Color.appTextOnPrimary)
}
.naturalShadow(.subtle)
.a11yDecorative()
}
}
@@ -315,6 +318,20 @@ private struct PropertyHeaderBackground: View {
isActive: true,
overdueCount: 0,
completionSummary: nil,
heatingType: nil,
coolingType: nil,
waterHeaterType: nil,
roofType: nil,
hasPool: false,
hasSprinklerSystem: false,
hasSeptic: false,
hasFireplace: false,
hasGarage: false,
hasBasement: false,
hasAttic: false,
exteriorType: nil,
flooringPrimary: nil,
landscapingType: nil,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z"
))

View File

@@ -78,6 +78,7 @@ struct ResidenceCard: View {
.foregroundColor(Color.appPrimary.opacity(0.6))
}
}
.accessibilityLabel("Open \(residence.streetAddress) in Maps")
.padding(.top, 2)
}
}
@@ -148,6 +149,23 @@ struct ResidenceCard: View {
.background(CardBackgroundView(hasOverdue: hasOverdueTasks))
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
.naturalShadow(.medium)
.accessibilityElement(children: .combine)
.accessibilityLabel({
var parts = [residence.name]
if let propertyTypeName = residence.propertyTypeName {
parts.append(propertyTypeName)
}
if !residence.streetAddress.isEmpty {
parts.append(residence.streetAddress)
}
if taskMetrics.totalCount > 0 {
parts.append("\(taskMetrics.totalCount) tasks")
}
if residence.isPrimary {
parts.append("Primary property")
}
return parts.joined(separator: ", ")
}())
}
}
@@ -198,6 +216,7 @@ private struct PrimaryBadgeView: View {
.font(.system(size: 14, weight: .bold))
.foregroundColor(Color.appAccent)
}
.accessibilityLabel("Primary property")
}
}
@@ -289,6 +308,20 @@ private struct CardBackgroundView: View {
isActive: true,
overdueCount: 2,
completionSummary: nil,
heatingType: nil,
coolingType: nil,
waterHeaterType: nil,
roofType: nil,
hasPool: false,
hasSprinklerSystem: false,
hasSeptic: false,
hasFireplace: false,
hasGarage: false,
hasBasement: false,
hasAttic: false,
exteriorType: nil,
flooringPrimary: nil,
landscapingType: nil,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z"
),
@@ -322,6 +355,20 @@ private struct CardBackgroundView: View {
isActive: true,
overdueCount: 0,
completionSummary: nil,
heatingType: nil,
coolingType: nil,
waterHeaterType: nil,
roofType: nil,
hasPool: false,
hasSprinklerSystem: false,
hasSeptic: false,
hasFireplace: false,
hasGarage: false,
hasBasement: false,
hasAttic: false,
exteriorType: nil,
flooringPrimary: nil,
landscapingType: nil,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z"
),

View File

@@ -62,6 +62,7 @@ struct ShareCodeCard: View {
.fill(Color.appTextSecondary.opacity(0.3))
.frame(height: 1)
}
.accessibilityHidden(true)
// Share Code Section
VStack(alignment: .leading, spacing: 8) {
@@ -75,6 +76,7 @@ struct ShareCodeCard: View {
.font(.system(size: 32, weight: .bold, design: .monospaced))
.foregroundColor(Color.appPrimary)
.kerning(4)
.accessibilityLabel("Share code: \(shareCode.code)")
Spacer()
@@ -85,6 +87,7 @@ struct ShareCodeCard: View {
.foregroundColor(Color.appPrimary)
}
.buttonStyle(.bordered)
.accessibilityLabel("Copy share code")
} else {
Text("No active code")
.font(.body)
@@ -110,6 +113,7 @@ struct ShareCodeCard: View {
}
.buttonStyle(.borderedProminent)
.disabled(isGeneratingCode)
.accessibilityLabel("Generate new share code")
if shareCode != nil {
Text("Share this 6-character code. They can enter it in the app to join.")

View File

@@ -15,6 +15,7 @@ struct SummaryCard: View {
.padding(.horizontal, OrganicSpacing.cozy)
.padding(.top, OrganicSpacing.cozy)
.padding(.bottom, 20)
.accessibilityAddTraits(.isHeader)
// Main Stats Row
HStack(spacing: 0) {
@@ -120,6 +121,7 @@ private struct OrganicStatItem: View {
.foregroundColor(Color.appTextSecondary)
}
.frame(maxWidth: .infinity)
.accessibilityElement(children: .combine)
}
}
@@ -149,6 +151,7 @@ private struct TimelineStatPill: View {
.foregroundColor(Color.appTextSecondary)
}
.frame(maxWidth: .infinity)
.accessibilityElement(children: .combine)
.padding(.vertical, 18)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)

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