Compare commits
10 Commits
0d80df07f6
...
d545fd463c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d545fd463c | ||
|
|
5bb27034aa | ||
|
|
00e9ed0a96 | ||
|
|
05ee8e0a79 | ||
|
|
266d540d28 | ||
|
|
4609d5a953 | ||
|
|
8f86fa2cd0 | ||
|
|
4d363ca44e | ||
|
|
e4dc3ac30b | ||
|
|
af73f8861b |
24
.claude/settings.local.json
Normal file
24
.claude/settings.local.json
Normal 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
417
03_14_26_uiresults.md
Normal 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.
|
||||
@@ -80,6 +80,9 @@ class MainActivity : FragmentActivity(), SingletonImageLoader.Factory {
|
||||
// Initialize PostHog Analytics
|
||||
PostHogAnalytics.initialize(application, debug = true) // Set debug=false for release
|
||||
|
||||
// Install uncaught exception handler to capture crashes to PostHog
|
||||
PostHogAnalytics.setupExceptionHandler()
|
||||
|
||||
// Handle deep link, notification navigation, and file import from intent
|
||||
handleDeepLink(intent)
|
||||
handleNotificationNavigation(intent)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.tt.honeyDue.analytics
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.posthog.PostHog
|
||||
import com.posthog.android.PostHogAndroid
|
||||
import com.posthog.android.PostHogAndroidConfig
|
||||
@@ -13,6 +14,8 @@ actual object PostHogAnalytics {
|
||||
private const val API_KEY = "YOUR_POSTHOG_API_KEY"
|
||||
private const val HOST = "https://us.i.posthog.com"
|
||||
|
||||
private const val TAG = "PostHogAnalytics"
|
||||
|
||||
private var isInitialized = false
|
||||
private var application: Application? = null
|
||||
|
||||
@@ -57,6 +60,28 @@ actual object PostHogAnalytics {
|
||||
PostHog.capture(event, properties = properties)
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture an exception/crash as a PostHog `$exception` event.
|
||||
* Uses PostHog's standard exception property names so exceptions
|
||||
* appear correctly in the PostHog Errors dashboard.
|
||||
*/
|
||||
actual fun captureException(throwable: Throwable, properties: Map<String, Any>?) {
|
||||
if (!isInitialized) return
|
||||
try {
|
||||
val exceptionProps = mutableMapOf<String, Any>(
|
||||
"\$exception_type" to (throwable::class.simpleName ?: "Unknown"),
|
||||
"\$exception_message" to (throwable.message ?: "No message"),
|
||||
"\$exception_stack_trace_raw" to throwable.stackTraceToString()
|
||||
)
|
||||
if (properties != null) {
|
||||
exceptionProps.putAll(properties)
|
||||
}
|
||||
PostHog.capture("\$exception", properties = exceptionProps)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to capture exception to PostHog", e)
|
||||
}
|
||||
}
|
||||
|
||||
actual fun screen(screenName: String, properties: Map<String, Any>?) {
|
||||
if (!isInitialized) return
|
||||
PostHog.screen(screenName, properties = properties)
|
||||
@@ -71,4 +96,36 @@ actual object PostHogAnalytics {
|
||||
if (!isInitialized) return
|
||||
PostHog.flush()
|
||||
}
|
||||
|
||||
/**
|
||||
* Install an uncaught exception handler that captures crashes to PostHog
|
||||
* before delegating to the default handler (which shows the crash dialog).
|
||||
* Call this after initialize() in MainActivity.onCreate().
|
||||
*/
|
||||
actual fun setupExceptionHandler() {
|
||||
if (!isInitialized) return
|
||||
|
||||
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
try {
|
||||
PostHog.capture(
|
||||
event = "\$exception",
|
||||
properties = mapOf(
|
||||
"\$exception_type" to (throwable::class.simpleName ?: "Unknown"),
|
||||
"\$exception_message" to (throwable.message ?: "No message"),
|
||||
"\$exception_stack_trace_raw" to throwable.stackTraceToString(),
|
||||
"\$exception_thread" to thread.name,
|
||||
"\$exception_is_fatal" to true
|
||||
)
|
||||
)
|
||||
// Flush to ensure the event is sent before the process dies
|
||||
PostHog.flush()
|
||||
} catch (_: Exception) {
|
||||
// Don't let our crash handler crash
|
||||
}
|
||||
// Call the default handler so the system crash dialog still appears
|
||||
defaultHandler?.uncaughtException(thread, throwable)
|
||||
}
|
||||
Log.d(TAG, "Uncaught exception handler installed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,6 +813,18 @@
|
||||
<string name="onboarding_subscription_continue_free">Continue with Free</string>
|
||||
<string name="onboarding_subscription_trial_terms">7-day free trial, then %1$s. Cancel anytime.</string>
|
||||
|
||||
<!-- Onboarding - Home Profile -->
|
||||
<string name="onboarding_home_profile_title">Tell us about your home</string>
|
||||
<string name="onboarding_home_profile_subtitle">All optional — helps us personalize your maintenance plan</string>
|
||||
<string name="onboarding_home_profile_systems">Systems</string>
|
||||
<string name="onboarding_home_profile_features">Features</string>
|
||||
<string name="onboarding_home_profile_exterior">Exterior</string>
|
||||
<string name="onboarding_home_profile_interior">Interior</string>
|
||||
|
||||
<!-- Onboarding - Task Selection Tabs -->
|
||||
<string name="for_you_tab">For You</string>
|
||||
<string name="browse_tab">Browse</string>
|
||||
|
||||
<!-- Biometric Lock -->
|
||||
<string name="biometric_lock_title">App Locked</string>
|
||||
<string name="biometric_lock_description">Authenticate to unlock honeyDue</string>
|
||||
|
||||
@@ -8,9 +8,11 @@ expect object PostHogAnalytics {
|
||||
fun initialize()
|
||||
fun identify(userId: String, properties: Map<String, Any>? = null)
|
||||
fun capture(event: String, properties: Map<String, Any>? = null)
|
||||
fun captureException(throwable: Throwable, properties: Map<String, Any>? = null)
|
||||
fun screen(screenName: String, properties: Map<String, Any>? = null)
|
||||
fun reset()
|
||||
fun flush()
|
||||
fun setupExceptionHandler()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,8 +33,12 @@ data class Document(
|
||||
val title: String,
|
||||
@SerialName("document_type") val documentType: String,
|
||||
val description: String? = null,
|
||||
@SerialName("file_url") val fileUrl: String? = null, // URL to the file
|
||||
@SerialName("media_url") val mediaUrl: String? = null, // Authenticated endpoint: /api/media/document/{id}
|
||||
// fileUrl: raw storage path (internal). Not included in Go DocumentResponse DTO —
|
||||
// will always be null from the API. Kept for backward compatibility; prefer mediaUrl.
|
||||
@SerialName("file_url") val fileUrl: String? = null,
|
||||
// mediaUrl: authenticated endpoint clients should use (e.g. /api/media/document/{id}).
|
||||
// This is the URL the Go API actually returns for document access.
|
||||
@SerialName("media_url") val mediaUrl: String? = null,
|
||||
@SerialName("file_name") val fileName: String? = null,
|
||||
@SerialName("file_size") val fileSize: Int? = null,
|
||||
@SerialName("mime_type") val mimeType: String? = null,
|
||||
@@ -43,7 +47,7 @@ data class Document(
|
||||
@SerialName("serial_number") val serialNumber: String? = null,
|
||||
val vendor: String? = null,
|
||||
@SerialName("purchase_date") val purchaseDate: String? = null,
|
||||
@SerialName("purchase_price") val purchasePrice: String? = null,
|
||||
@SerialName("purchase_price") val purchasePrice: Double? = null,
|
||||
@SerialName("expiry_date") val expiryDate: String? = null,
|
||||
// Relationships
|
||||
@SerialName("residence_id") val residenceId: Int? = null,
|
||||
@@ -87,7 +91,7 @@ data class DocumentCreateRequest(
|
||||
@SerialName("serial_number") val serialNumber: String? = null,
|
||||
val vendor: String? = null,
|
||||
@SerialName("purchase_date") val purchaseDate: String? = null,
|
||||
@SerialName("purchase_price") val purchasePrice: String? = null,
|
||||
@SerialName("purchase_price") val purchasePrice: Double? = null,
|
||||
@SerialName("expiry_date") val expiryDate: String? = null,
|
||||
// Relationships
|
||||
@SerialName("residence_id") val residenceId: Int,
|
||||
@@ -106,7 +110,7 @@ data class DocumentUpdateRequest(
|
||||
@SerialName("serial_number") val serialNumber: String? = null,
|
||||
val vendor: String? = null,
|
||||
@SerialName("purchase_date") val purchaseDate: String? = null,
|
||||
@SerialName("purchase_price") val purchasePrice: String? = null,
|
||||
@SerialName("purchase_price") val purchasePrice: Double? = null,
|
||||
@SerialName("expiry_date") val expiryDate: String? = null,
|
||||
// Relationships
|
||||
@SerialName("task_id") val taskId: Int? = null
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.tt.honeyDue.models
|
||||
|
||||
/**
|
||||
* Static option lists for home profile pickers.
|
||||
* Each entry is a (apiValue, displayLabel) pair.
|
||||
*/
|
||||
object HomeProfileOptions {
|
||||
val heatingTypes = listOf(
|
||||
"gas_furnace" to "Gas Furnace",
|
||||
"electric" to "Electric",
|
||||
"heat_pump" to "Heat Pump",
|
||||
"boiler" to "Boiler",
|
||||
"radiant" to "Radiant",
|
||||
"wood_stove" to "Wood Stove",
|
||||
"none" to "None"
|
||||
)
|
||||
val coolingTypes = listOf(
|
||||
"central_ac" to "Central AC",
|
||||
"window_unit" to "Window Unit",
|
||||
"mini_split" to "Mini Split",
|
||||
"evaporative" to "Evaporative",
|
||||
"none" to "None"
|
||||
)
|
||||
val waterHeaterTypes = listOf(
|
||||
"tank_gas" to "Tank (Gas)",
|
||||
"tank_electric" to "Tank (Electric)",
|
||||
"tankless" to "Tankless",
|
||||
"solar" to "Solar",
|
||||
"heat_pump_wh" to "Heat Pump"
|
||||
)
|
||||
val roofTypes = listOf(
|
||||
"asphalt_shingle" to "Asphalt Shingle",
|
||||
"metal" to "Metal",
|
||||
"tile" to "Tile",
|
||||
"flat_tpo" to "Flat/TPO",
|
||||
"slate" to "Slate",
|
||||
"wood_shake" to "Wood Shake"
|
||||
)
|
||||
val exteriorTypes = listOf(
|
||||
"vinyl_siding" to "Vinyl Siding",
|
||||
"brick" to "Brick",
|
||||
"stucco" to "Stucco",
|
||||
"wood" to "Wood",
|
||||
"stone" to "Stone",
|
||||
"fiber_cement" to "Fiber Cement"
|
||||
)
|
||||
val flooringTypes = listOf(
|
||||
"hardwood" to "Hardwood",
|
||||
"carpet" to "Carpet",
|
||||
"tile" to "Tile",
|
||||
"laminate" to "Laminate",
|
||||
"vinyl" to "Vinyl"
|
||||
)
|
||||
val landscapingTypes = listOf(
|
||||
"lawn" to "Lawn",
|
||||
"xeriscaping" to "Xeriscaping",
|
||||
"none" to "None"
|
||||
)
|
||||
}
|
||||
@@ -53,6 +53,20 @@ data class ResidenceResponse(
|
||||
@SerialName("is_active") val isActive: Boolean = true,
|
||||
@SerialName("overdue_count") val overdueCount: Int = 0,
|
||||
@SerialName("completion_summary") val completionSummary: CompletionSummary? = null,
|
||||
@SerialName("heating_type") val heatingType: String? = null,
|
||||
@SerialName("cooling_type") val coolingType: String? = null,
|
||||
@SerialName("water_heater_type") val waterHeaterType: String? = null,
|
||||
@SerialName("roof_type") val roofType: String? = null,
|
||||
@SerialName("has_pool") val hasPool: Boolean = false,
|
||||
@SerialName("has_sprinkler_system") val hasSprinklerSystem: Boolean = false,
|
||||
@SerialName("has_septic") val hasSeptic: Boolean = false,
|
||||
@SerialName("has_fireplace") val hasFireplace: Boolean = false,
|
||||
@SerialName("has_garage") val hasGarage: Boolean = false,
|
||||
@SerialName("has_basement") val hasBasement: Boolean = false,
|
||||
@SerialName("has_attic") val hasAttic: Boolean = false,
|
||||
@SerialName("exterior_type") val exteriorType: String? = null,
|
||||
@SerialName("flooring_primary") val flooringPrimary: String? = null,
|
||||
@SerialName("landscaping_type") val landscapingType: String? = null,
|
||||
@SerialName("created_at") val createdAt: String,
|
||||
@SerialName("updated_at") val updatedAt: String
|
||||
) {
|
||||
@@ -94,7 +108,21 @@ data class ResidenceCreateRequest(
|
||||
val description: String? = null,
|
||||
@SerialName("purchase_date") val purchaseDate: String? = null,
|
||||
@SerialName("purchase_price") val purchasePrice: Double? = null,
|
||||
@SerialName("is_primary") val isPrimary: Boolean? = null
|
||||
@SerialName("is_primary") val isPrimary: Boolean? = null,
|
||||
@SerialName("heating_type") val heatingType: String? = null,
|
||||
@SerialName("cooling_type") val coolingType: String? = null,
|
||||
@SerialName("water_heater_type") val waterHeaterType: String? = null,
|
||||
@SerialName("roof_type") val roofType: String? = null,
|
||||
@SerialName("has_pool") val hasPool: Boolean? = null,
|
||||
@SerialName("has_sprinkler_system") val hasSprinklerSystem: Boolean? = null,
|
||||
@SerialName("has_septic") val hasSeptic: Boolean? = null,
|
||||
@SerialName("has_fireplace") val hasFireplace: Boolean? = null,
|
||||
@SerialName("has_garage") val hasGarage: Boolean? = null,
|
||||
@SerialName("has_basement") val hasBasement: Boolean? = null,
|
||||
@SerialName("has_attic") val hasAttic: Boolean? = null,
|
||||
@SerialName("exterior_type") val exteriorType: String? = null,
|
||||
@SerialName("flooring_primary") val flooringPrimary: String? = null,
|
||||
@SerialName("landscaping_type") val landscapingType: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -118,7 +146,21 @@ data class ResidenceUpdateRequest(
|
||||
val description: String? = null,
|
||||
@SerialName("purchase_date") val purchaseDate: String? = null,
|
||||
@SerialName("purchase_price") val purchasePrice: Double? = null,
|
||||
@SerialName("is_primary") val isPrimary: Boolean? = null
|
||||
@SerialName("is_primary") val isPrimary: Boolean? = null,
|
||||
@SerialName("heating_type") val heatingType: String? = null,
|
||||
@SerialName("cooling_type") val coolingType: String? = null,
|
||||
@SerialName("water_heater_type") val waterHeaterType: String? = null,
|
||||
@SerialName("roof_type") val roofType: String? = null,
|
||||
@SerialName("has_pool") val hasPool: Boolean? = null,
|
||||
@SerialName("has_sprinkler_system") val hasSprinklerSystem: Boolean? = null,
|
||||
@SerialName("has_septic") val hasSeptic: Boolean? = null,
|
||||
@SerialName("has_fireplace") val hasFireplace: Boolean? = null,
|
||||
@SerialName("has_garage") val hasGarage: Boolean? = null,
|
||||
@SerialName("has_basement") val hasBasement: Boolean? = null,
|
||||
@SerialName("has_attic") val hasAttic: Boolean? = null,
|
||||
@SerialName("exterior_type") val exteriorType: String? = null,
|
||||
@SerialName("flooring_primary") val flooringPrimary: String? = null,
|
||||
@SerialName("landscaping_type") val landscapingType: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
package com.tt.honeyDue.ui.screens.onboarding
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tt.honeyDue.models.HomeProfileOptions
|
||||
import com.tt.honeyDue.ui.theme.*
|
||||
import com.tt.honeyDue.viewmodel.OnboardingViewModel
|
||||
import honeydue.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun OnboardingHomeProfileContent(
|
||||
viewModel: OnboardingViewModel,
|
||||
onContinue: () -> Unit,
|
||||
onSkip: () -> Unit
|
||||
) {
|
||||
val heatingType by viewModel.heatingType.collectAsState()
|
||||
val coolingType by viewModel.coolingType.collectAsState()
|
||||
val waterHeaterType by viewModel.waterHeaterType.collectAsState()
|
||||
val roofType by viewModel.roofType.collectAsState()
|
||||
val hasPool by viewModel.hasPool.collectAsState()
|
||||
val hasSprinklerSystem by viewModel.hasSprinklerSystem.collectAsState()
|
||||
val hasSeptic by viewModel.hasSeptic.collectAsState()
|
||||
val hasFireplace by viewModel.hasFireplace.collectAsState()
|
||||
val hasGarage by viewModel.hasGarage.collectAsState()
|
||||
val hasBasement by viewModel.hasBasement.collectAsState()
|
||||
val hasAttic by viewModel.hasAttic.collectAsState()
|
||||
val exteriorType by viewModel.exteriorType.collectAsState()
|
||||
val flooringPrimary by viewModel.flooringPrimary.collectAsState()
|
||||
val landscapingType by viewModel.landscapingType.collectAsState()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentPadding = PaddingValues(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md)
|
||||
) {
|
||||
// Header
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
OrganicIconContainer(
|
||||
icon = Icons.Default.Tune,
|
||||
size = 80.dp,
|
||||
iconSize = 40.dp,
|
||||
gradientColors = listOf(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
),
|
||||
contentDescription = null
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.onboarding_home_profile_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.onboarding_home_profile_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
|
||||
}
|
||||
}
|
||||
|
||||
// Systems section
|
||||
item {
|
||||
ProfileSectionHeader(
|
||||
icon = Icons.Default.Settings,
|
||||
title = stringResource(Res.string.onboarding_home_profile_systems)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
|
||||
}
|
||||
|
||||
item {
|
||||
OptionDropdownChips(
|
||||
label = "Heating",
|
||||
options = HomeProfileOptions.heatingTypes,
|
||||
selectedValue = heatingType,
|
||||
onSelect = { viewModel.setHeatingType(it) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.md))
|
||||
}
|
||||
|
||||
item {
|
||||
OptionDropdownChips(
|
||||
label = "Cooling",
|
||||
options = HomeProfileOptions.coolingTypes,
|
||||
selectedValue = coolingType,
|
||||
onSelect = { viewModel.setCoolingType(it) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.md))
|
||||
}
|
||||
|
||||
item {
|
||||
OptionDropdownChips(
|
||||
label = "Water Heater",
|
||||
options = HomeProfileOptions.waterHeaterTypes,
|
||||
selectedValue = waterHeaterType,
|
||||
onSelect = { viewModel.setWaterHeaterType(it) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
|
||||
}
|
||||
|
||||
// Features section
|
||||
item {
|
||||
ProfileSectionHeader(
|
||||
icon = Icons.Default.Star,
|
||||
title = stringResource(Res.string.onboarding_home_profile_features)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
|
||||
}
|
||||
|
||||
item {
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm),
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
|
||||
) {
|
||||
ToggleChip(label = "Pool", selected = hasPool, onToggle = { viewModel.setHasPool(!hasPool) })
|
||||
ToggleChip(label = "Sprinkler System", selected = hasSprinklerSystem, onToggle = { viewModel.setHasSprinklerSystem(!hasSprinklerSystem) })
|
||||
ToggleChip(label = "Fireplace", selected = hasFireplace, onToggle = { viewModel.setHasFireplace(!hasFireplace) })
|
||||
ToggleChip(label = "Garage", selected = hasGarage, onToggle = { viewModel.setHasGarage(!hasGarage) })
|
||||
ToggleChip(label = "Basement", selected = hasBasement, onToggle = { viewModel.setHasBasement(!hasBasement) })
|
||||
ToggleChip(label = "Attic", selected = hasAttic, onToggle = { viewModel.setHasAttic(!hasAttic) })
|
||||
ToggleChip(label = "Septic", selected = hasSeptic, onToggle = { viewModel.setHasSeptic(!hasSeptic) })
|
||||
}
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
|
||||
}
|
||||
|
||||
// Exterior section
|
||||
item {
|
||||
ProfileSectionHeader(
|
||||
icon = Icons.Default.Roofing,
|
||||
title = stringResource(Res.string.onboarding_home_profile_exterior)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
|
||||
}
|
||||
|
||||
item {
|
||||
OptionDropdownChips(
|
||||
label = "Roof Type",
|
||||
options = HomeProfileOptions.roofTypes,
|
||||
selectedValue = roofType,
|
||||
onSelect = { viewModel.setRoofType(it) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.md))
|
||||
}
|
||||
|
||||
item {
|
||||
OptionDropdownChips(
|
||||
label = "Exterior",
|
||||
options = HomeProfileOptions.exteriorTypes,
|
||||
selectedValue = exteriorType,
|
||||
onSelect = { viewModel.setExteriorType(it) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
|
||||
}
|
||||
|
||||
// Interior section
|
||||
item {
|
||||
ProfileSectionHeader(
|
||||
icon = Icons.Default.Weekend,
|
||||
title = stringResource(Res.string.onboarding_home_profile_interior)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
|
||||
}
|
||||
|
||||
item {
|
||||
OptionDropdownChips(
|
||||
label = "Flooring",
|
||||
options = HomeProfileOptions.flooringTypes,
|
||||
selectedValue = flooringPrimary,
|
||||
onSelect = { viewModel.setFlooringPrimary(it) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.md))
|
||||
}
|
||||
|
||||
item {
|
||||
OptionDropdownChips(
|
||||
label = "Landscaping",
|
||||
options = HomeProfileOptions.landscapingTypes,
|
||||
selectedValue = landscapingType,
|
||||
onSelect = { viewModel.setLandscapingType(it) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(100.dp)) // Space for bottom button
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom action area
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shadowElevation = 8.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(OrganicSpacing.lg)
|
||||
) {
|
||||
OrganicPrimaryButton(
|
||||
text = stringResource(Res.string.onboarding_continue),
|
||||
onClick = onContinue,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
icon = Icons.Default.ArrowForward
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
|
||||
|
||||
TextButton(
|
||||
onClick = onSkip,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.onboarding_skip),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileSectionHeader(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
title: String
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OptionDropdownChips(
|
||||
label: String,
|
||||
options: List<Pair<String, String>>,
|
||||
selectedValue: String?,
|
||||
onSelect: (String?) -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.xs))
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.xs),
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.xs)
|
||||
) {
|
||||
options.forEach { (apiValue, displayLabel) ->
|
||||
val isSelected = selectedValue == apiValue
|
||||
FilterChip(
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
onSelect(if (isSelected) null else apiValue)
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = displayLabel,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(OrganicRadius.lg),
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
|
||||
selectedLabelColor = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
border = BorderStroke(
|
||||
width = 1.dp,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
|
||||
else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleChip(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onToggle: () -> Unit
|
||||
) {
|
||||
val containerColor by animateColorAsState(
|
||||
targetValue = if (selected) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
},
|
||||
label = "toggleChipColor"
|
||||
)
|
||||
val contentColor by animateColorAsState(
|
||||
targetValue = if (selected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
label = "toggleChipContentColor"
|
||||
)
|
||||
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = onToggle,
|
||||
label = {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal
|
||||
)
|
||||
},
|
||||
leadingIcon = if (selected) {
|
||||
{
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
} else null,
|
||||
shape = RoundedCornerShape(OrganicRadius.xl),
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
containerColor = containerColor,
|
||||
labelColor = contentColor,
|
||||
iconColor = contentColor,
|
||||
selectedContainerColor = containerColor,
|
||||
selectedLabelColor = contentColor,
|
||||
selectedLeadingIconColor = contentColor
|
||||
),
|
||||
border = FilterChipDefaults.filterChipBorder(
|
||||
borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
|
||||
selectedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
|
||||
enabled = true,
|
||||
selected = selected
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -103,8 +103,7 @@ fun OnboardingScreen(
|
||||
if (userIntent == OnboardingIntent.JOIN_EXISTING) {
|
||||
viewModel.goToStep(OnboardingStep.JOIN_RESIDENCE)
|
||||
} else {
|
||||
viewModel.createResidence()
|
||||
viewModel.goToStep(OnboardingStep.RESIDENCE_LOCATION)
|
||||
viewModel.goToStep(OnboardingStep.HOME_PROFILE)
|
||||
}
|
||||
} else {
|
||||
viewModel.nextStep()
|
||||
@@ -118,35 +117,42 @@ fun OnboardingScreen(
|
||||
if (userIntent == OnboardingIntent.JOIN_EXISTING) {
|
||||
viewModel.goToStep(OnboardingStep.JOIN_RESIDENCE)
|
||||
} else {
|
||||
viewModel.createResidence()
|
||||
viewModel.goToStep(OnboardingStep.RESIDENCE_LOCATION)
|
||||
viewModel.goToStep(OnboardingStep.HOME_PROFILE)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
OnboardingStep.JOIN_RESIDENCE -> OnboardingJoinResidenceContent(
|
||||
viewModel = viewModel,
|
||||
onJoined = { viewModel.nextStep() }
|
||||
onJoined = { viewModel.completeOnboarding() }
|
||||
)
|
||||
|
||||
OnboardingStep.RESIDENCE_LOCATION -> OnboardingLocationContent(
|
||||
OnboardingStep.RESIDENCE_LOCATION -> {
|
||||
// Location step removed — skip to home profile if we land here
|
||||
LaunchedEffect(Unit) { viewModel.goToStep(OnboardingStep.HOME_PROFILE) }
|
||||
}
|
||||
|
||||
OnboardingStep.HOME_PROFILE -> OnboardingHomeProfileContent(
|
||||
viewModel = viewModel,
|
||||
onLocationDetected = { zip ->
|
||||
viewModel.loadRegionalTemplates(zip)
|
||||
onContinue = {
|
||||
viewModel.createResidence()
|
||||
viewModel.nextStep()
|
||||
},
|
||||
onSkip = { viewModel.nextStep() }
|
||||
onSkip = {
|
||||
viewModel.createResidence()
|
||||
viewModel.skipStep()
|
||||
}
|
||||
)
|
||||
|
||||
OnboardingStep.FIRST_TASK -> OnboardingFirstTaskContent(
|
||||
viewModel = viewModel,
|
||||
onTasksAdded = { viewModel.nextStep() }
|
||||
onTasksAdded = { viewModel.completeOnboarding() }
|
||||
)
|
||||
|
||||
OnboardingStep.SUBSCRIPTION_UPSELL -> OnboardingSubscriptionContent(
|
||||
onSubscribe = { viewModel.completeOnboarding() },
|
||||
onSkip = { viewModel.completeOnboarding() }
|
||||
)
|
||||
OnboardingStep.SUBSCRIPTION_UPSELL -> {
|
||||
// Subscription removed from onboarding — app is free
|
||||
LaunchedEffect(Unit) { viewModel.completeOnboarding() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,6 +170,7 @@ private fun OnboardingNavigationBar(
|
||||
OnboardingStep.WELCOME,
|
||||
OnboardingStep.JOIN_RESIDENCE,
|
||||
OnboardingStep.RESIDENCE_LOCATION,
|
||||
OnboardingStep.HOME_PROFILE,
|
||||
OnboardingStep.FIRST_TASK,
|
||||
OnboardingStep.SUBSCRIPTION_UPSELL -> false
|
||||
else -> true
|
||||
@@ -172,7 +179,7 @@ private fun OnboardingNavigationBar(
|
||||
val showSkipButton = when (currentStep) {
|
||||
OnboardingStep.VALUE_PROPS,
|
||||
OnboardingStep.JOIN_RESIDENCE,
|
||||
OnboardingStep.RESIDENCE_LOCATION,
|
||||
OnboardingStep.HOME_PROFILE,
|
||||
OnboardingStep.FIRST_TASK,
|
||||
OnboardingStep.SUBSCRIPTION_UPSELL -> true
|
||||
else -> false
|
||||
@@ -182,6 +189,7 @@ private fun OnboardingNavigationBar(
|
||||
OnboardingStep.WELCOME,
|
||||
OnboardingStep.JOIN_RESIDENCE,
|
||||
OnboardingStep.RESIDENCE_LOCATION,
|
||||
OnboardingStep.HOME_PROFILE,
|
||||
OnboardingStep.FIRST_TASK,
|
||||
OnboardingStep.SUBSCRIPTION_UPSELL -> false
|
||||
else -> true
|
||||
@@ -195,6 +203,7 @@ private fun OnboardingNavigationBar(
|
||||
OnboardingStep.VERIFY_EMAIL -> 4
|
||||
OnboardingStep.JOIN_RESIDENCE -> 4
|
||||
OnboardingStep.RESIDENCE_LOCATION -> 4
|
||||
OnboardingStep.HOME_PROFILE -> 4
|
||||
OnboardingStep.FIRST_TASK -> 4
|
||||
OnboardingStep.SUBSCRIPTION_UPSELL -> 4
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ actual object PostHogAnalytics {
|
||||
// iOS uses Swift PostHogAnalytics.shared.capture() directly
|
||||
}
|
||||
|
||||
actual fun captureException(throwable: Throwable, properties: Map<String, Any>?) {
|
||||
// iOS exception capture is done in Swift via AnalyticsManager
|
||||
}
|
||||
|
||||
actual fun screen(screenName: String, properties: Map<String, Any>?) {
|
||||
// iOS uses Swift PostHogAnalytics.shared.screen() directly
|
||||
}
|
||||
@@ -29,4 +33,8 @@ actual object PostHogAnalytics {
|
||||
actual fun flush() {
|
||||
// iOS uses Swift PostHogAnalytics.shared.flush() directly
|
||||
}
|
||||
|
||||
actual fun setupExceptionHandler() {
|
||||
// iOS exception handler is set up in Swift via AnalyticsManager.setupExceptionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ actual object PostHogAnalytics {
|
||||
// No-op for web
|
||||
}
|
||||
|
||||
actual fun captureException(throwable: Throwable, properties: Map<String, Any>?) {
|
||||
// No-op for web
|
||||
}
|
||||
|
||||
actual fun screen(screenName: String, properties: Map<String, Any>?) {
|
||||
// No-op for web
|
||||
}
|
||||
@@ -28,4 +32,8 @@ actual object PostHogAnalytics {
|
||||
actual fun flush() {
|
||||
// No-op for web
|
||||
}
|
||||
|
||||
actual fun setupExceptionHandler() {
|
||||
// No-op for web
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ actual object PostHogAnalytics {
|
||||
// No-op for desktop
|
||||
}
|
||||
|
||||
actual fun captureException(throwable: Throwable, properties: Map<String, Any>?) {
|
||||
// No-op for desktop
|
||||
}
|
||||
|
||||
actual fun screen(screenName: String, properties: Map<String, Any>?) {
|
||||
// No-op for desktop
|
||||
}
|
||||
@@ -28,4 +32,8 @@ actual object PostHogAnalytics {
|
||||
actual fun flush() {
|
||||
// No-op for desktop
|
||||
}
|
||||
|
||||
actual fun setupExceptionHandler() {
|
||||
// No-op for desktop
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ actual object PostHogAnalytics {
|
||||
// No-op for web
|
||||
}
|
||||
|
||||
actual fun captureException(throwable: Throwable, properties: Map<String, Any>?) {
|
||||
// No-op for web
|
||||
}
|
||||
|
||||
actual fun screen(screenName: String, properties: Map<String, Any>?) {
|
||||
// No-op for web
|
||||
}
|
||||
@@ -28,4 +32,8 @@ actual object PostHogAnalytics {
|
||||
actual fun flush() {
|
||||
// No-op for web
|
||||
}
|
||||
|
||||
actual fun setupExceptionHandler() {
|
||||
// No-op for web
|
||||
}
|
||||
}
|
||||
|
||||
32
iosApp/CITests.xctestplan
Normal file
32
iosApp/CITests.xctestplan
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"configurations" : [
|
||||
{
|
||||
"id" : "D4000004-CI00-4D4D-BFDC-000000000004",
|
||||
"name" : "CI Configuration",
|
||||
"options" : {
|
||||
|
||||
}
|
||||
}
|
||||
],
|
||||
"defaultOptions" : {
|
||||
"testTimeoutsEnabled" : true,
|
||||
"defaultTestExecutionTimeAllowance" : 300,
|
||||
"maximumTestRepetitions" : 1,
|
||||
"targetForVariableExpansion" : {
|
||||
"containerPath" : "container:honeyDue.xcodeproj",
|
||||
"identifier" : "D4ADB376A7A4CFB73469E173",
|
||||
"name" : "HoneyDue"
|
||||
}
|
||||
},
|
||||
"testTargets" : [
|
||||
{
|
||||
"parallelizable" : true,
|
||||
"target" : {
|
||||
"containerPath" : "container:honeyDue.xcodeproj",
|
||||
"identifier" : "1CBF1BEC2ECD9768001BF56C",
|
||||
"name" : "HoneyDueUITests"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 1
|
||||
}
|
||||
31
iosApp/CleanupTests.xctestplan
Normal file
31
iosApp/CleanupTests.xctestplan
Normal 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
|
||||
}
|
||||
@@ -42,7 +42,9 @@ extension DataLayerTests {
|
||||
iconAndroid: "",
|
||||
tags: tags,
|
||||
displayOrder: 0,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
regionId: nil,
|
||||
regionName: nil
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -191,19 +191,35 @@ extension XCUIElement {
|
||||
// SecureTextFields may trigger iOS strong password suggestion dialog
|
||||
// which blocks the regular keyboard. Handle them with a dedicated path.
|
||||
if elementType == .secureTextField {
|
||||
// Dismiss any open keyboard first — iOS 26 fails to transfer focus
|
||||
// from a TextField to a SecureTextField if the keyboard is already up.
|
||||
if app.keyboards.firstMatch.exists {
|
||||
let navBar = app.navigationBars.firstMatch
|
||||
if navBar.exists {
|
||||
navBar.tap()
|
||||
}
|
||||
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2)
|
||||
}
|
||||
|
||||
tap()
|
||||
// Dismiss "Choose My Own Password" or "Not Now" if iOS suggests a strong password
|
||||
let chooseOwn = app.buttons["Choose My Own Password"]
|
||||
if chooseOwn.waitForExistence(timeout: 1) {
|
||||
if chooseOwn.waitForExistence(timeout: 0.5) {
|
||||
chooseOwn.tap()
|
||||
} else {
|
||||
let notNow = app.buttons["Not Now"]
|
||||
if notNow.exists && notNow.isHittable { notNow.tap() }
|
||||
}
|
||||
if app.keyboards.firstMatch.waitForExistence(timeout: 2) {
|
||||
// Wait for keyboard after tapping SecureTextField
|
||||
if !app.keyboards.firstMatch.waitForExistence(timeout: 5) {
|
||||
// Retry tap — first tap may not have acquired focus
|
||||
tap()
|
||||
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
|
||||
}
|
||||
if app.keyboards.firstMatch.exists {
|
||||
typeText(text)
|
||||
} else {
|
||||
app.typeText(text)
|
||||
XCTFail("Keyboard did not appear after tapping SecureTextField: \(self)", file: file, line: line)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -257,8 +257,6 @@ struct RegisterScreenObject {
|
||||
|
||||
private var usernameField: XCUIElement { app.textFields[UITestID.Auth.registerUsernameField] }
|
||||
private var emailField: XCUIElement { app.textFields[UITestID.Auth.registerEmailField] }
|
||||
private var passwordField: XCUIElement { app.secureTextFields[UITestID.Auth.registerPasswordField] }
|
||||
private var confirmPasswordField: XCUIElement { app.secureTextFields[UITestID.Auth.registerConfirmPasswordField] }
|
||||
private var registerButton: XCUIElement { app.buttons[UITestID.Auth.registerButton] }
|
||||
private var cancelButton: XCUIElement { app.buttons[UITestID.Auth.registerCancelButton] }
|
||||
|
||||
@@ -268,30 +266,32 @@ struct RegisterScreenObject {
|
||||
}
|
||||
|
||||
func fill(username: String, email: String, password: String) {
|
||||
func advanceToNextField() {
|
||||
let keys = ["Next", "Return", "return", "Done", "done"]
|
||||
for key in keys {
|
||||
let button = app.keyboards.buttons[key]
|
||||
if button.waitForExistence(timeout: 1) && button.isHittable {
|
||||
button.tap()
|
||||
return
|
||||
}
|
||||
// iOS 26 bug: SecureTextField won't gain keyboard focus when tapped directly.
|
||||
// Workaround: toggle password visibility first to convert SecureField → TextField,
|
||||
// then use focusAndType() on all regular TextFields.
|
||||
usernameField.waitForExistenceOrFail(timeout: 10)
|
||||
|
||||
// Scroll down to reveal the password toggle buttons (they're below the fold)
|
||||
let scrollView = app.scrollViews.firstMatch
|
||||
if scrollView.exists { scrollView.swipeUp() }
|
||||
|
||||
// Toggle both password visibility buttons (converts SecureField → TextField)
|
||||
let toggleButtons = app.buttons.matching(NSPredicate(format: "label == 'Toggle password visibility'"))
|
||||
for i in 0..<toggleButtons.count {
|
||||
let toggle = toggleButtons.element(boundBy: i)
|
||||
if toggle.exists && toggle.isHittable {
|
||||
toggle.tap()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -100,18 +100,18 @@ final class Suite5_TaskTests: AuthenticatedUITestCase {
|
||||
// Wait for form to dismiss
|
||||
_ = saveButton.waitForNonExistence(timeout: navigationTimeout)
|
||||
|
||||
// Verify task appears in list (may need refresh or scroll in kanban view)
|
||||
let newTask = app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", taskTitle)).firstMatch
|
||||
if !newTask.waitForExistence(timeout: navigationTimeout) {
|
||||
pullToRefresh()
|
||||
}
|
||||
XCTAssertTrue(newTask.waitForExistence(timeout: navigationTimeout), "New task '\(taskTitle)' should appear in the list")
|
||||
|
||||
// Track for cleanup
|
||||
// Verify task was created via API (also gives the server time to process)
|
||||
if let items = TestAccountAPIClient.listTasks(token: session.token),
|
||||
let created = items.first(where: { $0.title.contains(taskTitle) }) {
|
||||
cleaner.trackTask(created.id)
|
||||
}
|
||||
|
||||
// Navigate to tasks tab and refresh to pick up the newly created task
|
||||
navigateToTasks()
|
||||
refreshTasks()
|
||||
let taskListScreen = TaskListScreen(app: app)
|
||||
let newTask = taskListScreen.findTask(title: taskTitle)
|
||||
XCTAssertTrue(newTask.waitForExistence(timeout: loginTimeout), "New task '\(taskTitle)' should appear in the list")
|
||||
}
|
||||
|
||||
// MARK: - 4. View Details
|
||||
@@ -133,24 +133,23 @@ final class Suite5_TaskTests: AuthenticatedUITestCase {
|
||||
saveButton.tap()
|
||||
_ = saveButton.waitForNonExistence(timeout: navigationTimeout)
|
||||
|
||||
// Find and tap the task (may need refresh)
|
||||
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", taskTitle)).firstMatch
|
||||
if !taskCard.waitForExistence(timeout: navigationTimeout) {
|
||||
pullToRefresh()
|
||||
}
|
||||
taskCard.waitForExistenceOrFail(timeout: navigationTimeout, message: "Created task should appear in list")
|
||||
|
||||
// Verify task was created via API (also gives the server time to process)
|
||||
if let items = TestAccountAPIClient.listTasks(token: session.token),
|
||||
let created = items.first(where: { $0.title.contains(taskTitle) }) {
|
||||
cleaner.trackTask(created.id)
|
||||
}
|
||||
|
||||
taskCard.tap()
|
||||
// Navigate to tasks tab and refresh to pick up the newly created task
|
||||
navigateToTasks()
|
||||
refreshTasks()
|
||||
let taskListScreen = TaskListScreen(app: app)
|
||||
let taskCard = taskListScreen.findTask(title: taskTitle)
|
||||
taskCard.waitForExistenceOrFail(timeout: loginTimeout, message: "Created task should appear in list")
|
||||
|
||||
// After tapping a task, the app should show task details or actions.
|
||||
// The navigation bar title or a detail view element should appear.
|
||||
let navBar = app.navigationBars.firstMatch
|
||||
XCTAssertTrue(navBar.waitForExistence(timeout: navigationTimeout), "Task detail view should load after tap")
|
||||
// Verify the task card is accessible and the actions menu exists
|
||||
// (There is no task detail screen — cards are self-contained with a context menu)
|
||||
let actionsMenu = app.buttons["Task actions"].firstMatch
|
||||
XCTAssertTrue(actionsMenu.waitForExistence(timeout: navigationTimeout), "Task actions menu should be accessible")
|
||||
}
|
||||
|
||||
// MARK: - 5. Navigation
|
||||
|
||||
@@ -22,9 +22,25 @@ final class Suite6_ComprehensiveTaskTests: AuthenticatedUITestCase {
|
||||
|
||||
// Test data tracking
|
||||
var createdTaskTitles: [String] = []
|
||||
private static var hasCleanedStaleData = false
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
|
||||
// Dismiss any open form from previous test
|
||||
let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton].firstMatch
|
||||
if cancelButton.exists { cancelButton.tap() }
|
||||
|
||||
// One-time cleanup of stale tasks from previous test runs
|
||||
if !Self.hasCleanedStaleData {
|
||||
Self.hasCleanedStaleData = true
|
||||
if let stale = TestAccountAPIClient.listTasks(token: session.token) {
|
||||
for task in stale {
|
||||
_ = TestAccountAPIClient.deleteTask(token: session.token, id: task.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure at least one residence exists (task add button requires it)
|
||||
if let residences = TestAccountAPIClient.listResidences(token: session.token),
|
||||
residences.isEmpty {
|
||||
@@ -109,6 +125,9 @@ final class Suite6_ComprehensiveTaskTests: AuthenticatedUITestCase {
|
||||
cleaner.trackTask(created.id)
|
||||
}
|
||||
|
||||
// Navigate to tasks tab to trigger list refresh and reset scroll position
|
||||
navigateToTasks()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -272,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,21 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
||||
|
||||
// Test data tracking
|
||||
var createdContractorNames: [String] = []
|
||||
private static var hasCleanedStaleData = false
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
|
||||
// One-time cleanup of stale contractors from previous test runs
|
||||
if !Self.hasCleanedStaleData {
|
||||
Self.hasCleanedStaleData = true
|
||||
if let stale = TestAccountAPIClient.listContractors(token: session.token) {
|
||||
for contractor in stale {
|
||||
_ = TestAccountAPIClient.deleteContractor(token: session.token, id: contractor.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dismiss any open form from previous test
|
||||
let cancelButton = app.buttons[AccessibilityIdentifiers.Contractor.formCancelButton].firstMatch
|
||||
if cancelButton.exists { cancelButton.tap() }
|
||||
@@ -133,6 +144,9 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
||||
let created = items.first(where: { $0.name.contains(name) }) {
|
||||
cleaner.trackContractor(created.id)
|
||||
}
|
||||
|
||||
// Navigate to contractors tab to trigger list refresh and reset scroll position
|
||||
navigateToContractors()
|
||||
}
|
||||
|
||||
private func findContractor(name: String, scrollIfNeeded: Bool = true) -> XCUIElement {
|
||||
@@ -248,9 +262,10 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
||||
}
|
||||
|
||||
for (index, specialty) in specialties.enumerated() {
|
||||
navigateToContractors()
|
||||
let contractorName = "\(specialty) Expert \(timestamp)_\(index)"
|
||||
let contractor = findContractor(name: contractorName)
|
||||
XCTAssertTrue(contractor.exists, "\(specialty) contractor should exist in list")
|
||||
XCTAssertTrue(contractor.waitForExistence(timeout: 10), "\(specialty) contractor should exist in list")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,9 +305,10 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
||||
}
|
||||
|
||||
for (index, (_, format)) in phoneFormats.enumerated() {
|
||||
navigateToContractors()
|
||||
let contractorName = "\(format) Phone \(timestamp)_\(index)"
|
||||
let contractor = findContractor(name: contractorName)
|
||||
XCTAssertTrue(contractor.exists, "Contractor with \(format) phone should exist")
|
||||
XCTAssertTrue(contractor.waitForExistence(timeout: 10), "Contractor with \(format) phone should exist")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,18 +128,35 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
||||
}
|
||||
|
||||
private func submitForm(file: StaticString = #filePath, line: UInt = #line) {
|
||||
// Dismiss keyboard so submit button is visible
|
||||
dismissKeyboard()
|
||||
// Dismiss keyboard by tapping outside form fields
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15)).tap()
|
||||
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3)
|
||||
|
||||
// If keyboard still showing (can happen with long text / autocorrect), try Return key
|
||||
if app.keyboards.firstMatch.exists {
|
||||
app.typeText("\n")
|
||||
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2)
|
||||
}
|
||||
|
||||
let submitButton = docForm.saveButton
|
||||
if !submitButton.exists || !submitButton.isHittable {
|
||||
app.swipeUp()
|
||||
_ = submitButton.waitForExistence(timeout: defaultTimeout)
|
||||
_ = submitButton.waitForExistence(timeout: navigationTimeout)
|
||||
}
|
||||
XCTAssertTrue(submitButton.exists && submitButton.isEnabled, "Submit button should exist and be enabled", file: file, line: line)
|
||||
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() {
|
||||
|
||||
46
iosApp/ParallelTests.xctestplan
Normal file
46
iosApp/ParallelTests.xctestplan
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"configurations" : [
|
||||
{
|
||||
"id" : "B2000002-PARA-4B2B-BFDC-000000000002",
|
||||
"name" : "Parallel Configuration",
|
||||
"options" : {
|
||||
|
||||
}
|
||||
}
|
||||
],
|
||||
"defaultOptions" : {
|
||||
"targetForVariableExpansion" : {
|
||||
"containerPath" : "container:honeyDue.xcodeproj",
|
||||
"identifier" : "D4ADB376A7A4CFB73469E173",
|
||||
"name" : "HoneyDue"
|
||||
}
|
||||
},
|
||||
"testTargets" : [
|
||||
{
|
||||
"parallelizable" : true,
|
||||
"selectedTests" : [
|
||||
"AuthCriticalPathTests",
|
||||
"NavigationCriticalPathTests",
|
||||
"SmokeTests",
|
||||
"SimpleLoginTest",
|
||||
"Suite0_OnboardingRebuildTests",
|
||||
"Suite1_RegistrationTests",
|
||||
"Suite2_AuthenticationRebuildTests",
|
||||
"Suite3_ResidenceRebuildTests",
|
||||
"Suite4_ComprehensiveResidenceTests",
|
||||
"Suite5_TaskTests",
|
||||
"Suite6_ComprehensiveTaskTests",
|
||||
"Suite7_ContractorTests",
|
||||
"Suite8_DocumentWarrantyTests",
|
||||
"Suite9_IntegrationE2ETests",
|
||||
"Suite10_ComprehensiveE2ETests"
|
||||
],
|
||||
"target" : {
|
||||
"containerPath" : "container:honeyDue.xcodeproj",
|
||||
"identifier" : "1CBF1BEC2ECD9768001BF56C",
|
||||
"name" : "HoneyDueUITests"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 1
|
||||
}
|
||||
31
iosApp/SeedTests.xctestplan
Normal file
31
iosApp/SeedTests.xctestplan
Normal 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
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -38,6 +38,7 @@ struct AuthenticatedImage: View {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: contentMode)
|
||||
.accessibilityLabel("Image")
|
||||
case .failure:
|
||||
errorView
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ struct TaskSummaryCard: View {
|
||||
.font(.headline)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
|
||||
ForEach(filteredCategories, id: \.name) { category in
|
||||
TaskCategoryRow(category: category)
|
||||
@@ -80,6 +81,7 @@ struct TaskCategoryRow: View {
|
||||
.padding(12)
|
||||
.background(categoryColor.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,11 +76,13 @@ struct ContractorCard: View {
|
||||
.foregroundColor(contractor.isFavorite ? Color.appAccent : Color.appTextSecondary.opacity(0.7))
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.accessibilityLabel(contractor.isFavorite ? "Remove \(contractor.name) from favorites" : "Add \(contractor.name) to favorites")
|
||||
|
||||
// Chevron
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appTextSecondary.opacity(0.7))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
.background(Color.appBackgroundSecondary)
|
||||
|
||||
@@ -64,6 +64,7 @@ struct ContractorDetailView: View {
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.menuButton)
|
||||
}
|
||||
.accessibilityLabel("Contractor actions")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,6 +176,7 @@ struct ContractorDetailView: View {
|
||||
Text(contractor.name)
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
|
||||
// Company
|
||||
if let company = contractor.company {
|
||||
|
||||
@@ -79,6 +79,7 @@ struct ContractorFormSheet: View {
|
||||
}
|
||||
} header: {
|
||||
Text(L10n.Contractors.basicInfoSection)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
} footer: {
|
||||
Text(L10n.Contractors.basicInfoFooter)
|
||||
.font(.caption)
|
||||
@@ -155,6 +156,7 @@ struct ContractorFormSheet: View {
|
||||
}
|
||||
} header: {
|
||||
Text(L10n.Contractors.contactInfoSection)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
}
|
||||
.sectionBackground()
|
||||
|
||||
@@ -226,6 +228,7 @@ struct ContractorFormSheet: View {
|
||||
}
|
||||
} header: {
|
||||
Text(L10n.Contractors.addressSection)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
}
|
||||
.sectionBackground()
|
||||
|
||||
|
||||
@@ -19,9 +19,10 @@ class ContractorViewModel: ObservableObject {
|
||||
|
||||
// MARK: - Private Properties
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
/// Guards against redundant detail reloads immediately after a mutation that already
|
||||
/// set selectedContractor from its response.
|
||||
private var suppressNextDetailReload = false
|
||||
/// Timestamp of the last mutation that already set selectedContractor from its response.
|
||||
/// Used to suppress redundant detail reloads within 1 second of a mutation.
|
||||
/// Unlike a boolean flag, this naturally expires and can never get stuck.
|
||||
private var lastMutationTime: Date?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
@@ -39,8 +40,10 @@ class ContractorViewModel: ObservableObject {
|
||||
if let self = self,
|
||||
let currentId = self.selectedContractor?.id,
|
||||
contractors.contains(where: { $0.id == currentId }) {
|
||||
if self.suppressNextDetailReload {
|
||||
self.suppressNextDetailReload = false
|
||||
// Skip reload if a mutation just updated selectedContractor within the last second
|
||||
if let mutationTime = self.lastMutationTime,
|
||||
Date().timeIntervalSince(mutationTime) < 1.0 {
|
||||
// Mutation was recent, no need to reload
|
||||
} else {
|
||||
self.reloadSelectedContractorQuietly(id: currentId)
|
||||
}
|
||||
@@ -120,7 +123,7 @@ class ContractorViewModel: ObservableObject {
|
||||
self.successMessage = "Contractor added successfully"
|
||||
self.isCreating = false
|
||||
// Update selectedContractor with the newly created contractor
|
||||
self.suppressNextDetailReload = true
|
||||
self.lastMutationTime = Date()
|
||||
self.selectedContractor = success.data
|
||||
completion(true)
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
@@ -152,7 +155,7 @@ class ContractorViewModel: ObservableObject {
|
||||
self.successMessage = "Contractor updated successfully"
|
||||
self.isUpdating = false
|
||||
// Update selectedContractor immediately so detail views stay fresh
|
||||
self.suppressNextDetailReload = true
|
||||
self.lastMutationTime = Date()
|
||||
self.selectedContractor = success.data
|
||||
completion(true)
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
@@ -209,7 +212,7 @@ class ContractorViewModel: ObservableObject {
|
||||
|
||||
if let success = result as? ApiResultSuccess<Contractor> {
|
||||
// Update selectedContractor immediately so detail views stay fresh
|
||||
self.suppressNextDetailReload = true
|
||||
self.lastMutationTime = Date()
|
||||
self.selectedContractor = success.data
|
||||
completion(true)
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
|
||||
@@ -119,6 +119,7 @@ struct ContractorsListView: View {
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(showFavoritesOnly ? Color.appAccent : Color.appTextSecondary)
|
||||
}
|
||||
.accessibilityLabel(showFavoritesOnly ? "Show all contractors" : "Show favorites only")
|
||||
|
||||
// Specialty Filter
|
||||
Menu {
|
||||
@@ -142,6 +143,7 @@ struct ContractorsListView: View {
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(selectedSpecialty != nil ? Color.appPrimary : Color.appTextSecondary)
|
||||
}
|
||||
.accessibilityLabel("Filter by specialty")
|
||||
|
||||
// Add Button
|
||||
Button(action: {
|
||||
@@ -156,6 +158,7 @@ struct ContractorsListView: View {
|
||||
OrganicToolbarButton(systemName: "plus", isPrimary: true)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.addButton)
|
||||
.accessibilityLabel("Add contractor")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -354,10 +357,12 @@ private struct OrganicContractorCard: View {
|
||||
.foregroundColor(contractor.isFavorite ? Color.appAccent : Color.appTextSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(contractor.isFavorite ? "Remove \(contractor.name) from favorites" : "Add \(contractor.name) to favorites")
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundColor(Color.appTextSecondary.opacity(0.5))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -115,6 +115,7 @@ struct WarrantyCard: View {
|
||||
.background(Color.appBackgroundSecondary)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private func getCategoryDisplayName(_ category: String) -> String {
|
||||
|
||||
@@ -68,6 +68,7 @@ struct DocumentDetailView: View {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Document.menuButton)
|
||||
}
|
||||
.accessibilityLabel("Document actions")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -483,6 +484,7 @@ struct DocumentDetailView: View {
|
||||
Text(title)
|
||||
.font(.system(size: 16, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@@ -163,6 +163,7 @@ struct DocumentFormView: View {
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Document.providerContactField)
|
||||
} header: {
|
||||
Text(L10n.Documents.warrantyDetails)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
} footer: {
|
||||
Text(L10n.Documents.requiredWarrantyFields)
|
||||
.font(.caption)
|
||||
@@ -363,6 +364,7 @@ struct DocumentFormView: View {
|
||||
.keyboardDismissToolbar()
|
||||
} header: {
|
||||
Text(L10n.Documents.basicInformation)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
} footer: {
|
||||
Text(L10n.Documents.requiredTitle)
|
||||
.font(.caption)
|
||||
|
||||
@@ -130,14 +130,23 @@ class DocumentViewModel: ObservableObject {
|
||||
|
||||
if let success = result as? ApiResultSuccess<Document> {
|
||||
if !images.isEmpty {
|
||||
guard let documentId = success.data?.id?.int32Value else {
|
||||
self.errorMessage = "Document created, but image upload could not start"
|
||||
guard let document = success.data,
|
||||
let documentId = document.id?.int32Value,
|
||||
document.title != nil else {
|
||||
let missingFields = [
|
||||
success.data == nil ? "data" : nil,
|
||||
success.data?.id == nil ? "id" : nil,
|
||||
success.data?.title == nil ? "title" : nil
|
||||
].compactMap { $0 }.joined(separator: ", ")
|
||||
print("DocumentViewModel: Document creation returned incomplete data (missing: \(missingFields)), skipping image upload")
|
||||
self.errorMessage = "Document created with incomplete data — images were not uploaded"
|
||||
self.isLoading = false
|
||||
completion(false, self.errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if let uploadError = await self.uploadImages(documentId: documentId, images: images) {
|
||||
print("DocumentViewModel: Image upload failed for document \(documentId): \(uploadError)")
|
||||
self.errorMessage = uploadError
|
||||
self.isLoading = false
|
||||
completion(false, self.errorMessage)
|
||||
|
||||
@@ -118,6 +118,7 @@ struct DocumentsWarrantiesView: View {
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(showActiveOnly ? Color.appPrimary : Color.appTextSecondary)
|
||||
}
|
||||
.accessibilityLabel(showActiveOnly ? "Show all warranties" : "Show active warranties only")
|
||||
}
|
||||
|
||||
// Filter Menu
|
||||
@@ -160,6 +161,7 @@ struct DocumentsWarrantiesView: View {
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor((selectedCategory != nil || selectedDocType != nil) ? Color.appPrimary : Color.appTextSecondary)
|
||||
}
|
||||
.accessibilityLabel("Filter documents")
|
||||
|
||||
// Add Button
|
||||
Button(action: {
|
||||
@@ -174,6 +176,7 @@ struct DocumentsWarrantiesView: View {
|
||||
OrganicDocToolbarButton()
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Document.addButton)
|
||||
.accessibilityLabel("Add document")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,6 +290,8 @@ private struct OrganicSegmentButton: View {
|
||||
.background(isSelected ? Color.appPrimary : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
.accessibilityLabel(title)
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,11 +22,13 @@ struct AccessibilityIdentifiers {
|
||||
static let registerConfirmPasswordField = "Register.ConfirmPasswordField"
|
||||
static let registerButton = "Register.RegisterButton"
|
||||
static let registerCancelButton = "Register.CancelButton"
|
||||
static let registerErrorMessage = "Register.ErrorMessage"
|
||||
|
||||
// Verification
|
||||
static let verificationCodeField = "Verification.CodeField"
|
||||
static let verifyButton = "Verification.VerifyButton"
|
||||
static let resendCodeButton = "Verification.ResendButton"
|
||||
static let verificationLogoutButton = "Verification.LogoutButton"
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
91
iosApp/iosApp/Helpers/AccessibilityLabels.swift
Normal file
91
iosApp/iosApp/Helpers/AccessibilityLabels.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@ struct LoginView: View {
|
||||
@StateObject private var appleSignInViewModel = AppleSignInViewModel()
|
||||
@FocusState private var focusedField: Field?
|
||||
@State private var showingRegister = false
|
||||
@State private var registrationVerified = false
|
||||
@State private var showVerification = false
|
||||
@State private var showPasswordReset = false
|
||||
@State private var isPasswordVisible = false
|
||||
@@ -69,6 +70,7 @@ struct LoginView: View {
|
||||
Text(L10n.Auth.welcomeBack)
|
||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
|
||||
Text(L10n.Auth.signInSubtitle)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
@@ -148,6 +150,7 @@ struct LoginView: View {
|
||||
action: viewModel.login
|
||||
)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.loginButton)
|
||||
.accessibilityHint("Double tap to sign in")
|
||||
|
||||
// Divider
|
||||
HStack(spacing: 12) {
|
||||
@@ -180,6 +183,7 @@ struct LoginView: View {
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.appleSignInButton)
|
||||
.accessibilityLabel("Sign in with Apple")
|
||||
|
||||
// Apple Sign In loading indicator
|
||||
if appleSignInViewModel.isLoading {
|
||||
@@ -216,6 +220,7 @@ struct LoginView: View {
|
||||
)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.googleSignInButton)
|
||||
.accessibilityLabel("Sign in with Google")
|
||||
|
||||
// Apple Sign In Error
|
||||
if let appleError = appleSignInViewModel.errorMessage {
|
||||
@@ -310,8 +315,18 @@ struct LoginView: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showingRegister) {
|
||||
RegisterView()
|
||||
.sheet(isPresented: $showingRegister, onDismiss: {
|
||||
// Sheet is fully removed from the UIKit presentation stack.
|
||||
// Set auth state now that no UIKit presentations block the
|
||||
// RootView hierarchy swap.
|
||||
if registrationVerified {
|
||||
registrationVerified = false
|
||||
AuthenticationManager.shared.login(verified: true)
|
||||
}
|
||||
}) {
|
||||
RegisterView(isPresented: $showingRegister, onVerified: {
|
||||
registrationVerified = true
|
||||
})
|
||||
}
|
||||
.sheet(isPresented: $showPasswordReset) {
|
||||
PasswordResetFlow(resetToken: activeResetToken, onLoginSuccess: { isVerified in
|
||||
|
||||
@@ -64,11 +64,20 @@ struct OnboardingCoordinator: View {
|
||||
return
|
||||
}
|
||||
|
||||
let postalCode = onboardingState.pendingPostalCode.isEmpty ? nil : onboardingState.pendingPostalCode
|
||||
print("🏠 ONBOARDING: Creating residence with name: \(onboardingState.pendingResidenceName), zip: \(postalCode ?? "none")")
|
||||
let postalCode: String? = nil
|
||||
print("🏠 ONBOARDING: Creating residence with name: \(onboardingState.pendingResidenceName)")
|
||||
|
||||
isCreatingResidence = true
|
||||
|
||||
// Collect home profile booleans — only send true values
|
||||
let hasPool = onboardingState.pendingHasPool ? KotlinBoolean(bool: true) : nil
|
||||
let hasSprinkler = onboardingState.pendingHasSprinklerSystem ? KotlinBoolean(bool: true) : nil
|
||||
let hasSeptic = onboardingState.pendingHasSeptic ? KotlinBoolean(bool: true) : nil
|
||||
let hasFireplace = onboardingState.pendingHasFireplace ? KotlinBoolean(bool: true) : nil
|
||||
let hasGarage = onboardingState.pendingHasGarage ? KotlinBoolean(bool: true) : nil
|
||||
let hasBasement = onboardingState.pendingHasBasement ? KotlinBoolean(bool: true) : nil
|
||||
let hasAttic = onboardingState.pendingHasAttic ? KotlinBoolean(bool: true) : nil
|
||||
|
||||
let request = ResidenceCreateRequest(
|
||||
name: onboardingState.pendingResidenceName,
|
||||
propertyTypeId: nil,
|
||||
@@ -86,7 +95,21 @@ struct OnboardingCoordinator: View {
|
||||
description: nil,
|
||||
purchaseDate: nil,
|
||||
purchasePrice: nil,
|
||||
isPrimary: KotlinBoolean(bool: true)
|
||||
isPrimary: KotlinBoolean(bool: true),
|
||||
heatingType: onboardingState.pendingHeatingType,
|
||||
coolingType: onboardingState.pendingCoolingType,
|
||||
waterHeaterType: onboardingState.pendingWaterHeaterType,
|
||||
roofType: onboardingState.pendingRoofType,
|
||||
hasPool: hasPool,
|
||||
hasSprinklerSystem: hasSprinkler,
|
||||
hasSeptic: hasSeptic,
|
||||
hasFireplace: hasFireplace,
|
||||
hasGarage: hasGarage,
|
||||
hasBasement: hasBasement,
|
||||
hasAttic: hasAttic,
|
||||
exteriorType: onboardingState.pendingExteriorType,
|
||||
flooringPrimary: onboardingState.pendingFlooringPrimary,
|
||||
landscapingType: onboardingState.pendingLandscapingType
|
||||
)
|
||||
|
||||
residenceViewModel.createResidence(request: request) { (residence: ResidenceResponse?) in
|
||||
@@ -103,7 +126,7 @@ struct OnboardingCoordinator: View {
|
||||
}
|
||||
|
||||
/// Current step index for progress indicator (0-based)
|
||||
/// Flow: Welcome → Features → Name → Account → Verify → Location → Tasks → Upsell
|
||||
/// Flow: Welcome → Features → Name → Account → Verify → Home Profile → Tasks → Upsell
|
||||
private var currentProgressStep: Int {
|
||||
switch onboardingState.currentStep {
|
||||
case .welcome: return 0
|
||||
@@ -113,6 +136,7 @@ struct OnboardingCoordinator: View {
|
||||
case .verifyEmail: return 4
|
||||
case .joinResidence: return 4
|
||||
case .residenceLocation: return 4
|
||||
case .homeProfile: return 4
|
||||
case .firstTask: return 4
|
||||
case .subscriptionUpsell: return 4
|
||||
}
|
||||
@@ -121,7 +145,7 @@ struct OnboardingCoordinator: View {
|
||||
/// Whether to show the back button
|
||||
private var showBackButton: Bool {
|
||||
switch onboardingState.currentStep {
|
||||
case .welcome, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
|
||||
case .welcome, .joinResidence, .residenceLocation, .homeProfile, .firstTask, .subscriptionUpsell:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
@@ -131,7 +155,7 @@ struct OnboardingCoordinator: View {
|
||||
/// Whether to show the skip button
|
||||
private var showSkipButton: Bool {
|
||||
switch onboardingState.currentStep {
|
||||
case .valueProps, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
|
||||
case .valueProps, .joinResidence, .homeProfile, .firstTask, .subscriptionUpsell:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -141,7 +165,7 @@ struct OnboardingCoordinator: View {
|
||||
/// Whether to show the progress indicator
|
||||
private var showProgressIndicator: Bool {
|
||||
switch onboardingState.currentStep {
|
||||
case .welcome, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
|
||||
case .welcome, .joinResidence, .residenceLocation, .homeProfile, .firstTask, .subscriptionUpsell:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
@@ -173,12 +197,13 @@ struct OnboardingCoordinator: View {
|
||||
switch onboardingState.currentStep {
|
||||
case .valueProps:
|
||||
goForward()
|
||||
case .residenceLocation:
|
||||
// Skipping location — still need to create residence (without postal code)
|
||||
case .homeProfile:
|
||||
// Skipping home profile — create residence without profile data, go to tasks
|
||||
createResidenceIfNeeded(thenNavigateTo: .firstTask)
|
||||
case .joinResidence, .firstTask:
|
||||
goForward()
|
||||
case .subscriptionUpsell:
|
||||
case .joinResidence:
|
||||
onboardingState.completeOnboarding()
|
||||
onComplete()
|
||||
case .firstTask, .subscriptionUpsell:
|
||||
onboardingState.completeOnboarding()
|
||||
onComplete()
|
||||
default:
|
||||
@@ -197,6 +222,7 @@ struct OnboardingCoordinator: View {
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.backButton)
|
||||
.accessibilityLabel("Go back")
|
||||
.frame(width: 44, alignment: .leading)
|
||||
.opacity(showBackButton ? 1 : 0)
|
||||
.disabled(!showBackButton)
|
||||
@@ -207,6 +233,7 @@ struct OnboardingCoordinator: View {
|
||||
if showProgressIndicator {
|
||||
OnboardingProgressIndicator(currentStep: currentProgressStep, totalSteps: 5)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.progressIndicator)
|
||||
.accessibilityLabel("Step \(currentProgressStep + 1) of 5")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -219,6 +246,7 @@ struct OnboardingCoordinator: View {
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.skipButton)
|
||||
.accessibilityLabel("Skip")
|
||||
.frame(width: 44, alignment: .trailing)
|
||||
.opacity(showSkipButton ? 1 : 0)
|
||||
.disabled(!showSkipButton)
|
||||
@@ -273,7 +301,7 @@ struct OnboardingCoordinator: View {
|
||||
if onboardingState.userIntent == .joinExisting {
|
||||
goForward(to: .joinResidence)
|
||||
} else {
|
||||
goForward(to: .residenceLocation)
|
||||
goForward(to: .homeProfile)
|
||||
}
|
||||
} else {
|
||||
goForward()
|
||||
@@ -289,7 +317,7 @@ struct OnboardingCoordinator: View {
|
||||
if onboardingState.userIntent == .joinExisting {
|
||||
goForward(to: .joinResidence)
|
||||
} else {
|
||||
goForward(to: .residenceLocation)
|
||||
goForward(to: .homeProfile)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -298,17 +326,22 @@ struct OnboardingCoordinator: View {
|
||||
case .joinResidence:
|
||||
OnboardingJoinResidenceContent(
|
||||
onJoined: {
|
||||
goForward()
|
||||
onboardingState.completeOnboarding()
|
||||
onComplete()
|
||||
}
|
||||
)
|
||||
.transition(navigationTransition)
|
||||
|
||||
case .residenceLocation:
|
||||
OnboardingLocationContent(
|
||||
onLocationDetected: { zip in
|
||||
// Load regional templates in background while creating residence
|
||||
onboardingState.loadRegionalTemplates(zip: zip)
|
||||
// Create residence with postal code, then go to first task
|
||||
// Location step removed — skip to home profile if we land here
|
||||
EmptyView()
|
||||
.onAppear { goForward(to: .homeProfile) }
|
||||
.transition(navigationTransition)
|
||||
|
||||
case .homeProfile:
|
||||
OnboardingHomeProfileContent(
|
||||
onContinue: {
|
||||
// Create residence with all collected data, then go to tasks
|
||||
createResidenceIfNeeded(thenNavigateTo: .firstTask)
|
||||
},
|
||||
onSkip: {
|
||||
@@ -321,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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
515
iosApp/iosApp/Onboarding/OnboardingHomeProfileView.swift
Normal file
515
iosApp/iosApp/Onboarding/OnboardingHomeProfileView.swift
Normal 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: {}
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ struct OnboardingJoinResidenceContent: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
.a11yDecorative()
|
||||
|
||||
// Decorative blobs
|
||||
GeometryReader { geo in
|
||||
@@ -57,6 +58,7 @@ struct OnboardingJoinResidenceContent: View {
|
||||
.offset(x: geo.size.width * 0.65, y: geo.size.height * 0.6)
|
||||
.blur(radius: 20)
|
||||
}
|
||||
.a11yDecorative()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
@@ -110,6 +112,7 @@ struct OnboardingJoinResidenceContent: View {
|
||||
Text("Join a Residence")
|
||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.a11yHeader()
|
||||
|
||||
Text("Enter the 6-character code shared with you to join an existing home.")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
@@ -137,6 +140,7 @@ struct OnboardingJoinResidenceContent: View {
|
||||
.textInputAutocapitalization(.characters)
|
||||
.autocorrectionDisabled()
|
||||
.focused($isCodeFieldFocused)
|
||||
.accessibilityHint("Enter 6-character share code")
|
||||
.onChange(of: shareCode) { _, newValue in
|
||||
// Limit to 6 characters
|
||||
if newValue.count > 6 {
|
||||
|
||||
@@ -17,6 +17,7 @@ struct OnboardingLocationContent: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
.a11yDecorative()
|
||||
|
||||
// Decorative blobs
|
||||
GeometryReader { geo in
|
||||
@@ -54,6 +55,7 @@ struct OnboardingLocationContent: View {
|
||||
.offset(x: geo.size.width * 0.65, y: geo.size.height * 0.75)
|
||||
.blur(radius: 15)
|
||||
}
|
||||
.a11yDecorative()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
@@ -125,6 +127,7 @@ struct OnboardingLocationContent: View {
|
||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
.a11yHeader()
|
||||
|
||||
Text("Enter your ZIP code so we can suggest\nmaintenance tasks for your climate region.")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
@@ -163,6 +166,7 @@ struct OnboardingLocationContent: View {
|
||||
.keyboardType(.numberPad)
|
||||
.focused($isTextFieldFocused)
|
||||
.multilineTextAlignment(.center)
|
||||
.accessibilityHint("Enter your ZIP code")
|
||||
.onChange(of: zipCode) { _, newValue in
|
||||
// Only allow digits, max 5
|
||||
let filtered = String(newValue.filter(\.isNumber).prefix(5))
|
||||
|
||||
@@ -24,6 +24,7 @@ struct OnboardingNameResidenceContent: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
.a11yDecorative()
|
||||
|
||||
// Decorative blobs
|
||||
GeometryReader { geo in
|
||||
@@ -61,6 +62,7 @@ struct OnboardingNameResidenceContent: View {
|
||||
.offset(x: geo.size.width * 0.55, y: geo.size.height * 0.6)
|
||||
.blur(radius: 20)
|
||||
}
|
||||
.a11yDecorative()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
@@ -124,6 +126,7 @@ struct OnboardingNameResidenceContent: View {
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceTitle)
|
||||
.a11yHeader()
|
||||
|
||||
Text("Don't worry, nothing's written in stone here.\nYou can always change it later in the app.")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
@@ -166,6 +169,7 @@ struct OnboardingNameResidenceContent: View {
|
||||
.focused($isTextFieldFocused)
|
||||
.submitLabel(.continue)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.residenceNameField)
|
||||
.accessibilityHint("Enter the name of your property")
|
||||
.onSubmit {
|
||||
if isValid {
|
||||
onContinue()
|
||||
|
||||
@@ -36,14 +36,22 @@ class OnboardingState: ObservableObject {
|
||||
/// The ID of the residence created during onboarding (used for task creation)
|
||||
@Published var createdResidenceId: Int32? = nil
|
||||
|
||||
/// ZIP code entered during the location step (used for residence creation and regional templates)
|
||||
@Published var pendingPostalCode: String = ""
|
||||
// MARK: - Home Profile State (collected during onboarding)
|
||||
|
||||
/// Regional task templates loaded from API based on ZIP code
|
||||
@Published var regionalTemplates: [TaskTemplate] = []
|
||||
|
||||
/// Whether regional templates are currently loading
|
||||
@Published var isLoadingTemplates: Bool = false
|
||||
@Published var pendingHeatingType: String? = nil
|
||||
@Published var pendingCoolingType: String? = nil
|
||||
@Published var pendingWaterHeaterType: String? = nil
|
||||
@Published var pendingRoofType: String? = nil
|
||||
@Published var pendingHasPool: Bool = false
|
||||
@Published var pendingHasSprinklerSystem: Bool = false
|
||||
@Published var pendingHasSeptic: Bool = false
|
||||
@Published var pendingHasFireplace: Bool = false
|
||||
@Published var pendingHasGarage: Bool = false
|
||||
@Published var pendingHasBasement: Bool = false
|
||||
@Published var pendingHasAttic: Bool = false
|
||||
@Published var pendingExteriorType: String? = nil
|
||||
@Published var pendingFlooringPrimary: String? = nil
|
||||
@Published var pendingLandscapingType: String? = nil
|
||||
|
||||
/// The user's selected intent (start fresh or join existing).
|
||||
/// Reads/writes the persisted @AppStorage value and notifies SwiftUI of the change.
|
||||
@@ -63,20 +71,6 @@ class OnboardingState: ObservableObject {
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Load regional task templates from the backend for the given ZIP code
|
||||
func loadRegionalTemplates(zip: String) {
|
||||
pendingPostalCode = zip
|
||||
isLoadingTemplates = true
|
||||
Task {
|
||||
defer { self.isLoadingTemplates = false }
|
||||
let result = try await APILayer.shared.getRegionalTemplates(state: nil, zip: zip)
|
||||
if let success = result as? ApiResultSuccess<NSArray>,
|
||||
let templates = success.data as? [TaskTemplate] {
|
||||
self.regionalTemplates = templates
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the onboarding flow
|
||||
func startOnboarding() {
|
||||
isOnboardingActive = true
|
||||
@@ -85,7 +79,7 @@ class OnboardingState: ObservableObject {
|
||||
}
|
||||
|
||||
/// Move to the next step in the flow
|
||||
/// Order: Welcome → Features → Name → Account → Verify → Location → Tasks → Upsell
|
||||
/// Order: Welcome → Features → Name → Account → Verify → Home Profile → Tasks → Upsell
|
||||
func nextStep() {
|
||||
switch currentStep {
|
||||
case .welcome:
|
||||
@@ -104,14 +98,17 @@ class OnboardingState: ObservableObject {
|
||||
if userIntent == .joinExisting {
|
||||
currentStep = .joinResidence
|
||||
} else {
|
||||
currentStep = .residenceLocation
|
||||
currentStep = .homeProfile
|
||||
}
|
||||
case .joinResidence:
|
||||
currentStep = .subscriptionUpsell
|
||||
completeOnboarding()
|
||||
case .residenceLocation:
|
||||
// Skip past this step if we somehow land here
|
||||
currentStep = .homeProfile
|
||||
case .homeProfile:
|
||||
currentStep = .firstTask
|
||||
case .firstTask:
|
||||
currentStep = .subscriptionUpsell
|
||||
completeOnboarding()
|
||||
case .subscriptionUpsell:
|
||||
completeOnboarding()
|
||||
}
|
||||
@@ -124,14 +121,18 @@ class OnboardingState: ObservableObject {
|
||||
|
||||
/// Complete the onboarding flow.
|
||||
/// Setting `hasCompletedOnboarding` also syncs with Kotlin DataManager via `didSet`.
|
||||
/// Guards against completing without authentication to prevent broken state.
|
||||
func completeOnboarding() {
|
||||
guard AuthenticationManager.shared.isAuthenticated else {
|
||||
// Don't complete onboarding without auth
|
||||
return
|
||||
}
|
||||
hasCompletedOnboarding = true
|
||||
isOnboardingActive = false
|
||||
pendingResidenceName = ""
|
||||
pendingPostalCode = ""
|
||||
regionalTemplates = []
|
||||
createdResidenceId = nil
|
||||
userIntent = .unknown
|
||||
resetHomeProfile()
|
||||
}
|
||||
|
||||
/// Reset onboarding state (useful for testing or re-onboarding).
|
||||
@@ -140,11 +141,28 @@ class OnboardingState: ObservableObject {
|
||||
hasCompletedOnboarding = false
|
||||
isOnboardingActive = false
|
||||
pendingResidenceName = ""
|
||||
pendingPostalCode = ""
|
||||
regionalTemplates = []
|
||||
createdResidenceId = nil
|
||||
userIntent = .unknown
|
||||
currentStep = .welcome
|
||||
resetHomeProfile()
|
||||
}
|
||||
|
||||
/// Reset all home profile fields
|
||||
private func resetHomeProfile() {
|
||||
pendingHeatingType = nil
|
||||
pendingCoolingType = nil
|
||||
pendingWaterHeaterType = nil
|
||||
pendingRoofType = nil
|
||||
pendingHasPool = false
|
||||
pendingHasSprinklerSystem = false
|
||||
pendingHasSeptic = false
|
||||
pendingHasFireplace = false
|
||||
pendingHasGarage = false
|
||||
pendingHasBasement = false
|
||||
pendingHasAttic = false
|
||||
pendingExteriorType = nil
|
||||
pendingFlooringPrimary = nil
|
||||
pendingLandscapingType = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,8 +175,9 @@ enum OnboardingStep: Int, CaseIterable {
|
||||
case verifyEmail = 4
|
||||
case joinResidence = 5 // Only for users joining with a code
|
||||
case residenceLocation = 6 // ZIP code entry for regional templates
|
||||
case firstTask = 7
|
||||
case subscriptionUpsell = 8
|
||||
case homeProfile = 7 // Home systems & features (optional)
|
||||
case firstTask = 8
|
||||
case subscriptionUpsell = 9
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
@@ -176,6 +195,8 @@ enum OnboardingStep: Int, CaseIterable {
|
||||
return "Join Residence"
|
||||
case .residenceLocation:
|
||||
return "Your Location"
|
||||
case .homeProfile:
|
||||
return "Home Profile"
|
||||
case .firstTask:
|
||||
return "First Task"
|
||||
case .subscriptionUpsell:
|
||||
|
||||
@@ -54,6 +54,7 @@ struct OnboardingSubscriptionContent: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
.a11yDecorative()
|
||||
|
||||
// Decorative blobs
|
||||
GeometryReader { geo in
|
||||
@@ -91,6 +92,7 @@ struct OnboardingSubscriptionContent: View {
|
||||
.offset(x: geo.size.width * 0.65, y: geo.size.height * 0.7)
|
||||
.blur(radius: 20)
|
||||
}
|
||||
.a11yDecorative()
|
||||
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: OrganicSpacing.comfortable) {
|
||||
@@ -176,6 +178,7 @@ struct OnboardingSubscriptionContent: View {
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.accessibilityLabel("Rated 4.9 stars by 10K+ homeowners")
|
||||
}
|
||||
.padding(.top, OrganicSpacing.comfortable)
|
||||
|
||||
@@ -272,6 +275,7 @@ struct OnboardingSubscriptionContent: View {
|
||||
.naturalShadow(.medium)
|
||||
}
|
||||
.disabled(isLoading)
|
||||
.a11yButton("Start free trial")
|
||||
|
||||
// Continue without
|
||||
Button(action: {
|
||||
@@ -281,6 +285,7 @@ struct OnboardingSubscriptionContent: View {
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.a11yButton("Continue with free plan")
|
||||
|
||||
// Legal text
|
||||
VStack(spacing: 4) {
|
||||
@@ -509,6 +514,8 @@ private struct OrganicPricingPlanCard: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.animation(.easeInOut(duration: 0.2), value: isSelected)
|
||||
.accessibilityLabel("\(plan.title) plan, \(displayPrice ?? plan.price)\(plan.period)\(plan.savings.map { ", \($0)" } ?? "")")
|
||||
.accessibilityValue(isSelected ? "Selected" : "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,6 +564,7 @@ private struct OrganicSubscriptionBenefitRow: View {
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.naturalShadow(.subtle)
|
||||
.a11yDecorative()
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(benefit.title)
|
||||
@@ -574,6 +582,7 @@ private struct OrganicSubscriptionBenefitRow: View {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundColor(benefit.gradient[0])
|
||||
.a11yDecorative()
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 6)
|
||||
|
||||
@@ -57,6 +57,7 @@ struct OnboardingValuePropsContent: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
.a11yDecorative()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Feature cards in a tab view
|
||||
@@ -105,6 +106,7 @@ struct OnboardingValuePropsContent: View {
|
||||
.naturalShadow(.medium)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.valuePropsNextButton)
|
||||
.accessibilityHint("Double tap to continue to the next step")
|
||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||
.padding(.bottom, OrganicSpacing.airy)
|
||||
}
|
||||
@@ -153,6 +155,7 @@ struct OrganicFeatureCard: View {
|
||||
.frame(width: 180, height: 180)
|
||||
.scaleEffect(appeared ? 1 : 0.8)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.a11yDecorative()
|
||||
|
||||
// Icon circle
|
||||
ZStack {
|
||||
@@ -181,6 +184,7 @@ struct OrganicFeatureCard: View {
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
.a11yHeader()
|
||||
|
||||
Text(feature.subtitle)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
|
||||
@@ -14,6 +14,7 @@ struct OnboardingVerifyEmailContent: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
.a11yDecorative()
|
||||
|
||||
// Decorative blobs
|
||||
GeometryReader { geo in
|
||||
@@ -51,6 +52,7 @@ struct OnboardingVerifyEmailContent: View {
|
||||
.offset(x: geo.size.width * 0.6, y: geo.size.height * 0.65)
|
||||
.blur(radius: 15)
|
||||
}
|
||||
.a11yDecorative()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
@@ -105,6 +107,7 @@ struct OnboardingVerifyEmailContent: View {
|
||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyEmailTitle)
|
||||
.a11yHeader()
|
||||
|
||||
Text("We sent a 6-digit code to your email address. Enter it below to verify your account.")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
@@ -133,6 +136,7 @@ struct OnboardingVerifyEmailContent: View {
|
||||
.textContentType(.oneTimeCode)
|
||||
.focused($isCodeFieldFocused)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verificationCodeField)
|
||||
.accessibilityHint("Enter 6-digit verification code")
|
||||
.keyboardDismissToolbar()
|
||||
.onChange(of: viewModel.code) { _, newValue in
|
||||
// Filter to digits only and truncate to 6 in one pass to prevent re-triggering
|
||||
|
||||
@@ -14,6 +14,7 @@ struct OnboardingWelcomeView: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
.a11yDecorative()
|
||||
|
||||
// Decorative blobs
|
||||
GeometryReader { geo in
|
||||
@@ -51,6 +52,7 @@ struct OnboardingWelcomeView: View {
|
||||
.offset(x: geo.size.width * 0.6, y: geo.size.height * 0.65)
|
||||
.blur(radius: 25)
|
||||
}
|
||||
.a11yDecorative()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
@@ -99,6 +101,7 @@ struct OnboardingWelcomeView: View {
|
||||
.font(.system(size: 32, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.welcomeTitle)
|
||||
.a11yHeader()
|
||||
|
||||
Text("Your home maintenance companion")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
@@ -136,6 +139,7 @@ struct OnboardingWelcomeView: View {
|
||||
.naturalShadow(.medium)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.startFreshButton)
|
||||
.accessibilityHint("Double tap to start setting up your property")
|
||||
|
||||
// Secondary CTA - Join Existing
|
||||
Button(action: onJoinExisting) {
|
||||
@@ -156,6 +160,7 @@ struct OnboardingWelcomeView: View {
|
||||
)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.joinExistingButton)
|
||||
.accessibilityHint("Double tap to join an existing property with a share code")
|
||||
|
||||
// Returning user login
|
||||
Button(action: {
|
||||
@@ -166,6 +171,7 @@ struct OnboardingWelcomeView: View {
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.loginButton)
|
||||
.accessibilityHint("Double tap to log in to your existing account")
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||
@@ -179,6 +185,7 @@ struct OnboardingWelcomeView: View {
|
||||
}
|
||||
.opacity(0.5)
|
||||
.padding(.bottom, 20)
|
||||
.a11yDecorative()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ struct ForgotPasswordView: View {
|
||||
Text("Forgot Password?")
|
||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.a11yHeader()
|
||||
|
||||
Text("Enter your email address and we'll send you a verification code")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
@@ -83,6 +84,7 @@ struct ForgotPasswordView: View {
|
||||
viewModel.clearError()
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.PasswordReset.emailField)
|
||||
.accessibilityHint("Enter your account email address")
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||
@@ -160,6 +162,7 @@ struct ForgotPasswordView: View {
|
||||
}
|
||||
.disabled(viewModel.email.isEmpty || viewModel.isLoading)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.PasswordReset.sendCodeButton)
|
||||
.accessibilityHint("Double tap to send a verification code to your email")
|
||||
|
||||
// Back to Login
|
||||
Button(action: { dismiss() }) {
|
||||
|
||||
@@ -68,6 +68,7 @@ struct ResetPasswordView: View {
|
||||
Text("Set New Password")
|
||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.a11yHeader()
|
||||
|
||||
Text("Create a strong password to secure your account")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
|
||||
@@ -41,6 +41,7 @@ struct VerifyResetCodeView: View {
|
||||
Text("Check Your Email")
|
||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.a11yHeader()
|
||||
|
||||
Text("We sent a 6-digit code to")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
@@ -89,6 +90,7 @@ struct VerifyResetCodeView: View {
|
||||
.focused($isCodeFocused)
|
||||
.keyboardDismissToolbar()
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.PasswordReset.codeField)
|
||||
.accessibilityHint("Enter 6-digit verification code from your email")
|
||||
.padding(20)
|
||||
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -147,6 +147,8 @@ struct ThemeRow: View {
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityLabel(theme.displayName)
|
||||
.accessibilityValue(isSelected ? "Selected" : "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 LoginView→MainTabView behind the UIKit sheet, leaving
|
||||
// a stale view hierarchy.
|
||||
|
||||
self.isRegistered = true
|
||||
self.isLoading = false
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" : "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
40
iosApp/iosApp/Shared/Extensions/AccessibilityModifiers.swift
Normal file
40
iosApp/iosApp/Shared/Extensions/AccessibilityModifiers.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)" } ?? "")")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,5 +63,7 @@ struct StatView: View {
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(A11y.Common.stat(value: value, label: label))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ struct PropertyDetailItem: View {
|
||||
.font(.caption2)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(A11y.Common.stat(value: value, label: label))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
))
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user