Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e350467975 | |||
| 3cd115a436 | |||
| 418ffc7772 | |||
| cec521b3e3 | |||
| 1b001323e4 | |||
| ce25c80783 | |||
| 4181b6005d | |||
| 2bd3bd85b6 | |||
| 60ae14c79e | |||
| dc6d3525fa | |||
| 5d0c3597fa | |||
| c9d5c048b7 | |||
| f5f02145a2 | |||
| cb4806b423 | |||
| 6bfe058050 | |||
| 9ececfa48a | |||
| d545fd463c | |||
| 5bb27034aa | |||
| 00e9ed0a96 | |||
| 05ee8e0a79 | |||
| 266d540d28 | |||
| 4609d5a953 | |||
| 8f86fa2cd0 | |||
| 4d363ca44e | |||
| e4dc3ac30b | |||
| af73f8861b |
@@ -1,8 +1,5 @@
|
||||
{
|
||||
{
|
||||
"permissions": {
|
||||
"ask": [
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push:*)"
|
||||
]
|
||||
}
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(head:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(tail:*)",
|
||||
"Bash(xcodebuild:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(git fetch:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(git:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(ps:*)",
|
||||
"Bash(stdbuf:*)",
|
||||
"Bash(sysctl:*)",
|
||||
"Bash(tee:*)",
|
||||
"Bash(codesign -d --entitlements :- /Users/treyt/Library/Developer/Xcode/DerivedData/honeyDue-buvczbpttcfkxxcmxbnqkqrmujyh/Build/Products/Debug-iphonesimulator/honeyDue.app)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -1219,6 +1219,48 @@ val CURRENT_ENV = Environment.DEV // or Environment.LOCAL
|
||||
3. Add method to relevant ViewModel that calls APILayer
|
||||
4. Update UI to observe the new StateFlow
|
||||
|
||||
### Onboarding task suggestions (server-driven)
|
||||
|
||||
The First-Task onboarding screen is **fully server-driven** on both
|
||||
platforms. There is no hardcoded catalog or client-side suggestion rules;
|
||||
when the API fails the screen shows error + Retry + Skip.
|
||||
|
||||
**Data flow:**
|
||||
|
||||
```
|
||||
"For You" tab → APILayer.getTaskSuggestions(residenceId)
|
||||
→ GET /api/tasks/suggestions/?residence_id=X
|
||||
→ scored against 15 home-profile fields (incl. climate zone)
|
||||
|
||||
"Browse All" tab → APILayer.getTaskTemplatesGrouped()
|
||||
→ GET /api/tasks/templates/grouped/
|
||||
→ cached on DataManager.taskTemplatesGrouped (24h TTL)
|
||||
|
||||
Submit → APILayer.bulkCreateTasks(BulkCreateTasksRequest)
|
||||
→ POST /api/tasks/bulk/
|
||||
→ single DB transaction, all-or-nothing
|
||||
```
|
||||
|
||||
**Key files:**
|
||||
|
||||
- Shared ViewModel: `composeApp/.../viewmodel/OnboardingViewModel.kt`
|
||||
(`suggestionsState`, `templatesGroupedState`, `createTasks`)
|
||||
- Android screen: `composeApp/.../ui/screens/onboarding/OnboardingFirstTaskContent.kt`
|
||||
- iOS Swift wrapper: `iosApp/iosApp/Onboarding/OnboardingTasksViewModel.swift`
|
||||
(mirrors the Kotlin ViewModel but calls `APILayer.shared` directly in
|
||||
Swift rather than observing Kotlin StateFlows — matches the convention in
|
||||
`iosApp/iosApp/Task/TaskViewModel.swift`)
|
||||
- iOS view: `iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift`
|
||||
- Analytics: 5 shared event names in `AnalyticsEvents` (Kotlin) +
|
||||
`AnalyticsEvent` (Swift) — `onboarding_suggestions_loaded`,
|
||||
`onboarding_suggestion_accepted`, `onboarding_browse_template_accepted`,
|
||||
`onboarding_tasks_created`, `onboarding_task_step_skipped`.
|
||||
|
||||
**When selecting a template from either tab**, always populate
|
||||
`TaskCreateRequest.templateId` with the backend `TaskTemplate.id` so the
|
||||
created task carries the template backlink for reporting. Swift wraps the
|
||||
id as `KotlinInt(int: template.id)`.
|
||||
|
||||
### Handling Platform-Specific Code
|
||||
|
||||
Use `expect/actual` pattern:
|
||||
|
||||
@@ -80,6 +80,9 @@ class MainActivity : FragmentActivity(), SingletonImageLoader.Factory {
|
||||
// Initialize PostHog Analytics
|
||||
PostHogAnalytics.initialize(application, debug = true) // Set debug=false for release
|
||||
|
||||
// Install uncaught exception handler to capture crashes to PostHog
|
||||
PostHogAnalytics.setupExceptionHandler()
|
||||
|
||||
// Handle deep link, notification navigation, and file import from intent
|
||||
handleDeepLink(intent)
|
||||
handleNotificationNavigation(intent)
|
||||
|
||||
+57
@@ -1,6 +1,7 @@
|
||||
package com.tt.honeyDue.analytics
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.posthog.PostHog
|
||||
import com.posthog.android.PostHogAndroid
|
||||
import com.posthog.android.PostHogAndroidConfig
|
||||
@@ -13,6 +14,8 @@ actual object PostHogAnalytics {
|
||||
private const val API_KEY = "YOUR_POSTHOG_API_KEY"
|
||||
private const val HOST = "https://us.i.posthog.com"
|
||||
|
||||
private const val TAG = "PostHogAnalytics"
|
||||
|
||||
private var isInitialized = false
|
||||
private var application: Application? = null
|
||||
|
||||
@@ -57,6 +60,28 @@ actual object PostHogAnalytics {
|
||||
PostHog.capture(event, properties = properties)
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture an exception/crash as a PostHog `$exception` event.
|
||||
* Uses PostHog's standard exception property names so exceptions
|
||||
* appear correctly in the PostHog Errors dashboard.
|
||||
*/
|
||||
actual fun captureException(throwable: Throwable, properties: Map<String, Any>?) {
|
||||
if (!isInitialized) return
|
||||
try {
|
||||
val exceptionProps = mutableMapOf<String, Any>(
|
||||
"\$exception_type" to (throwable::class.simpleName ?: "Unknown"),
|
||||
"\$exception_message" to (throwable.message ?: "No message"),
|
||||
"\$exception_stack_trace_raw" to throwable.stackTraceToString()
|
||||
)
|
||||
if (properties != null) {
|
||||
exceptionProps.putAll(properties)
|
||||
}
|
||||
PostHog.capture("\$exception", properties = exceptionProps)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to capture exception to PostHog", e)
|
||||
}
|
||||
}
|
||||
|
||||
actual fun screen(screenName: String, properties: Map<String, Any>?) {
|
||||
if (!isInitialized) return
|
||||
PostHog.screen(screenName, properties = properties)
|
||||
@@ -71,4 +96,36 @@ actual object PostHogAnalytics {
|
||||
if (!isInitialized) return
|
||||
PostHog.flush()
|
||||
}
|
||||
|
||||
/**
|
||||
* Install an uncaught exception handler that captures crashes to PostHog
|
||||
* before delegating to the default handler (which shows the crash dialog).
|
||||
* Call this after initialize() in MainActivity.onCreate().
|
||||
*/
|
||||
actual fun setupExceptionHandler() {
|
||||
if (!isInitialized) return
|
||||
|
||||
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
try {
|
||||
PostHog.capture(
|
||||
event = "\$exception",
|
||||
properties = mapOf(
|
||||
"\$exception_type" to (throwable::class.simpleName ?: "Unknown"),
|
||||
"\$exception_message" to (throwable.message ?: "No message"),
|
||||
"\$exception_stack_trace_raw" to throwable.stackTraceToString(),
|
||||
"\$exception_thread" to thread.name,
|
||||
"\$exception_is_fatal" to true
|
||||
)
|
||||
)
|
||||
// Flush to ensure the event is sent before the process dies
|
||||
PostHog.flush()
|
||||
} catch (_: Exception) {
|
||||
// Don't let our crash handler crash
|
||||
}
|
||||
// Call the default handler so the system crash dialog still appears
|
||||
defaultHandler?.uncaughtException(thread, throwable)
|
||||
}
|
||||
Log.d(TAG, "Uncaught exception handler installed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.tt.honeyDue.media
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Memory-efficient image resizer for upload preprocessing on Android.
|
||||
*
|
||||
* Why not just decode + Bitmap.createScaledBitmap? createScaledBitmap
|
||||
* decodes the full source bitmap first — a 12 MP photo materializes ~50 MB
|
||||
* in RAM regardless of how big the JPEG is. That OOMs older devices.
|
||||
*
|
||||
* BitmapFactory.Options.inSampleSize, paired with inJustDecodeBounds=true
|
||||
* for a metadata-only first pass, lets us decode at a power-of-two
|
||||
* subsample. Combined with a final scaled-down draw, peak memory is
|
||||
* roughly proportional to the *output* bitmap's pixel count — not the
|
||||
* source's.
|
||||
*
|
||||
* Quality tuning matches WhatsApp-class apps: 2048 px max edge, JPEG 85.
|
||||
*/
|
||||
object ImageDownsampler {
|
||||
|
||||
data class Profile(
|
||||
val maxPixelEdge: Int,
|
||||
/** JPEG quality 0-100. */
|
||||
val jpegQuality: Int,
|
||||
) {
|
||||
companion object {
|
||||
val Completion = Profile(maxPixelEdge = 2048, jpegQuality = 85)
|
||||
val DocumentImage = Profile(maxPixelEdge = 2560, jpegQuality = 90)
|
||||
}
|
||||
}
|
||||
|
||||
/** Downsample raw image bytes into JPEG bytes ready for upload. */
|
||||
fun downsample(bytes: ByteArray, profile: Profile): ByteArray? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
|
||||
val sampleSize = computeSampleSize(bounds.outWidth, bounds.outHeight, profile.maxPixelEdge)
|
||||
val decodeOpts = BitmapFactory.Options().apply {
|
||||
inSampleSize = sampleSize
|
||||
// ARGB_8888 keeps quality; on memory-constrained devices we
|
||||
// could drop to RGB_565 here, but for upload prep the extra
|
||||
// ~2x peak memory isn't worth the visible quality loss.
|
||||
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
}
|
||||
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOpts)
|
||||
?: return null
|
||||
|
||||
// Subsample is power-of-two only; the result may still be larger
|
||||
// than maxPixelEdge by up to 2x. One more proportional scale gets
|
||||
// us to the exact target.
|
||||
val scaled = scaleProportional(decoded, profile.maxPixelEdge)
|
||||
|
||||
val out = ByteArrayOutputStream(64 * 1024)
|
||||
val ok = scaled.compress(Bitmap.CompressFormat.JPEG, profile.jpegQuality, out)
|
||||
// Only recycle if scaled is a different bitmap; createScaledBitmap
|
||||
// sometimes returns the input unchanged, and recycling that would
|
||||
// double-recycle below.
|
||||
if (scaled !== decoded) decoded.recycle()
|
||||
scaled.recycle()
|
||||
return if (ok) out.toByteArray() else null
|
||||
}
|
||||
|
||||
/** Same, from a stream (for content:// URIs etc.). */
|
||||
fun downsample(input: InputStream, profile: Profile): ByteArray? {
|
||||
val bytes = input.use { it.readBytes() }
|
||||
return downsample(bytes, profile)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the largest power-of-two sub-sample factor that still yields
|
||||
* an image at least as large as maxPixelEdge on both axes. Mirrors
|
||||
* the canonical Android docs example.
|
||||
*/
|
||||
private fun computeSampleSize(srcW: Int, srcH: Int, maxEdge: Int): Int {
|
||||
var sample = 1
|
||||
var halfW = srcW / 2
|
||||
var halfH = srcH / 2
|
||||
while (halfW >= maxEdge && halfH >= maxEdge) {
|
||||
sample *= 2
|
||||
halfW /= 2
|
||||
halfH /= 2
|
||||
}
|
||||
return sample
|
||||
}
|
||||
|
||||
private fun scaleProportional(src: Bitmap, maxEdge: Int): Bitmap {
|
||||
val w = src.width
|
||||
val h = src.height
|
||||
val longest = maxOf(w, h)
|
||||
if (longest <= maxEdge) return src
|
||||
val ratio = maxEdge.toFloat() / longest.toFloat()
|
||||
val newW = (w * ratio).toInt().coerceAtLeast(1)
|
||||
val newH = (h * ratio).toInt().coerceAtLeast(1)
|
||||
return Bitmap.createScaledBitmap(src, newW, newH, true)
|
||||
}
|
||||
}
|
||||
@@ -813,6 +813,18 @@
|
||||
<string name="onboarding_subscription_continue_free">Continue with Free</string>
|
||||
<string name="onboarding_subscription_trial_terms">7-day free trial, then %1$s. Cancel anytime.</string>
|
||||
|
||||
<!-- Onboarding - Home Profile -->
|
||||
<string name="onboarding_home_profile_title">Tell us about your home</string>
|
||||
<string name="onboarding_home_profile_subtitle">All optional — helps us personalize your maintenance plan</string>
|
||||
<string name="onboarding_home_profile_systems">Systems</string>
|
||||
<string name="onboarding_home_profile_features">Features</string>
|
||||
<string name="onboarding_home_profile_exterior">Exterior</string>
|
||||
<string name="onboarding_home_profile_interior">Interior</string>
|
||||
|
||||
<!-- Onboarding - Task Selection Tabs -->
|
||||
<string name="for_you_tab">For You</string>
|
||||
<string name="browse_tab">Browse</string>
|
||||
|
||||
<!-- Biometric Lock -->
|
||||
<string name="biometric_lock_title">App Locked</string>
|
||||
<string name="biometric_lock_description">Authenticate to unlock honeyDue</string>
|
||||
|
||||
@@ -8,9 +8,11 @@ expect object PostHogAnalytics {
|
||||
fun initialize()
|
||||
fun identify(userId: String, properties: Map<String, Any>? = null)
|
||||
fun capture(event: String, properties: Map<String, Any>? = null)
|
||||
fun captureException(throwable: Throwable, properties: Map<String, Any>? = null)
|
||||
fun screen(screenName: String, properties: Map<String, Any>? = null)
|
||||
fun reset()
|
||||
fun flush()
|
||||
fun setupExceptionHandler()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,6 +36,20 @@ object AnalyticsEvents {
|
||||
const val NEW_TASK_SCREEN_SHOWN = "new_task_screen_shown"
|
||||
const val TASK_CREATED = "task_created"
|
||||
|
||||
// Onboarding — First Task screen funnel
|
||||
// Fired by both iOS and Android with identical event names so the
|
||||
// PostHog funnel is cross-platform. Properties documented next to each.
|
||||
// ONBOARDING_SUGGESTIONS_LOADED: {"count": Int, "profile_completeness": Double}
|
||||
// ONBOARDING_SUGGESTION_ACCEPTED: {"template_id": Int, "relevance_score": Double}
|
||||
// ONBOARDING_BROWSE_TEMPLATE_ACCEPTED: {"template_id": Int, "category": String?}
|
||||
// ONBOARDING_TASKS_CREATED: {"count": Int}
|
||||
// ONBOARDING_TASK_STEP_SKIPPED: {"reason": "network_error" | "user_skip"}
|
||||
const val ONBOARDING_SUGGESTIONS_LOADED = "onboarding_suggestions_loaded"
|
||||
const val ONBOARDING_SUGGESTION_ACCEPTED = "onboarding_suggestion_accepted"
|
||||
const val ONBOARDING_BROWSE_TEMPLATE_ACCEPTED = "onboarding_browse_template_accepted"
|
||||
const val ONBOARDING_TASKS_CREATED = "onboarding_tasks_created"
|
||||
const val ONBOARDING_TASK_STEP_SKIPPED = "onboarding_task_step_skipped"
|
||||
|
||||
// Contractor
|
||||
const val CONTRACTOR_SCREEN_SHOWN = "contractor_screen_shown"
|
||||
const val NEW_CONTRACTOR_SCREEN_SHOWN = "new_contractor_screen_shown"
|
||||
|
||||
@@ -62,8 +62,6 @@ object DataManager {
|
||||
private set
|
||||
var tasksCacheTime: Long = 0L
|
||||
private set
|
||||
var tasksByResidenceCacheTime: MutableMap<Int, Long> = mutableMapOf()
|
||||
private set
|
||||
var contractorsCacheTime: Long = 0L
|
||||
private set
|
||||
var documentsCacheTime: Long = 0L
|
||||
@@ -138,8 +136,6 @@ object DataManager {
|
||||
private val _allTasks = MutableStateFlow<TaskColumnsResponse?>(null)
|
||||
val allTasks: StateFlow<TaskColumnsResponse?> = _allTasks.asStateFlow()
|
||||
|
||||
private val _tasksByResidence = MutableStateFlow<Map<Int, TaskColumnsResponse>>(emptyMap())
|
||||
val tasksByResidence: StateFlow<Map<Int, TaskColumnsResponse>> = _tasksByResidence.asStateFlow()
|
||||
|
||||
// ==================== DOCUMENTS ====================
|
||||
|
||||
@@ -414,7 +410,6 @@ object DataManager {
|
||||
|
||||
fun removeResidence(residenceId: Int) {
|
||||
_residences.value = _residences.value.filter { it.id != residenceId }
|
||||
_tasksByResidence.value = _tasksByResidence.value - residenceId
|
||||
_documentsByResidence.value = _documentsByResidence.value - residenceId
|
||||
_residenceSummaries.value = _residenceSummaries.value - residenceId
|
||||
|
||||
@@ -445,16 +440,10 @@ object DataManager {
|
||||
persistToDisk()
|
||||
}
|
||||
|
||||
fun setTasksForResidence(residenceId: Int, response: TaskColumnsResponse) {
|
||||
_tasksByResidence.value = _tasksByResidence.value + (residenceId to response)
|
||||
tasksByResidenceCacheTime[residenceId] = currentTimeMs()
|
||||
persistToDisk()
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter cached allTasks by residence ID to avoid separate API call.
|
||||
* Returns null if allTasks not cached.
|
||||
* This enables client-side filtering when we already have all tasks loaded.
|
||||
* Filter cached allTasks by residence ID. Single source of truth for
|
||||
* residence-scoped kanban data; returns null when _allTasks is null
|
||||
* (caller must hit the API to populate).
|
||||
*/
|
||||
fun getTasksForResidence(residenceId: Int): TaskColumnsResponse? {
|
||||
val allTasksData = _allTasks.value ?: return null
|
||||
@@ -480,45 +469,60 @@ object DataManager {
|
||||
* Also refreshes the summary from the updated kanban data.
|
||||
*/
|
||||
fun updateTask(task: TaskResponse) {
|
||||
// Update in allTasks
|
||||
_allTasks.value?.let { current ->
|
||||
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
|
||||
val newColumns = current.columns.map { column ->
|
||||
// Remove task from this column if present
|
||||
val filteredTasks = column.tasks.filter { it.id != task.id }
|
||||
// Add task if this is the target column
|
||||
val updatedTasks = if (column.name == targetColumn) {
|
||||
filteredTasks + task
|
||||
|
||||
// Upsert into _allTasks. Crucially, when _allTasks is null (fresh
|
||||
// launch, kanban never fetched — the gitea#2 bug scenario), seed
|
||||
// an empty kanban shell so the new task isn't silently dropped.
|
||||
// The Phase 2 force-refresh after bulkCreateTasks/createTask will
|
||||
// replace this shell with authoritative server data shortly.
|
||||
val current = _allTasks.value ?: emptyKanbanShell()
|
||||
val columnsWithTarget = if (current.columns.any { it.name == targetColumn }) {
|
||||
current.columns
|
||||
} else {
|
||||
filteredTasks
|
||||
// Server returned a kanban_column the client doesn't know about
|
||||
// yet — append it so the task is still reachable.
|
||||
current.columns + emptyColumn(targetColumn)
|
||||
}
|
||||
val newColumns = columnsWithTarget.map { column ->
|
||||
val filteredTasks = column.tasks.filter { it.id != task.id }
|
||||
val updatedTasks = if (column.name == targetColumn) filteredTasks + task else filteredTasks
|
||||
column.copy(tasks = updatedTasks, count = updatedTasks.size)
|
||||
}
|
||||
_allTasks.value = current.copy(columns = newColumns)
|
||||
}
|
||||
|
||||
// Update in tasksByResidence if this task's residence is cached
|
||||
task.residenceId?.let { residenceId ->
|
||||
_tasksByResidence.value[residenceId]?.let { current ->
|
||||
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
|
||||
val newColumns = current.columns.map { column ->
|
||||
val filteredTasks = column.tasks.filter { it.id != task.id }
|
||||
val updatedTasks = if (column.name == targetColumn) {
|
||||
filteredTasks + task
|
||||
} else {
|
||||
filteredTasks
|
||||
}
|
||||
column.copy(tasks = updatedTasks, count = updatedTasks.size)
|
||||
}
|
||||
_tasksByResidence.value = _tasksByResidence.value + (residenceId to current.copy(columns = newColumns))
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh summary from updated kanban data (API no longer returns summaries for CRUD)
|
||||
refreshSummaryFromKanban()
|
||||
persistToDisk()
|
||||
}
|
||||
|
||||
/// Default kanban skeleton used when `_allTasks` was never populated.
|
||||
/// Display metadata is intentionally placeholder — the Phase 2 force-refresh
|
||||
/// in `APILayer.bulkCreateTasks` / `createTask` replaces these shortly with
|
||||
/// authoritative server values. The `name` field is the contract — every
|
||||
/// observer keys off it.
|
||||
private fun emptyKanbanShell(): TaskColumnsResponse = TaskColumnsResponse(
|
||||
columns = listOf(
|
||||
emptyColumn("overdue_tasks"),
|
||||
emptyColumn("due_soon_tasks"),
|
||||
emptyColumn("in_progress_tasks"),
|
||||
emptyColumn("upcoming_tasks"),
|
||||
emptyColumn("completed_tasks")
|
||||
),
|
||||
daysThreshold = 30,
|
||||
residenceId = ""
|
||||
)
|
||||
|
||||
private fun emptyColumn(name: String): TaskColumn = TaskColumn(
|
||||
name = name,
|
||||
displayName = "",
|
||||
buttonTypes = emptyList(),
|
||||
icons = emptyMap(),
|
||||
color = "",
|
||||
tasks = emptyList(),
|
||||
count = 0
|
||||
)
|
||||
|
||||
fun removeTask(taskId: Int) {
|
||||
// Remove from allTasks
|
||||
_allTasks.value?.let { current ->
|
||||
@@ -529,15 +533,6 @@ object DataManager {
|
||||
_allTasks.value = current.copy(columns = newColumns)
|
||||
}
|
||||
|
||||
// Remove from all residence task caches
|
||||
_tasksByResidence.value = _tasksByResidence.value.mapValues { (_, tasks) ->
|
||||
val newColumns = tasks.columns.map { column ->
|
||||
val filteredTasks = column.tasks.filter { it.id != taskId }
|
||||
column.copy(tasks = filteredTasks, count = filteredTasks.size)
|
||||
}
|
||||
tasks.copy(columns = newColumns)
|
||||
}
|
||||
|
||||
// Refresh summary from updated kanban data (API no longer returns summaries for CRUD)
|
||||
refreshSummaryFromKanban()
|
||||
persistToDisk()
|
||||
@@ -780,7 +775,6 @@ object DataManager {
|
||||
_totalSummary.value = null
|
||||
_residenceSummaries.value = emptyMap()
|
||||
_allTasks.value = null
|
||||
_tasksByResidence.value = emptyMap()
|
||||
_documents.value = emptyList()
|
||||
_documentsByResidence.value = emptyMap()
|
||||
_contractors.value = emptyList()
|
||||
@@ -811,7 +805,6 @@ object DataManager {
|
||||
residencesCacheTime = 0L
|
||||
myResidencesCacheTime = 0L
|
||||
tasksCacheTime = 0L
|
||||
tasksByResidenceCacheTime.clear()
|
||||
contractorsCacheTime = 0L
|
||||
documentsCacheTime = 0L
|
||||
summaryCacheTime = 0L
|
||||
@@ -833,7 +826,6 @@ object DataManager {
|
||||
_totalSummary.value = null
|
||||
_residenceSummaries.value = emptyMap()
|
||||
_allTasks.value = null
|
||||
_tasksByResidence.value = emptyMap()
|
||||
_documents.value = emptyList()
|
||||
_documentsByResidence.value = emptyMap()
|
||||
_contractors.value = emptyList()
|
||||
@@ -846,7 +838,6 @@ object DataManager {
|
||||
residencesCacheTime = 0L
|
||||
myResidencesCacheTime = 0L
|
||||
tasksCacheTime = 0L
|
||||
tasksByResidenceCacheTime.clear()
|
||||
contractorsCacheTime = 0L
|
||||
documentsCacheTime = 0L
|
||||
summaryCacheTime = 0L
|
||||
|
||||
@@ -53,8 +53,14 @@ data class TaskResponse(
|
||||
@SerialName("is_cancelled") val isCancelled: Boolean = false,
|
||||
@SerialName("is_archived") val isArchived: Boolean = false,
|
||||
@SerialName("parent_task_id") val parentTaskId: Int? = null,
|
||||
// Backlink to the TaskTemplate this task was spawned from (onboarding
|
||||
// suggestion or browse catalog). Null for user-created custom tasks.
|
||||
@SerialName("template_id") val templateId: Int? = null,
|
||||
@SerialName("completion_count") val completionCount: Int = 0,
|
||||
@SerialName("kanban_column") val kanbanColumn: String? = null, // Which kanban column this task belongs to
|
||||
// Note: Go API does not return completions inline with TaskResponse.
|
||||
// Completions are fetched separately via the completions endpoint.
|
||||
// This field defaults to emptyList() and is only populated client-side after a separate fetch.
|
||||
val completions: List<TaskCompletionResponse> = emptyList(),
|
||||
@SerialName("created_at") val createdAt: String,
|
||||
@SerialName("updated_at") val updatedAt: String
|
||||
@@ -130,7 +136,33 @@ data class TaskCreateRequest(
|
||||
@SerialName("assigned_to_id") val assignedToId: Int? = null,
|
||||
@SerialName("due_date") val dueDate: String? = null,
|
||||
@SerialName("estimated_cost") val estimatedCost: Double? = null,
|
||||
@SerialName("contractor_id") val contractorId: Int? = null
|
||||
@SerialName("contractor_id") val contractorId: Int? = null,
|
||||
// Set when the task is spawned from a TaskTemplate (onboarding
|
||||
// suggestion or browse catalog). Null for free-form custom tasks.
|
||||
@SerialName("template_id") val templateId: Int? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Bulk create request matching Go API BulkCreateTasksRequest.
|
||||
* Used by onboarding to insert 1-50 tasks atomically in a single
|
||||
* transaction. The server forces every entry's residence_id to the
|
||||
* top-level value, so any mismatch in the list is silently corrected.
|
||||
*/
|
||||
@Serializable
|
||||
data class BulkCreateTasksRequest(
|
||||
@SerialName("residence_id") val residenceId: Int,
|
||||
val tasks: List<TaskCreateRequest>
|
||||
)
|
||||
|
||||
/**
|
||||
* Bulk create response matching Go API BulkCreateTasksResponse.
|
||||
* All [tasks] are created-or-none — partial state never reaches the client.
|
||||
*/
|
||||
@Serializable
|
||||
data class BulkCreateTasksResponse(
|
||||
val tasks: List<TaskResponse>,
|
||||
val summary: TotalSummary,
|
||||
@SerialName("created_count") val createdCount: Int
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,8 +33,12 @@ data class Document(
|
||||
val title: String,
|
||||
@SerialName("document_type") val documentType: String,
|
||||
val description: String? = null,
|
||||
@SerialName("file_url") val fileUrl: String? = null, // URL to the file
|
||||
@SerialName("media_url") val mediaUrl: String? = null, // Authenticated endpoint: /api/media/document/{id}
|
||||
// fileUrl: raw storage path (internal). Not included in Go DocumentResponse DTO —
|
||||
// will always be null from the API. Kept for backward compatibility; prefer mediaUrl.
|
||||
@SerialName("file_url") val fileUrl: String? = null,
|
||||
// mediaUrl: authenticated endpoint clients should use (e.g. /api/media/document/{id}).
|
||||
// This is the URL the Go API actually returns for document access.
|
||||
@SerialName("media_url") val mediaUrl: String? = null,
|
||||
@SerialName("file_name") val fileName: String? = null,
|
||||
@SerialName("file_size") val fileSize: Int? = null,
|
||||
@SerialName("mime_type") val mimeType: String? = null,
|
||||
@@ -43,7 +47,7 @@ data class Document(
|
||||
@SerialName("serial_number") val serialNumber: String? = null,
|
||||
val vendor: String? = null,
|
||||
@SerialName("purchase_date") val purchaseDate: String? = null,
|
||||
@SerialName("purchase_price") val purchasePrice: String? = null,
|
||||
@SerialName("purchase_price") val purchasePrice: Double? = null,
|
||||
@SerialName("expiry_date") val expiryDate: String? = null,
|
||||
// Relationships
|
||||
@SerialName("residence_id") val residenceId: Int? = null,
|
||||
@@ -87,7 +91,7 @@ data class DocumentCreateRequest(
|
||||
@SerialName("serial_number") val serialNumber: String? = null,
|
||||
val vendor: String? = null,
|
||||
@SerialName("purchase_date") val purchaseDate: String? = null,
|
||||
@SerialName("purchase_price") val purchasePrice: String? = null,
|
||||
@SerialName("purchase_price") val purchasePrice: Double? = null,
|
||||
@SerialName("expiry_date") val expiryDate: String? = null,
|
||||
// Relationships
|
||||
@SerialName("residence_id") val residenceId: Int,
|
||||
@@ -106,7 +110,7 @@ data class DocumentUpdateRequest(
|
||||
@SerialName("serial_number") val serialNumber: String? = null,
|
||||
val vendor: String? = null,
|
||||
@SerialName("purchase_date") val purchaseDate: String? = null,
|
||||
@SerialName("purchase_price") val purchasePrice: String? = null,
|
||||
@SerialName("purchase_price") val purchasePrice: Double? = null,
|
||||
@SerialName("expiry_date") val expiryDate: String? = null,
|
||||
// Relationships
|
||||
@SerialName("task_id") val taskId: Int? = null
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.tt.honeyDue.models
|
||||
|
||||
/**
|
||||
* Static option lists for home profile pickers.
|
||||
* Each entry is a (apiValue, displayLabel) pair.
|
||||
*/
|
||||
object HomeProfileOptions {
|
||||
val heatingTypes = listOf(
|
||||
"gas_furnace" to "Gas Furnace",
|
||||
"electric" to "Electric",
|
||||
"heat_pump" to "Heat Pump",
|
||||
"boiler" to "Boiler",
|
||||
"radiant" to "Radiant",
|
||||
"wood_stove" to "Wood Stove",
|
||||
"none" to "None"
|
||||
)
|
||||
val coolingTypes = listOf(
|
||||
"central_ac" to "Central AC",
|
||||
"window_unit" to "Window Unit",
|
||||
"mini_split" to "Mini Split",
|
||||
"evaporative" to "Evaporative",
|
||||
"none" to "None"
|
||||
)
|
||||
val waterHeaterTypes = listOf(
|
||||
"tank_gas" to "Tank (Gas)",
|
||||
"tank_electric" to "Tank (Electric)",
|
||||
"tankless" to "Tankless",
|
||||
"solar" to "Solar",
|
||||
"heat_pump_wh" to "Heat Pump"
|
||||
)
|
||||
val roofTypes = listOf(
|
||||
"asphalt_shingle" to "Asphalt Shingle",
|
||||
"metal" to "Metal",
|
||||
"tile" to "Tile",
|
||||
"flat_tpo" to "Flat/TPO",
|
||||
"slate" to "Slate",
|
||||
"wood_shake" to "Wood Shake"
|
||||
)
|
||||
val exteriorTypes = listOf(
|
||||
"vinyl_siding" to "Vinyl Siding",
|
||||
"brick" to "Brick",
|
||||
"stucco" to "Stucco",
|
||||
"wood" to "Wood",
|
||||
"stone" to "Stone",
|
||||
"fiber_cement" to "Fiber Cement"
|
||||
)
|
||||
val flooringTypes = listOf(
|
||||
"hardwood" to "Hardwood",
|
||||
"carpet" to "Carpet",
|
||||
"tile" to "Tile",
|
||||
"laminate" to "Laminate",
|
||||
"vinyl" to "Vinyl"
|
||||
)
|
||||
val landscapingTypes = listOf(
|
||||
"lawn" to "Lawn",
|
||||
"xeriscaping" to "Xeriscaping",
|
||||
"none" to "None"
|
||||
)
|
||||
}
|
||||
@@ -53,6 +53,20 @@ data class ResidenceResponse(
|
||||
@SerialName("is_active") val isActive: Boolean = true,
|
||||
@SerialName("overdue_count") val overdueCount: Int = 0,
|
||||
@SerialName("completion_summary") val completionSummary: CompletionSummary? = null,
|
||||
@SerialName("heating_type") val heatingType: String? = null,
|
||||
@SerialName("cooling_type") val coolingType: String? = null,
|
||||
@SerialName("water_heater_type") val waterHeaterType: String? = null,
|
||||
@SerialName("roof_type") val roofType: String? = null,
|
||||
@SerialName("has_pool") val hasPool: Boolean = false,
|
||||
@SerialName("has_sprinkler_system") val hasSprinklerSystem: Boolean = false,
|
||||
@SerialName("has_septic") val hasSeptic: Boolean = false,
|
||||
@SerialName("has_fireplace") val hasFireplace: Boolean = false,
|
||||
@SerialName("has_garage") val hasGarage: Boolean = false,
|
||||
@SerialName("has_basement") val hasBasement: Boolean = false,
|
||||
@SerialName("has_attic") val hasAttic: Boolean = false,
|
||||
@SerialName("exterior_type") val exteriorType: String? = null,
|
||||
@SerialName("flooring_primary") val flooringPrimary: String? = null,
|
||||
@SerialName("landscaping_type") val landscapingType: String? = null,
|
||||
@SerialName("created_at") val createdAt: String,
|
||||
@SerialName("updated_at") val updatedAt: String
|
||||
) {
|
||||
@@ -94,7 +108,21 @@ data class ResidenceCreateRequest(
|
||||
val description: String? = null,
|
||||
@SerialName("purchase_date") val purchaseDate: String? = null,
|
||||
@SerialName("purchase_price") val purchasePrice: Double? = null,
|
||||
@SerialName("is_primary") val isPrimary: Boolean? = null
|
||||
@SerialName("is_primary") val isPrimary: Boolean? = null,
|
||||
@SerialName("heating_type") val heatingType: String? = null,
|
||||
@SerialName("cooling_type") val coolingType: String? = null,
|
||||
@SerialName("water_heater_type") val waterHeaterType: String? = null,
|
||||
@SerialName("roof_type") val roofType: String? = null,
|
||||
@SerialName("has_pool") val hasPool: Boolean? = null,
|
||||
@SerialName("has_sprinkler_system") val hasSprinklerSystem: Boolean? = null,
|
||||
@SerialName("has_septic") val hasSeptic: Boolean? = null,
|
||||
@SerialName("has_fireplace") val hasFireplace: Boolean? = null,
|
||||
@SerialName("has_garage") val hasGarage: Boolean? = null,
|
||||
@SerialName("has_basement") val hasBasement: Boolean? = null,
|
||||
@SerialName("has_attic") val hasAttic: Boolean? = null,
|
||||
@SerialName("exterior_type") val exteriorType: String? = null,
|
||||
@SerialName("flooring_primary") val flooringPrimary: String? = null,
|
||||
@SerialName("landscaping_type") val landscapingType: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -118,7 +146,21 @@ data class ResidenceUpdateRequest(
|
||||
val description: String? = null,
|
||||
@SerialName("purchase_date") val purchaseDate: String? = null,
|
||||
@SerialName("purchase_price") val purchasePrice: Double? = null,
|
||||
@SerialName("is_primary") val isPrimary: Boolean? = null
|
||||
@SerialName("is_primary") val isPrimary: Boolean? = null,
|
||||
@SerialName("heating_type") val heatingType: String? = null,
|
||||
@SerialName("cooling_type") val coolingType: String? = null,
|
||||
@SerialName("water_heater_type") val waterHeaterType: String? = null,
|
||||
@SerialName("roof_type") val roofType: String? = null,
|
||||
@SerialName("has_pool") val hasPool: Boolean? = null,
|
||||
@SerialName("has_sprinkler_system") val hasSprinklerSystem: Boolean? = null,
|
||||
@SerialName("has_septic") val hasSeptic: Boolean? = null,
|
||||
@SerialName("has_fireplace") val hasFireplace: Boolean? = null,
|
||||
@SerialName("has_garage") val hasGarage: Boolean? = null,
|
||||
@SerialName("has_basement") val hasBasement: Boolean? = null,
|
||||
@SerialName("has_attic") val hasAttic: Boolean? = null,
|
||||
@SerialName("exterior_type") val exteriorType: String? = null,
|
||||
@SerialName("flooring_primary") val flooringPrimary: String? = null,
|
||||
@SerialName("landscaping_type") val landscapingType: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,37 @@ data class TaskCompletionCreateRequest(
|
||||
val notes: String? = null,
|
||||
@SerialName("actual_cost") val actualCost: Double? = null,
|
||||
val rating: Int? = null, // 1-5 star rating
|
||||
@SerialName("image_urls") val imageUrls: List<String>? = null // Multiple image URLs
|
||||
@SerialName("upload_ids") val uploadIds: List<Int>? = null // pending_uploads.id values from /api/uploads/presign + direct B2 POST
|
||||
)
|
||||
|
||||
/**
|
||||
* Presigned upload session — request body for POST /api/uploads/presign.
|
||||
*
|
||||
* Category: "completion" | "document_image" | "document_file"
|
||||
* ContentType: the MIME type the client will upload (must match the policy
|
||||
* exactly when POSTing to B2).
|
||||
* ContentLength: byte count of the upload (server permits ±256 bytes slack).
|
||||
*/
|
||||
@Serializable
|
||||
data class PresignUploadRequest(
|
||||
val category: String,
|
||||
@SerialName("content_type") val contentType: String,
|
||||
@SerialName("content_length") val contentLength: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* Presigned upload session — response from POST /api/uploads/presign.
|
||||
*
|
||||
* The client uses [uploadUrl] + [fields] to perform a multipart/form-data
|
||||
* POST directly to B2, then passes [id] back in the upload_ids[] field of
|
||||
* the next /api/task-completions/ or /api/documents/ create call.
|
||||
*/
|
||||
@Serializable
|
||||
data class PresignUploadResponse(
|
||||
val id: Int,
|
||||
@SerialName("upload_url") val uploadUrl: String,
|
||||
val fields: Map<String, String>,
|
||||
val key: String,
|
||||
@SerialName("expires_at") val expiresAt: String
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.tt.honeyDue.models
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A single task suggestion with relevance scoring from the backend.
|
||||
*/
|
||||
@Serializable
|
||||
data class TaskSuggestionResponse(
|
||||
val template: TaskTemplate,
|
||||
@SerialName("relevance_score") val relevanceScore: Double,
|
||||
@SerialName("match_reasons") val matchReasons: List<String>
|
||||
)
|
||||
|
||||
/**
|
||||
* Response wrapper for task suggestions endpoint.
|
||||
*/
|
||||
@Serializable
|
||||
data class TaskSuggestionsResponse(
|
||||
val suggestions: List<TaskSuggestionResponse>,
|
||||
@SerialName("total_count") val totalCount: Int,
|
||||
@SerialName("profile_completeness") val profileCompleteness: Double
|
||||
)
|
||||
@@ -27,6 +27,7 @@ object APILayer {
|
||||
private val notificationApi = NotificationApi()
|
||||
private val subscriptionApi = SubscriptionApi()
|
||||
private val taskTemplateApi = TaskTemplateApi()
|
||||
private val uploadApi = UploadApi()
|
||||
|
||||
// ==================== Initialization Guards ====================
|
||||
|
||||
@@ -588,37 +589,23 @@ object APILayer {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns kanban data for a single residence. Single source of truth
|
||||
* is `_allTasks`; this function ensures it's fresh, then filters.
|
||||
*
|
||||
* Replaces the previous 3-path implementation (per-residence cache →
|
||||
* filter from allTasks → API) that produced inconsistent results
|
||||
* when the per-residence cache slot was empty but `_allTasks` was
|
||||
* stale. Phase 3 deletes the per-residence cache entirely.
|
||||
*/
|
||||
suspend fun getTasksByResidence(residenceId: Int, forceRefresh: Boolean = false): ApiResult<TaskColumnsResponse> {
|
||||
// 1. Check residence-specific cache first
|
||||
if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksByResidenceCacheTime[residenceId] ?: 0L)) {
|
||||
val cached = DataManager.tasksByResidence.value[residenceId]
|
||||
if (cached != null) {
|
||||
return ApiResult.Success(cached)
|
||||
}
|
||||
}
|
||||
val allTasksResult = getTasks(forceRefresh = forceRefresh)
|
||||
if (allTasksResult is ApiResult.Error) return allTasksResult
|
||||
|
||||
// 2. Try filtering from allTasks cache before hitting API (optimization)
|
||||
// This avoids a redundant API call when we already have all tasks loaded
|
||||
if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksCacheTime)) {
|
||||
val filtered = DataManager.getTasksForResidence(residenceId)
|
||||
if (filtered != null) {
|
||||
// Cache the filtered result for future use
|
||||
DataManager.setTasksForResidence(residenceId, filtered)
|
||||
?: return ApiResult.Error("Tasks unavailable", 0)
|
||||
return ApiResult.Success(filtered)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback: Fetch from API
|
||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
val result = taskApi.getTasksByResidence(token, residenceId)
|
||||
|
||||
// Update DataManager on success
|
||||
if (result is ApiResult.Success) {
|
||||
DataManager.setTasksForResidence(residenceId, result.data)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun createTask(request: TaskCreateRequest): ApiResult<TaskResponse> {
|
||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
@@ -638,6 +625,31 @@ object APILayer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically creates 1-50 tasks via POST /api/tasks/bulk/. The whole
|
||||
* batch succeeds or fails together on the server. On success, force-
|
||||
* refreshes _allTasks from the server — the server is the
|
||||
* authoritative kanban categorizer, and a single round-trip
|
||||
* eliminates any drift between the per-task `kanbanColumn` hint and
|
||||
* the global kanban view.
|
||||
*
|
||||
* This is the bug-class fix for gitea#2: the previous per-task
|
||||
* updateTask loop was a no-op when _allTasks was null (fresh launch
|
||||
* after onboarding), silently dropping the new tasks from cache.
|
||||
*/
|
||||
suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
|
||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
val result = taskApi.bulkCreateTasks(token, request)
|
||||
|
||||
if (result is ApiResult.Success) {
|
||||
DataManager.setTotalSummary(result.data.summary)
|
||||
// Authoritative refresh — replaces any placeholder kanban
|
||||
// shell from updateTask with proper server data.
|
||||
getTasks(forceRefresh = true)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun updateTask(id: Int, request: TaskCreateRequest): ApiResult<TaskResponse> {
|
||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
val result = taskApi.updateTask(token, id, request)
|
||||
@@ -772,30 +784,6 @@ object APILayer {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createTaskCompletionWithImages(
|
||||
request: TaskCompletionCreateRequest,
|
||||
images: List<ByteArray>,
|
||||
imageFileNames: List<String>
|
||||
): ApiResult<TaskCompletionResponse> {
|
||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
val result = taskCompletionApi.createCompletionWithImages(token, request, images, imageFileNames)
|
||||
|
||||
if (result is ApiResult.Success) {
|
||||
// Update summary from response - eliminates need for separate getSummary call
|
||||
DataManager.setTotalSummary(result.data.summary)
|
||||
// The response includes the updated task, update it in DataManager
|
||||
result.data.data.updatedTask?.let { updatedTask ->
|
||||
DataManager.updateTask(updatedTask)
|
||||
}
|
||||
return ApiResult.Success(result.data.data)
|
||||
}
|
||||
|
||||
return when (result) {
|
||||
is ApiResult.Error -> result
|
||||
else -> ApiResult.Error("Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all completions for a specific task
|
||||
*/
|
||||
@@ -1201,12 +1189,11 @@ object APILayer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task templates filtered by climate region.
|
||||
* Accepts either a state abbreviation or ZIP code — backend resolves to climate zone.
|
||||
* This calls the API directly since regional templates are not cached in seeded data.
|
||||
* Get personalized task suggestions for a residence based on its home profile.
|
||||
*/
|
||||
suspend fun getRegionalTemplates(state: String? = null, zip: String? = null): ApiResult<List<TaskTemplate>> {
|
||||
return taskTemplateApi.getTemplatesByRegion(state = state, zip = zip)
|
||||
suspend fun getTaskSuggestions(residenceId: Int): ApiResult<TaskSuggestionsResponse> {
|
||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
return taskTemplateApi.getTaskSuggestions(token, residenceId)
|
||||
}
|
||||
|
||||
// ==================== Auth Operations ====================
|
||||
@@ -1365,6 +1352,42 @@ object APILayer {
|
||||
return result
|
||||
}
|
||||
|
||||
// ==================== Upload Operations ====================
|
||||
|
||||
/**
|
||||
* Direct-to-B2 image upload. The bytes are POSTed straight to Backblaze
|
||||
* — they never touch our API server. Returns the pending_uploads.id
|
||||
* which the caller passes back via `upload_ids[]` on the next entity-
|
||||
* creation call (task completion, document, etc.).
|
||||
*
|
||||
* Caller responsibilities:
|
||||
* - Pre-downsample to a sensible size before calling. Use the
|
||||
* platform-specific ImageDownsampler (Android) or
|
||||
* ImageDownsampler.swift (iOS).
|
||||
* - Pass [contentType] matching the bytes (typically "image/jpeg").
|
||||
* - Pass a [fileName] for B2's metadata. Need not be unique — the
|
||||
* server picks the actual storage key.
|
||||
*
|
||||
* Errors at either step (presign or B2 POST) surface as ApiResult.Error.
|
||||
* Partial state (presign succeeded but B2 POST failed) is reaped by
|
||||
* the server-side cleanup cron within an hour.
|
||||
*/
|
||||
suspend fun uploadImage(
|
||||
category: String,
|
||||
contentType: String,
|
||||
bytes: ByteArray,
|
||||
fileName: String,
|
||||
): ApiResult<Int> {
|
||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
return uploadApi.uploadOne(
|
||||
token = token,
|
||||
category = category,
|
||||
contentType = contentType,
|
||||
data = bytes,
|
||||
fileName = fileName,
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== Notification Operations ====================
|
||||
|
||||
suspend fun registerDevice(request: DeviceRegistrationRequest): ApiResult<DeviceRegistrationResponse> {
|
||||
|
||||
@@ -66,6 +66,31 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically creates 1-50 tasks in a single transaction. Used by
|
||||
* onboarding and anywhere else that needs "all or nothing" task
|
||||
* creation. The server overrides every entry's residence_id with the
|
||||
* top-level request.residenceId.
|
||||
*/
|
||||
suspend fun bulkCreateTasks(token: String, request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
|
||||
return try {
|
||||
val response = client.post("$baseUrl/tasks/bulk/") {
|
||||
header("Authorization", "Token $token")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request)
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
val errorMessage = ErrorParser.parseError(response)
|
||||
ApiResult.Error(errorMessage, response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateTask(token: String, id: Int, request: TaskCreateRequest): ApiResult<WithSummaryResponse<TaskResponse>> {
|
||||
return try {
|
||||
val response = client.put("$baseUrl/tasks/$id/") {
|
||||
|
||||
@@ -94,47 +94,4 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createCompletionWithImages(
|
||||
token: String,
|
||||
request: TaskCompletionCreateRequest,
|
||||
images: List<ByteArray> = emptyList(),
|
||||
imageFileNames: List<String> = emptyList()
|
||||
): ApiResult<WithSummaryResponse<TaskCompletionResponse>> {
|
||||
return try {
|
||||
val response = client.submitFormWithBinaryData(
|
||||
url = "$baseUrl/task-completions/",
|
||||
formData = formData {
|
||||
// Add text fields
|
||||
append("task_id", request.taskId.toString())
|
||||
request.completedAt?.let { append("completed_at", it) }
|
||||
request.actualCost?.let { append("actual_cost", it.toString()) }
|
||||
request.notes?.let { append("notes", it) }
|
||||
request.rating?.let { append("rating", it.toString()) }
|
||||
|
||||
// Add image files
|
||||
images.forEachIndexed { index, imageBytes ->
|
||||
val fileName = imageFileNames.getOrNull(index) ?: "image_$index.jpg"
|
||||
append(
|
||||
"images",
|
||||
imageBytes,
|
||||
Headers.build {
|
||||
append(HttpHeaders.ContentType, "image/jpeg")
|
||||
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
header("Authorization", "Token $token")
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
ApiResult.Error("Failed to create completion with images", response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.tt.honeyDue.network
|
||||
|
||||
import com.tt.honeyDue.models.TaskTemplate
|
||||
import com.tt.honeyDue.models.TaskSuggestionsResponse
|
||||
import com.tt.honeyDue.models.TaskTemplatesGroupedResponse
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
@@ -85,20 +86,20 @@ class TaskTemplateApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get templates filtered by climate region.
|
||||
* Accepts either a state abbreviation or ZIP code — backend resolves to climate zone.
|
||||
* Get personalized task suggestions for a residence based on its home profile.
|
||||
* Requires authentication.
|
||||
*/
|
||||
suspend fun getTemplatesByRegion(state: String? = null, zip: String? = null): ApiResult<List<TaskTemplate>> {
|
||||
suspend fun getTaskSuggestions(token: String, residenceId: Int): ApiResult<TaskSuggestionsResponse> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/tasks/templates/by-region/") {
|
||||
state?.let { parameter("state", it) }
|
||||
zip?.let { parameter("zip", it) }
|
||||
val response = client.get("$baseUrl/tasks/suggestions/") {
|
||||
header("Authorization", "Token $token")
|
||||
parameter("residence_id", residenceId)
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
ApiResult.Error("Failed to fetch regional templates", response.status.value)
|
||||
ApiResult.Error("Failed to fetch task suggestions", response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.tt.honeyDue.network
|
||||
|
||||
import com.tt.honeyDue.models.PresignUploadRequest
|
||||
import com.tt.honeyDue.models.PresignUploadResponse
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.request.forms.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.utils.io.core.*
|
||||
|
||||
/**
|
||||
* Three-step direct-to-B2 upload helper.
|
||||
*
|
||||
* Step 1: [presign] — call POST /api/uploads/presign on our API. Returns a
|
||||
* B2 POST policy plus form fields the client needs to perform the
|
||||
* direct upload.
|
||||
* Step 2: [postToStorage] — multipart/form-data POST straight to B2.
|
||||
* Bytes never traverse our API server.
|
||||
* Step 3: caller invokes the relevant entity-creation endpoint
|
||||
* (POST /api/task-completions/, POST /api/documents/) with the
|
||||
* returned upload_id in the `upload_ids` field.
|
||||
*
|
||||
* iOS uses its own native equivalent (PresignedUploader.swift) for memory
|
||||
* reasons — Swift can stream a multipart body without buffering. Android
|
||||
* uses this Kotlin path which works fine for ≤10 MB images.
|
||||
*/
|
||||
class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
private val baseUrl = ApiClient.getBaseUrl()
|
||||
|
||||
/** Step 1 — request a signed POST policy. */
|
||||
suspend fun presign(
|
||||
token: String,
|
||||
category: String,
|
||||
contentType: String,
|
||||
contentLength: Long,
|
||||
): ApiResult<PresignUploadResponse> {
|
||||
return try {
|
||||
val response = client.post("$baseUrl/uploads/presign/") {
|
||||
header("Authorization", "Token $token")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(PresignUploadRequest(category, contentType, contentLength))
|
||||
}
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
ApiResult.Error(
|
||||
when (response.status.value) {
|
||||
413 -> "That photo is too large after resizing."
|
||||
422 -> "That image format isn't supported."
|
||||
429 -> "Too many uploads in flight; try again shortly."
|
||||
else -> "Couldn't start upload (HTTP ${response.status.value})."
|
||||
},
|
||||
response.status.value,
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Network error during presign")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2 — POST `data` directly to B2 using the signed policy fields.
|
||||
*
|
||||
* The S3 POST policy spec requires every signed field to appear before
|
||||
* the file part, and `key` + `Content-Type` must match the policy
|
||||
* exactly. Ktor's MultiPartFormDataContent preserves insertion order
|
||||
* for the appended parts.
|
||||
*/
|
||||
suspend fun postToStorage(
|
||||
uploadUrl: String,
|
||||
fields: Map<String, String>,
|
||||
data: ByteArray,
|
||||
contentType: String,
|
||||
fileName: String,
|
||||
): ApiResult<Unit> {
|
||||
return try {
|
||||
val parts = formData {
|
||||
// Stable order: signed fields first, then file. We rely on
|
||||
// Ktor preserving the order in which append() is called.
|
||||
fields.forEach { (k, v) -> append(k, v) }
|
||||
append(
|
||||
key = "file",
|
||||
value = data,
|
||||
headers = Headers.build {
|
||||
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"")
|
||||
append(HttpHeaders.ContentType, contentType)
|
||||
},
|
||||
)
|
||||
}
|
||||
val response = client.submitFormWithBinaryData(
|
||||
url = uploadUrl,
|
||||
formData = parts,
|
||||
)
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(Unit)
|
||||
} else {
|
||||
val body = try {
|
||||
response.bodyAsText()
|
||||
} catch (_: Throwable) {
|
||||
""
|
||||
}
|
||||
ApiResult.Error(
|
||||
"Upload to storage failed (HTTP ${response.status.value}): $body",
|
||||
response.status.value,
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Network error during upload")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 + Step 2 in one call. Returns the upload_id the caller passes
|
||||
* back via upload_ids[] on the entity-creation endpoint.
|
||||
*
|
||||
* Errors at either step short-circuit and surface up — the partial
|
||||
* pending_uploads row created at presign time will be reaped by the
|
||||
* server-side hourly cleanup cron.
|
||||
*/
|
||||
suspend fun uploadOne(
|
||||
token: String,
|
||||
category: String,
|
||||
contentType: String,
|
||||
data: ByteArray,
|
||||
fileName: String,
|
||||
): ApiResult<Int> {
|
||||
val presignResult = presign(token, category, contentType, data.size.toLong())
|
||||
val presigned = (presignResult as? ApiResult.Success)?.data
|
||||
?: return ApiResult.Error(
|
||||
(presignResult as? ApiResult.Error)?.message ?: "Presign failed",
|
||||
(presignResult as? ApiResult.Error)?.code,
|
||||
)
|
||||
|
||||
val postResult = postToStorage(
|
||||
uploadUrl = presigned.uploadUrl,
|
||||
fields = presigned.fields,
|
||||
data = data,
|
||||
contentType = contentType,
|
||||
fileName = fileName,
|
||||
)
|
||||
return when (postResult) {
|
||||
is ApiResult.Success -> ApiResult.Success(presigned.id)
|
||||
is ApiResult.Error -> postResult
|
||||
else -> ApiResult.Error("Upload failed in unknown state")
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -373,7 +373,8 @@ fun CompleteTaskDialog(
|
||||
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
|
||||
notes = notesWithContractor,
|
||||
rating = rating,
|
||||
imageUrls = null // Images uploaded separately and URLs added by handler
|
||||
// upload_ids populated by the ViewModel after each
|
||||
// image is uploaded directly to B2.
|
||||
),
|
||||
selectedImages
|
||||
)
|
||||
|
||||
@@ -405,7 +405,8 @@ fun CompleteTaskScreen(
|
||||
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
|
||||
notes = notesWithContractor,
|
||||
rating = rating,
|
||||
imageUrls = null
|
||||
// upload_ids populated by the ViewModel after each
|
||||
// image is uploaded directly to B2.
|
||||
),
|
||||
selectedImages
|
||||
)
|
||||
|
||||
+588
-226
File diff suppressed because it is too large
Load Diff
+378
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
+24
-15
@@ -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
|
||||
}
|
||||
|
||||
+151
-34
@@ -4,11 +4,13 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tt.honeyDue.data.DataManager
|
||||
import com.tt.honeyDue.models.AuthResponse
|
||||
import com.tt.honeyDue.models.BulkCreateTasksRequest
|
||||
import com.tt.honeyDue.models.LoginRequest
|
||||
import com.tt.honeyDue.models.RegisterRequest
|
||||
import com.tt.honeyDue.models.ResidenceCreateRequest
|
||||
import com.tt.honeyDue.models.TaskCreateRequest
|
||||
import com.tt.honeyDue.models.TaskTemplate
|
||||
import com.tt.honeyDue.models.TaskSuggestionsResponse
|
||||
import com.tt.honeyDue.models.TaskTemplatesGroupedResponse
|
||||
import com.tt.honeyDue.models.VerifyEmailRequest
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.network.APILayer
|
||||
@@ -37,6 +39,7 @@ enum class OnboardingStep {
|
||||
VERIFY_EMAIL,
|
||||
JOIN_RESIDENCE,
|
||||
RESIDENCE_LOCATION,
|
||||
HOME_PROFILE,
|
||||
FIRST_TASK,
|
||||
SUBSCRIPTION_UPSELL
|
||||
}
|
||||
@@ -78,18 +81,67 @@ class OnboardingViewModel : ViewModel() {
|
||||
private val _joinResidenceState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
||||
val joinResidenceState: StateFlow<ApiResult<Unit>> = _joinResidenceState
|
||||
|
||||
// Task creation state
|
||||
// Task creation state (bulk create)
|
||||
private val _createTasksState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
||||
val createTasksState: StateFlow<ApiResult<Unit>> = _createTasksState
|
||||
|
||||
// Regional templates state
|
||||
private val _regionalTemplates = MutableStateFlow<ApiResult<List<TaskTemplate>>>(ApiResult.Idle)
|
||||
val regionalTemplates: StateFlow<ApiResult<List<TaskTemplate>>> = _regionalTemplates
|
||||
// Grouped templates for the Browse tab on the First-Task screen
|
||||
private val _templatesGroupedState = MutableStateFlow<ApiResult<TaskTemplatesGroupedResponse>>(ApiResult.Idle)
|
||||
val templatesGroupedState: StateFlow<ApiResult<TaskTemplatesGroupedResponse>> = _templatesGroupedState
|
||||
|
||||
// ZIP code entered during location step (persisted on residence)
|
||||
// ZIP code entered during onboarding (persisted on residence). Still
|
||||
// collected so the suggestion service can factor in the user's climate
|
||||
// zone as its 15th scoring condition.
|
||||
private val _postalCode = MutableStateFlow("")
|
||||
val postalCode: StateFlow<String> = _postalCode
|
||||
|
||||
// Home profile fields
|
||||
private val _heatingType = MutableStateFlow<String?>(null)
|
||||
val heatingType: StateFlow<String?> = _heatingType
|
||||
|
||||
private val _coolingType = MutableStateFlow<String?>(null)
|
||||
val coolingType: StateFlow<String?> = _coolingType
|
||||
|
||||
private val _waterHeaterType = MutableStateFlow<String?>(null)
|
||||
val waterHeaterType: StateFlow<String?> = _waterHeaterType
|
||||
|
||||
private val _roofType = MutableStateFlow<String?>(null)
|
||||
val roofType: StateFlow<String?> = _roofType
|
||||
|
||||
private val _hasPool = MutableStateFlow(false)
|
||||
val hasPool: StateFlow<Boolean> = _hasPool
|
||||
|
||||
private val _hasSprinklerSystem = MutableStateFlow(false)
|
||||
val hasSprinklerSystem: StateFlow<Boolean> = _hasSprinklerSystem
|
||||
|
||||
private val _hasSeptic = MutableStateFlow(false)
|
||||
val hasSeptic: StateFlow<Boolean> = _hasSeptic
|
||||
|
||||
private val _hasFireplace = MutableStateFlow(false)
|
||||
val hasFireplace: StateFlow<Boolean> = _hasFireplace
|
||||
|
||||
private val _hasGarage = MutableStateFlow(false)
|
||||
val hasGarage: StateFlow<Boolean> = _hasGarage
|
||||
|
||||
private val _hasBasement = MutableStateFlow(false)
|
||||
val hasBasement: StateFlow<Boolean> = _hasBasement
|
||||
|
||||
private val _hasAttic = MutableStateFlow(false)
|
||||
val hasAttic: StateFlow<Boolean> = _hasAttic
|
||||
|
||||
private val _exteriorType = MutableStateFlow<String?>(null)
|
||||
val exteriorType: StateFlow<String?> = _exteriorType
|
||||
|
||||
private val _flooringPrimary = MutableStateFlow<String?>(null)
|
||||
val flooringPrimary: StateFlow<String?> = _flooringPrimary
|
||||
|
||||
private val _landscapingType = MutableStateFlow<String?>(null)
|
||||
val landscapingType: StateFlow<String?> = _landscapingType
|
||||
|
||||
// Task suggestions state
|
||||
private val _suggestionsState = MutableStateFlow<ApiResult<TaskSuggestionsResponse>>(ApiResult.Idle)
|
||||
val suggestionsState: StateFlow<ApiResult<TaskSuggestionsResponse>> = _suggestionsState
|
||||
|
||||
// Whether onboarding is complete
|
||||
private val _isComplete = MutableStateFlow(false)
|
||||
val isComplete: StateFlow<Boolean> = _isComplete
|
||||
@@ -106,6 +158,32 @@ class OnboardingViewModel : ViewModel() {
|
||||
_shareCode.value = code
|
||||
}
|
||||
|
||||
// Home profile setters
|
||||
fun setHeatingType(value: String?) { _heatingType.value = value }
|
||||
fun setCoolingType(value: String?) { _coolingType.value = value }
|
||||
fun setWaterHeaterType(value: String?) { _waterHeaterType.value = value }
|
||||
fun setRoofType(value: String?) { _roofType.value = value }
|
||||
fun setHasPool(value: Boolean) { _hasPool.value = value }
|
||||
fun setHasSprinklerSystem(value: Boolean) { _hasSprinklerSystem.value = value }
|
||||
fun setHasSeptic(value: Boolean) { _hasSeptic.value = value }
|
||||
fun setHasFireplace(value: Boolean) { _hasFireplace.value = value }
|
||||
fun setHasGarage(value: Boolean) { _hasGarage.value = value }
|
||||
fun setHasBasement(value: Boolean) { _hasBasement.value = value }
|
||||
fun setHasAttic(value: Boolean) { _hasAttic.value = value }
|
||||
fun setExteriorType(value: String?) { _exteriorType.value = value }
|
||||
fun setFlooringPrimary(value: String?) { _flooringPrimary.value = value }
|
||||
fun setLandscapingType(value: String?) { _landscapingType.value = value }
|
||||
|
||||
/**
|
||||
* Load personalized task suggestions for the given residence.
|
||||
*/
|
||||
fun loadSuggestions(residenceId: Int) {
|
||||
viewModelScope.launch {
|
||||
_suggestionsState.value = ApiResult.Loading
|
||||
_suggestionsState.value = APILayer.getTaskSuggestions(residenceId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to the next step in the flow
|
||||
* Flow: Welcome → Features → Name Residence → Create Account → Verify → Tasks → Upsell
|
||||
@@ -126,12 +204,19 @@ class OnboardingViewModel : ViewModel() {
|
||||
if (_userIntent.value == OnboardingIntent.JOIN_EXISTING) {
|
||||
OnboardingStep.JOIN_RESIDENCE
|
||||
} else {
|
||||
OnboardingStep.RESIDENCE_LOCATION
|
||||
OnboardingStep.HOME_PROFILE
|
||||
}
|
||||
}
|
||||
OnboardingStep.JOIN_RESIDENCE -> OnboardingStep.SUBSCRIPTION_UPSELL
|
||||
OnboardingStep.RESIDENCE_LOCATION -> OnboardingStep.FIRST_TASK
|
||||
OnboardingStep.FIRST_TASK -> OnboardingStep.SUBSCRIPTION_UPSELL
|
||||
OnboardingStep.JOIN_RESIDENCE -> {
|
||||
completeOnboarding()
|
||||
OnboardingStep.JOIN_RESIDENCE
|
||||
}
|
||||
OnboardingStep.RESIDENCE_LOCATION -> OnboardingStep.HOME_PROFILE // Skip past if somehow reached
|
||||
OnboardingStep.HOME_PROFILE -> OnboardingStep.FIRST_TASK
|
||||
OnboardingStep.FIRST_TASK -> {
|
||||
completeOnboarding()
|
||||
OnboardingStep.FIRST_TASK
|
||||
}
|
||||
OnboardingStep.SUBSCRIPTION_UPSELL -> {
|
||||
completeOnboarding()
|
||||
OnboardingStep.SUBSCRIPTION_UPSELL
|
||||
@@ -171,9 +256,9 @@ class OnboardingViewModel : ViewModel() {
|
||||
fun skipStep() {
|
||||
when (_currentStep.value) {
|
||||
OnboardingStep.VALUE_PROPS,
|
||||
OnboardingStep.HOME_PROFILE -> nextStep()
|
||||
OnboardingStep.JOIN_RESIDENCE,
|
||||
OnboardingStep.RESIDENCE_LOCATION,
|
||||
OnboardingStep.FIRST_TASK -> nextStep()
|
||||
OnboardingStep.FIRST_TASK,
|
||||
OnboardingStep.SUBSCRIPTION_UPSELL -> completeOnboarding()
|
||||
else -> {}
|
||||
}
|
||||
@@ -272,7 +357,21 @@ class OnboardingViewModel : ViewModel() {
|
||||
description = null,
|
||||
purchaseDate = null,
|
||||
purchasePrice = null,
|
||||
isPrimary = true
|
||||
isPrimary = true,
|
||||
heatingType = _heatingType.value,
|
||||
coolingType = _coolingType.value,
|
||||
waterHeaterType = _waterHeaterType.value,
|
||||
roofType = _roofType.value,
|
||||
hasPool = _hasPool.value.takeIf { it },
|
||||
hasSprinklerSystem = _hasSprinklerSystem.value.takeIf { it },
|
||||
hasSeptic = _hasSeptic.value.takeIf { it },
|
||||
hasFireplace = _hasFireplace.value.takeIf { it },
|
||||
hasGarage = _hasGarage.value.takeIf { it },
|
||||
hasBasement = _hasBasement.value.takeIf { it },
|
||||
hasAttic = _hasAttic.value.takeIf { it },
|
||||
exteriorType = _exteriorType.value,
|
||||
flooringPrimary = _flooringPrimary.value,
|
||||
landscapingType = _landscapingType.value
|
||||
)
|
||||
)
|
||||
|
||||
@@ -300,9 +399,15 @@ class OnboardingViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create selected tasks during onboarding
|
||||
* Create the user's selected tasks in a single atomic bulk request.
|
||||
* The server runs the inserts inside one transaction, so either every
|
||||
* entry lands or none do — no risk of a half-populated kanban board
|
||||
* if the network flakes mid-batch.
|
||||
*
|
||||
* [residenceId] overrides every entry's residence_id on the server side;
|
||||
* pass the just-created residence's ID.
|
||||
*/
|
||||
fun createTasks(taskRequests: List<TaskCreateRequest>) {
|
||||
fun createTasks(residenceId: Int, taskRequests: List<TaskCreateRequest>) {
|
||||
viewModelScope.launch {
|
||||
if (taskRequests.isEmpty()) {
|
||||
_createTasksState.value = ApiResult.Success(Unit)
|
||||
@@ -311,31 +416,28 @@ class OnboardingViewModel : ViewModel() {
|
||||
|
||||
_createTasksState.value = ApiResult.Loading
|
||||
|
||||
var successCount = 0
|
||||
for (request in taskRequests) {
|
||||
val result = APILayer.createTask(request)
|
||||
if (result is ApiResult.Success) {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
_createTasksState.value = if (successCount > 0) {
|
||||
ApiResult.Success(Unit)
|
||||
} else {
|
||||
ApiResult.Error("Failed to create tasks")
|
||||
val request = BulkCreateTasksRequest(
|
||||
residenceId = residenceId,
|
||||
tasks = taskRequests
|
||||
)
|
||||
_createTasksState.value = when (val result = APILayer.bulkCreateTasks(request)) {
|
||||
is ApiResult.Success -> ApiResult.Success(Unit)
|
||||
is ApiResult.Error -> ApiResult.Error(result.message, result.code)
|
||||
is ApiResult.Loading -> ApiResult.Loading
|
||||
is ApiResult.Idle -> ApiResult.Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load regional templates by ZIP code (backend resolves ZIP → state → climate zone).
|
||||
* Also stores the ZIP code for later use when creating the residence.
|
||||
* Load the flat template catalog grouped by category. Feeds the Browse
|
||||
* tab on the First-Task screen; no caching special-case because
|
||||
* APILayer.getTaskTemplatesGrouped already reads from DataManager first.
|
||||
*/
|
||||
fun loadRegionalTemplates(zip: String) {
|
||||
_postalCode.value = zip
|
||||
fun loadTemplatesGrouped(forceRefresh: Boolean = false) {
|
||||
viewModelScope.launch {
|
||||
_regionalTemplates.value = ApiResult.Loading
|
||||
_regionalTemplates.value = APILayer.getRegionalTemplates(zip = zip)
|
||||
_templatesGroupedState.value = ApiResult.Loading
|
||||
_templatesGroupedState.value = APILayer.getTaskTemplatesGrouped(forceRefresh = forceRefresh)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,8 +462,23 @@ class OnboardingViewModel : ViewModel() {
|
||||
_createResidenceState.value = ApiResult.Idle
|
||||
_joinResidenceState.value = ApiResult.Idle
|
||||
_createTasksState.value = ApiResult.Idle
|
||||
_regionalTemplates.value = ApiResult.Idle
|
||||
_templatesGroupedState.value = ApiResult.Idle
|
||||
_postalCode.value = ""
|
||||
_heatingType.value = null
|
||||
_coolingType.value = null
|
||||
_waterHeaterType.value = null
|
||||
_roofType.value = null
|
||||
_hasPool.value = false
|
||||
_hasSprinklerSystem.value = false
|
||||
_hasSeptic.value = false
|
||||
_hasFireplace.value = false
|
||||
_hasGarage.value = false
|
||||
_hasBasement.value = false
|
||||
_hasAttic.value = false
|
||||
_exteriorType.value = null
|
||||
_flooringPrimary.value = null
|
||||
_landscapingType.value = null
|
||||
_suggestionsState.value = ApiResult.Idle
|
||||
_isComplete.value = false
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.tt.honeyDue.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tt.honeyDue.data.DataManager
|
||||
import com.tt.honeyDue.models.Residence
|
||||
import com.tt.honeyDue.models.ResidenceCreateRequest
|
||||
import com.tt.honeyDue.models.TotalSummary
|
||||
@@ -11,7 +12,10 @@ import com.tt.honeyDue.models.ContractorSummary
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.network.APILayer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ResidenceViewModel : ViewModel() {
|
||||
@@ -28,8 +32,24 @@ class ResidenceViewModel : ViewModel() {
|
||||
private val _updateResidenceState = MutableStateFlow<ApiResult<Residence>>(ApiResult.Idle)
|
||||
val updateResidenceState: StateFlow<ApiResult<Residence>> = _updateResidenceState
|
||||
|
||||
private val _residenceTasksState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle)
|
||||
val residenceTasksState: StateFlow<ApiResult<TaskColumnsResponse>> = _residenceTasksState
|
||||
/// Residence-scoped kanban derived from `DataManager.allTasks` filtered
|
||||
/// by `_currentResidenceId`. Re-emits whenever either upstream changes,
|
||||
/// so the residence detail screen reacts to new tasks (created or
|
||||
/// completed elsewhere) without manual refresh. Replaces the previous
|
||||
/// imperative `_residenceTasksState` that was only written by
|
||||
/// loadResidenceTasks's API result and stayed stale otherwise.
|
||||
private val _currentResidenceId = MutableStateFlow<Int?>(null)
|
||||
val residenceTasksState: StateFlow<ApiResult<TaskColumnsResponse>> =
|
||||
combine(DataManager.allTasks, _currentResidenceId) { all, id ->
|
||||
when {
|
||||
id == null -> ApiResult.Idle
|
||||
all == null -> ApiResult.Loading
|
||||
else -> {
|
||||
val filtered = DataManager.getTasksForResidence(id)
|
||||
if (filtered != null) ApiResult.Success(filtered) else ApiResult.Loading
|
||||
}
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ApiResult.Idle)
|
||||
|
||||
private val _myResidencesState = MutableStateFlow<ApiResult<MyResidencesResponse>>(ApiResult.Idle)
|
||||
val myResidencesState: StateFlow<ApiResult<MyResidencesResponse>> = _myResidencesState
|
||||
@@ -85,13 +105,16 @@ class ResidenceViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun resetResidenceTasksState() {
|
||||
_residenceTasksState.value = ApiResult.Idle
|
||||
_currentResidenceId.value = null
|
||||
}
|
||||
|
||||
fun loadResidenceTasks(residenceId: Int) {
|
||||
fun loadResidenceTasks(residenceId: Int, forceRefresh: Boolean = false) {
|
||||
_currentResidenceId.value = residenceId
|
||||
// Trigger an _allTasks refresh in the background. The combine flow
|
||||
// above re-emits Success when allTasks lands, so the screen
|
||||
// re-renders without needing the result here.
|
||||
viewModelScope.launch {
|
||||
_residenceTasksState.value = ApiResult.Loading
|
||||
_residenceTasksState.value = APILayer.getTasksByResidence(residenceId)
|
||||
APILayer.getTasks(forceRefresh = forceRefresh)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+42
-19
@@ -25,38 +25,61 @@ class TaskCompletionViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create task completion with images.
|
||||
* Create task completion with images, using the presigned-URL upload flow.
|
||||
*
|
||||
* For each image: compress, presign + POST direct to B2, collect the
|
||||
* upload_id. Once all uploads succeed, create the completion with the
|
||||
* collected upload_ids in a single JSON request. Bytes never traverse
|
||||
* our API server.
|
||||
*
|
||||
* If any individual upload fails, the whole batch fails — partial
|
||||
* pending_uploads rows are reaped server-side by the hourly cleanup
|
||||
* cron, so there's nothing to clean up client-side.
|
||||
*
|
||||
* @param request The completion request data
|
||||
* @param images List of ImageData (from platform-specific image pickers)
|
||||
*/
|
||||
fun createTaskCompletionWithImages(
|
||||
request: TaskCompletionCreateRequest,
|
||||
images: List<com.tt.honeyDue.platform.ImageData> = emptyList()
|
||||
images: List<com.tt.honeyDue.platform.ImageData> = emptyList(),
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_createCompletionState.value = ApiResult.Loading
|
||||
|
||||
// Compress images and prepare for upload
|
||||
val compressedImages = images.map { ImageCompressor.compressImage(it) }
|
||||
val imageFileNames = images.mapIndexed { index, image ->
|
||||
// Always use .jpg extension since we compress to JPEG
|
||||
val baseName = image.fileName.ifBlank { "completion_$index" }
|
||||
if (baseName.endsWith(".jpg", ignoreCase = true) ||
|
||||
baseName.endsWith(".jpeg", ignoreCase = true)) {
|
||||
baseName
|
||||
} else {
|
||||
// Remove any existing extension and add .jpg
|
||||
baseName.substringBeforeLast('.', baseName) + ".jpg"
|
||||
val uploadIds = mutableListOf<Int>()
|
||||
for ((index, image) in images.withIndex()) {
|
||||
val compressed = ImageCompressor.compressImage(image)
|
||||
val fileName = run {
|
||||
val base = image.fileName.ifBlank { "completion_$index" }
|
||||
if (base.endsWith(".jpg", ignoreCase = true) ||
|
||||
base.endsWith(".jpeg", ignoreCase = true)
|
||||
) base else base.substringBeforeLast('.', base) + ".jpg"
|
||||
}
|
||||
val uploadResult = APILayer.uploadImage(
|
||||
category = "completion",
|
||||
contentType = "image/jpeg",
|
||||
bytes = compressed,
|
||||
fileName = fileName,
|
||||
)
|
||||
when (uploadResult) {
|
||||
is ApiResult.Success -> uploadIds += uploadResult.data
|
||||
is ApiResult.Error -> {
|
||||
_createCompletionState.value = ApiResult.Error(uploadResult.message, uploadResult.code)
|
||||
return@launch
|
||||
}
|
||||
else -> {
|
||||
_createCompletionState.value = ApiResult.Error("Upload failed in unexpected state")
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use APILayer which handles DataManager updates and summary refresh
|
||||
_createCompletionState.value = APILayer.createTaskCompletionWithImages(
|
||||
request = request,
|
||||
images = compressedImages,
|
||||
imageFileNames = imageFileNames
|
||||
)
|
||||
val withUploads = if (uploadIds.isNotEmpty()) {
|
||||
request.copy(uploadIds = uploadIds.toList())
|
||||
} else {
|
||||
request
|
||||
}
|
||||
_createCompletionState.value = APILayer.createTaskCompletion(withUploads)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,6 @@ class TaskViewModel : ViewModel() {
|
||||
private val _tasksState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle)
|
||||
val tasksState: StateFlow<ApiResult<TaskColumnsResponse>> = _tasksState
|
||||
|
||||
private val _tasksByResidenceState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle)
|
||||
val tasksByResidenceState: StateFlow<ApiResult<TaskColumnsResponse>> = _tasksByResidenceState
|
||||
|
||||
private val _taskAddNewCustomTaskState = MutableStateFlow<ApiResult<CustomTask>>(ApiResult.Idle)
|
||||
val taskAddNewCustomTaskState: StateFlow<ApiResult<CustomTask>> = _taskAddNewCustomTaskState
|
||||
|
||||
@@ -35,16 +32,6 @@ class TaskViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun loadTasksByResidence(residenceId: Int, forceRefresh: Boolean = false) {
|
||||
viewModelScope.launch {
|
||||
_tasksByResidenceState.value = ApiResult.Loading
|
||||
_tasksByResidenceState.value = APILayer.getTasksByResidence(
|
||||
residenceId = residenceId,
|
||||
forceRefresh = forceRefresh
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createNewTask(request: TaskCreateRequest) {
|
||||
println("TaskViewModel: createNewTask called with $request")
|
||||
viewModelScope.launch {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.tt.honeyDue.data
|
||||
|
||||
import com.tt.honeyDue.models.TaskResponse
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.BeforeTest
|
||||
|
||||
/**
|
||||
* Regression tests for the gitea#2 task-cache bug:
|
||||
* `DataManager.updateTask` was a no-op when both `_allTasks` was null AND
|
||||
* `_tasksByResidence[residenceId]` was empty — exactly the cache state
|
||||
* after a fresh register-then-bulkCreateTasks flow. The just-created
|
||||
* tasks would only appear after an app restart.
|
||||
*
|
||||
* After the fix, `updateTask` must seed `_allTasks` from empty rather
|
||||
* than skipping the update.
|
||||
*/
|
||||
class DataManagerTaskCacheTest {
|
||||
|
||||
@BeforeTest
|
||||
fun resetState() {
|
||||
DataManager.clear()
|
||||
}
|
||||
|
||||
/// Onboarding-flow scenario: brand-new user, fresh launch, no kanban
|
||||
/// has ever been fetched, then a task arrives via bulkCreateTasks →
|
||||
/// DataManager.updateTask. The new task MUST land in `_allTasks` and
|
||||
/// be visible to any observer.
|
||||
@Test
|
||||
fun updateTask_seedsAllTasks_whenCacheIsEmpty() {
|
||||
// Given: fresh DataManager, kanban never loaded
|
||||
assertEquals(null, DataManager.allTasks.value, "_allTasks must start null after clear()")
|
||||
|
||||
// When: a new task arrives via the same path bulkCreateTasks uses
|
||||
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
|
||||
|
||||
// Then: _allTasks must contain that task in the right column
|
||||
val allTasks = DataManager.allTasks.value
|
||||
assertNotNull(allTasks, "updateTask must seed _allTasks even when it was null")
|
||||
|
||||
val upcoming = allTasks.columns.firstOrNull { it.name == "upcoming_tasks" }
|
||||
assertNotNull(upcoming, "the seeded kanban must include an upcoming_tasks column")
|
||||
assertTrue(
|
||||
upcoming.tasks.any { it.id == 1 },
|
||||
"the new task must land in upcoming_tasks; got columns=${allTasks.columns.map { it.name to it.tasks.map { t -> t.id } }}"
|
||||
)
|
||||
assertEquals(upcoming.tasks.size, upcoming.count, "column count must match tasks.size")
|
||||
}
|
||||
|
||||
/// Reasonable-defaults sanity check for the bulk-create scenario:
|
||||
/// multiple tasks land across different kanban columns and end up
|
||||
/// distributed correctly. This exercises the upsert when _allTasks
|
||||
/// was seeded by a previous call.
|
||||
@Test
|
||||
fun updateTask_distributesAcrossColumns_whenSeedingThenAdding() {
|
||||
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "overdue_tasks"))
|
||||
DataManager.updateTask(sampleTask(id = 2, residenceId = 100, column = "upcoming_tasks"))
|
||||
DataManager.updateTask(sampleTask(id = 3, residenceId = 100, column = "upcoming_tasks"))
|
||||
|
||||
val allTasks = DataManager.allTasks.value
|
||||
assertNotNull(allTasks)
|
||||
|
||||
val overdue = allTasks.columns.first { it.name == "overdue_tasks" }
|
||||
val upcoming = allTasks.columns.first { it.name == "upcoming_tasks" }
|
||||
|
||||
assertEquals(setOf(1), overdue.tasks.map { it.id }.toSet())
|
||||
assertEquals(setOf(2, 3), upcoming.tasks.map { it.id }.toSet())
|
||||
}
|
||||
|
||||
/// Replacement contract: calling updateTask with the same id twice
|
||||
/// must not duplicate; the second call replaces the first wherever it
|
||||
/// lives. Catches the "always-append" implementation mistake.
|
||||
@Test
|
||||
fun updateTask_replacesExistingTaskById_acrossColumns() {
|
||||
DataManager.updateTask(sampleTask(id = 5, residenceId = 100, column = "upcoming_tasks", title = "v1"))
|
||||
DataManager.updateTask(sampleTask(id = 5, residenceId = 100, column = "in_progress_tasks", title = "v2"))
|
||||
|
||||
val allTasks = DataManager.allTasks.value
|
||||
assertNotNull(allTasks)
|
||||
|
||||
val upcoming = allTasks.columns.first { it.name == "upcoming_tasks" }
|
||||
val inProgress = allTasks.columns.first { it.name == "in_progress_tasks" }
|
||||
|
||||
assertTrue(upcoming.tasks.none { it.id == 5 }, "task 5 must move out of upcoming_tasks")
|
||||
assertEquals(1, inProgress.tasks.count { it.id == 5 }, "task 5 must appear once in in_progress_tasks")
|
||||
assertEquals("v2", inProgress.tasks.first { it.id == 5 }.title)
|
||||
}
|
||||
|
||||
/// Characterization: getTasksForResidence filters _allTasks by
|
||||
/// residence id. This is the helper that becomes the primary path
|
||||
/// for residence-detail in Phase 3 (collapse the dual cache).
|
||||
@Test
|
||||
fun getTasksForResidence_filtersAllTasksByResidenceId() {
|
||||
// Seed _allTasks with tasks across two residences via the upsert path.
|
||||
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
|
||||
DataManager.updateTask(sampleTask(id = 2, residenceId = 100, column = "overdue_tasks"))
|
||||
DataManager.updateTask(sampleTask(id = 3, residenceId = 200, column = "upcoming_tasks"))
|
||||
|
||||
val r100 = DataManager.getTasksForResidence(100)
|
||||
assertNotNull(r100)
|
||||
val r100Ids = r100.columns.flatMap { it.tasks }.map { it.id }.toSet()
|
||||
assertEquals(setOf(1, 2), r100Ids)
|
||||
|
||||
val r200 = DataManager.getTasksForResidence(200)
|
||||
assertNotNull(r200)
|
||||
val r200Ids = r200.columns.flatMap { it.tasks }.map { it.id }.toSet()
|
||||
assertEquals(setOf(3), r200Ids)
|
||||
|
||||
// Counts on each column must match the filtered task lists.
|
||||
for (column in r100.columns) {
|
||||
assertEquals(column.tasks.size, column.count, "column ${column.name} count mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
/// Characterization: residenceId with no tasks returns a non-null
|
||||
/// shell so the residence-detail screen can distinguish "loading"
|
||||
/// (null) from "loaded, no tasks" (non-null with empty columns).
|
||||
@Test
|
||||
fun getTasksForResidence_returnsEmptyShellForResidenceWithNoTasks() {
|
||||
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
|
||||
|
||||
val r999 = DataManager.getTasksForResidence(999)
|
||||
assertNotNull(r999, "residence with no tasks must return an empty shell, not null")
|
||||
assertEquals(0, r999.columns.sumOf { it.tasks.size })
|
||||
}
|
||||
|
||||
/// Characterization: when _allTasks is null entirely (cache never
|
||||
/// populated), getTasksForResidence returns null — caller must call
|
||||
/// the API path. Phase 3's getTasksByResidence relies on this.
|
||||
@Test
|
||||
fun getTasksForResidence_returnsNullWhenAllTasksIsNull() {
|
||||
DataManager.clear()
|
||||
assertEquals(null, DataManager.getTasksForResidence(100))
|
||||
}
|
||||
|
||||
private fun sampleTask(
|
||||
id: Int,
|
||||
residenceId: Int,
|
||||
column: String,
|
||||
title: String = "Task $id"
|
||||
) = TaskResponse(
|
||||
id = id,
|
||||
residenceId = residenceId,
|
||||
createdById = 1,
|
||||
title = title,
|
||||
kanbanColumn = column,
|
||||
createdAt = "2026-04-25T00:00:00Z",
|
||||
updatedAt = "2026-04-25T00:00:00Z"
|
||||
)
|
||||
}
|
||||
@@ -18,15 +18,6 @@ class TaskViewModelTest {
|
||||
assertIs<ApiResult.Idle>(viewModel.tasksState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInitialTasksByResidenceState() {
|
||||
// Given
|
||||
val viewModel = TaskViewModel()
|
||||
|
||||
// Then
|
||||
assertIs<ApiResult.Idle>(viewModel.tasksByResidenceState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInitialAddNewCustomTaskState() {
|
||||
// Given
|
||||
|
||||
@@ -18,6 +18,10 @@ actual object PostHogAnalytics {
|
||||
// iOS uses Swift PostHogAnalytics.shared.capture() directly
|
||||
}
|
||||
|
||||
actual fun captureException(throwable: Throwable, properties: Map<String, Any>?) {
|
||||
// iOS exception capture is done in Swift via AnalyticsManager
|
||||
}
|
||||
|
||||
actual fun screen(screenName: String, properties: Map<String, Any>?) {
|
||||
// iOS uses Swift PostHogAnalytics.shared.screen() directly
|
||||
}
|
||||
@@ -29,4 +33,8 @@ actual object PostHogAnalytics {
|
||||
actual fun flush() {
|
||||
// iOS uses Swift PostHogAnalytics.shared.flush() directly
|
||||
}
|
||||
|
||||
actual fun setupExceptionHandler() {
|
||||
// iOS exception handler is set up in Swift via AnalyticsManager.setupExceptionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ actual object PostHogAnalytics {
|
||||
// No-op for web
|
||||
}
|
||||
|
||||
actual fun captureException(throwable: Throwable, properties: Map<String, Any>?) {
|
||||
// No-op for web
|
||||
}
|
||||
|
||||
actual fun screen(screenName: String, properties: Map<String, Any>?) {
|
||||
// No-op for web
|
||||
}
|
||||
@@ -28,4 +32,8 @@ actual object PostHogAnalytics {
|
||||
actual fun flush() {
|
||||
// No-op for web
|
||||
}
|
||||
|
||||
actual fun setupExceptionHandler() {
|
||||
// No-op for web
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ actual object PostHogAnalytics {
|
||||
// No-op for desktop
|
||||
}
|
||||
|
||||
actual fun captureException(throwable: Throwable, properties: Map<String, Any>?) {
|
||||
// No-op for desktop
|
||||
}
|
||||
|
||||
actual fun screen(screenName: String, properties: Map<String, Any>?) {
|
||||
// No-op for desktop
|
||||
}
|
||||
@@ -28,4 +32,8 @@ actual object PostHogAnalytics {
|
||||
actual fun flush() {
|
||||
// No-op for desktop
|
||||
}
|
||||
|
||||
actual fun setupExceptionHandler() {
|
||||
// No-op for desktop
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ actual object PostHogAnalytics {
|
||||
// No-op for web
|
||||
}
|
||||
|
||||
actual fun captureException(throwable: Throwable, properties: Map<String, Any>?) {
|
||||
// No-op for web
|
||||
}
|
||||
|
||||
actual fun screen(screenName: String, properties: Map<String, Any>?) {
|
||||
// No-op for web
|
||||
}
|
||||
@@ -28,4 +32,8 @@ actual object PostHogAnalytics {
|
||||
actual fun flush() {
|
||||
// No-op for web
|
||||
}
|
||||
|
||||
actual fun setupExceptionHandler() {
|
||||
// No-op for web
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,919 @@
|
||||
# Task Cache Unification Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Make `_allTasks` the single source of truth for tasks; collapse `_tasksByResidence` into a derived view. Fix the bug where tasks created during onboarding don't appear on the Residence Detail screen until app restart (Gitea issue #2).
|
||||
|
||||
**Architecture:** The current code has two parallel task caches that must be kept in sync (`_allTasks` for the kanban tab, `_tasksByResidence` per-residence for the residence detail screen). `DataManager.updateTask` is a no-op when either cache is empty, so post-`bulkCreateTasks` the new tasks live only on the server until something forces a fetch. After this change there is exactly one cache (`_allTasks`); residence detail screens observe it and apply an in-memory filter by `residenceId`. Mutations (`createTask`, `bulkCreateTasks`) force a refresh of `_allTasks` from the server to guarantee freshness with one round-trip instead of relying on conditional branches that silently skip.
|
||||
|
||||
**Tech Stack:** Kotlin Multiplatform (commonMain), Ktor client, kotlinx.serialization, Combine bridge to SwiftUI iOS, Compose StateFlow on Android. Test framework: `kotlin.test` in commonTest.
|
||||
|
||||
**Affected files:**
|
||||
- `composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt` — remove `_tasksByResidence`, simplify `updateTask`/`removeTask`, add upsert behavior
|
||||
- `composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt` — change `bulkCreateTasks`, `createTask`, `getTasksByResidence`
|
||||
- `composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModel.kt` — feed `_residenceTasksState` from a `combine(allTasks, residenceId)` flow
|
||||
- `composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt` (new) — cache behavior tests
|
||||
- `iosApp/iosApp/Task/TaskViewModel.swift` — drop `$tasksByResidence` sink, filter `$allTasks` when residence-scoped
|
||||
- `iosApp/iosApp/Data/DataManagerObservable.swift` — drop `tasksByResidence` `@Published` and its `for await` task
|
||||
|
||||
**Out of scope (do not touch):**
|
||||
- Backend Go API — `/api/tasks/by-residence/:id/` endpoint stays in place untouched (might still be used by web admin)
|
||||
- Android `ResidenceDetailScreen.kt` — the screen contract (`residenceViewModel.residenceTasksState`) is preserved; only the VM internals change
|
||||
- Disk persistence schema — kotlinx.serialization is configured with `ignoreUnknownKeys` for forward/backward compat (verified in Task 11)
|
||||
|
||||
---
|
||||
|
||||
## Pre-flight
|
||||
|
||||
### Task 0: Verify clean state and baseline
|
||||
|
||||
**Files:** none (read only)
|
||||
|
||||
**Step 1: Confirm working tree is clean (or only the expected exception)**
|
||||
|
||||
Run: `git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP status --short`
|
||||
Expected: empty, **or** the only line is `M composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiConfig.kt`. That single file is intentionally on `Environment.LOCAL` for the duration of this work — it stays uncommitted and gets flipped back to `Environment.PROD` in Task 11 Step 5. If anything else shows up, stop and ask the user.
|
||||
|
||||
**Step 2: Confirm we're on a feature branch (not master)**
|
||||
|
||||
Run: `git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP rev-parse --abbrev-ref HEAD`
|
||||
Expected: NOT `master`. If `master`, run `git checkout -b fix/task-cache-unification` before continuing.
|
||||
|
||||
**Step 3: Run the existing commonTest baseline so we know what currently passes**
|
||||
|
||||
Run: `./gradlew :composeApp:testDebugUnitTest`
|
||||
Expected: BUILD SUCCESSFUL. Note the count — every later run must keep ≥ this count of green tests.
|
||||
|
||||
**Step 4: Build iOS to confirm starting point compiles**
|
||||
|
||||
Run: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build 2>&1 | tail -20`
|
||||
Expected: `** BUILD SUCCEEDED **`
|
||||
|
||||
No commit — this is verification only.
|
||||
|
||||
### Task 0.5: Failing regression XCUITest — reproduce the bug end-to-end
|
||||
|
||||
**Goal:** Write a UI test that drives the exact onboarding-to-residence-detail flow from gitea#2 and asserts that tasks appear on the residence detail screen without an app restart. Run it now — it MUST fail. The Phase 1-3 fixes will make it pass; Task 12 re-runs it as the final gate.
|
||||
|
||||
**Why before the unit tests:** The unit tests in Phase 1 catch the bug at the cache layer, but the *user-facing* bug is "I tap my residence and see 'no tasks'". A passing UI test is the only thing that proves the user experience is actually fixed. Writing it once up front + running it once at the end is cheaper than running a full UI cycle every iteration.
|
||||
|
||||
**Files:**
|
||||
- Create: `iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift`
|
||||
- Maybe modify: `iosApp/HoneyDueUITests/AccessibilityIdentifiers.swift` (only if missing IDs are needed — see Step 2)
|
||||
- Maybe modify: SwiftUI views in `iosApp/iosApp/` (only if the view layer is missing accessibility identifiers — see Step 2)
|
||||
|
||||
**Pre-requisites already satisfied by Task 0 setup:**
|
||||
- iOS app is on `Environment.LOCAL`
|
||||
- Docker stack is up and healthy at `http://127.0.0.1:8000`
|
||||
- `DEBUG=true` on the local API → email confirmation code is fixed at `123456` (saves a manual step in the test)
|
||||
|
||||
**Step 1: Pick a clean run by wiping prior simulator state**
|
||||
|
||||
Run: `xcrun simctl uninstall booted com.myhoneydue.honeyDue.dev`
|
||||
Run: `docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml down && docker volume rm honeydueapi-go_postgres_data && docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml up -d`
|
||||
Wait until: `curl -fsS http://127.0.0.1:8000/api/health/` returns 200.
|
||||
|
||||
**Step 2: Audit accessibility identifiers along the test path**
|
||||
|
||||
The test taps and asserts on these SwiftUI surfaces. Open `iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift` and verify (or add) identifiers for each. Use stable, non-localized strings.
|
||||
|
||||
Surfaces to identify:
|
||||
| Where | Why the test needs it | Suggested ID constant |
|
||||
|---|---|---|
|
||||
| Login/Register screen — username, email, password, first/last name fields, "Register" button, "Verify" code field | Drive registration | already in `AccessibilityIdentifiers.Authentication.*` per existing UI tests — verify by grep |
|
||||
| Onboarding — residence-creation form (name field + Continue) | Drive residence creation | `AccessibilityIdentifiers.Onboarding.residenceNameField`, `.continueButton` (add if missing) |
|
||||
| Onboarding First-Task screen — "Browse All" tab button | Switch from suggestions to browse | `AccessibilityIdentifiers.Onboarding.browseAllTab` (add if missing) |
|
||||
| Onboarding First-Task screen — each template row (selectable) | Pick 3 tasks | `AccessibilityIdentifiers.Onboarding.templateRowPrefix` (e.g., `"onboarding.template.<id>"`) — see how `OnboardingFirstTaskView.swift` renders rows; add an `.accessibilityIdentifier(...)` keyed on `template.id` |
|
||||
| Onboarding First-Task screen — Submit button | Trigger bulk-create | `AccessibilityIdentifiers.Onboarding.submitTasksButton` (add if missing) |
|
||||
| Residence list / home — the residence cell | Tap into detail | `AccessibilityIdentifiers.Residence.cellPrefix` (e.g., `"residence.cell.<name>"` or `<id>`) — verify in `ResidenceListView` or wherever the post-onboarding landing screen renders cells |
|
||||
| Residence detail — task row | Assert presence | `AccessibilityIdentifiers.Task.rowPrefix` (e.g., `"task.row"`) — verify the task list inside `TasksSectionContainer` in `ResidenceDetailView.swift:538` |
|
||||
| Residence detail — empty state ("No tasks" copy) | Assert ABSENCE | `AccessibilityIdentifiers.Task.noTasksLabel` (add if missing) — find the empty-state copy in the residence-detail tasks section and pin an identifier on it |
|
||||
|
||||
For each missing ID, add it in two places:
|
||||
1. The constant in `AccessibilityIdentifiers.swift`
|
||||
2. `.accessibilityIdentifier(AccessibilityIdentifiers.X.Y)` on the SwiftUI view
|
||||
|
||||
Keep these app-side additions in **a single dedicated commit** so reviewers can see "test scaffolding only, no behavior change":
|
||||
|
||||
```bash
|
||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add -A
|
||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: add accessibility identifiers along the onboarding-to-residence-detail path
|
||||
|
||||
Scaffolding for the gitea#2 regression XCUITest. No user-visible
|
||||
change — pure metadata for UI automation."
|
||||
```
|
||||
|
||||
**Step 3: Write the failing UI test**
|
||||
|
||||
Create `iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift`:
|
||||
|
||||
```swift
|
||||
import XCTest
|
||||
|
||||
/// Regression test for gitea#2.
|
||||
///
|
||||
/// Onboarding flow: register → create residence → pick 3 tasks → submit.
|
||||
/// After submit, the user lands on the home/residences screen. They tap
|
||||
/// the new residence WITHOUT visiting the Tasks tab first (the Tasks tab
|
||||
/// triggers a `getTasks()` that masks the bug by populating `_allTasks`).
|
||||
///
|
||||
/// Expected: residence detail shows ≥1 task row within 10s.
|
||||
/// Pre-fix: residence detail shows empty state ("no tasks") forever
|
||||
/// until the app is restarted.
|
||||
final class Suite11_TaskCacheRegressionTests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments += ["UI-Testing"]
|
||||
app.launch()
|
||||
|
||||
// 1. Register a fresh user. Email confirmation code is fixed at "123456"
|
||||
// in DEBUG mode (DEBUG_FIXED_CODES=true on the local docker stack).
|
||||
let stamp = String(Int(Date().timeIntervalSince1970))
|
||||
UITestHelpers.register(
|
||||
in: app,
|
||||
username: "uitest\(stamp)",
|
||||
email: "uitest+\(stamp)@treymail.com",
|
||||
password: "UItest\(stamp)!aZ",
|
||||
confirmationCode: "123456"
|
||||
)
|
||||
|
||||
// 2. Onboarding: create the residence.
|
||||
UITestHelpers.completeResidenceCreation(in: app, name: "UI Test Property")
|
||||
|
||||
// 3. Switch to "Browse All" tab and pick 3 templates. The "For You"
|
||||
// suggestions tab depends on a server-side recommendation that
|
||||
// might be empty for a freshly created residence; Browse is
|
||||
// deterministic.
|
||||
let browseTab = app.buttons[AccessibilityIdentifiers.Onboarding.browseAllTab]
|
||||
XCTAssertTrue(browseTab.waitForExistence(timeout: 5),
|
||||
"Browse All tab must appear on First-Task screen")
|
||||
browseTab.tap()
|
||||
|
||||
let templates = app.buttons.matching(
|
||||
NSPredicate(format: "identifier BEGINSWITH %@",
|
||||
AccessibilityIdentifiers.Onboarding.templateRowPrefix)
|
||||
)
|
||||
|
||||
// Wait for the catalog to load — fresh API call against local backend.
|
||||
XCTAssertTrue(templates.element(boundBy: 0).waitForExistence(timeout: 10),
|
||||
"Template catalog must load")
|
||||
|
||||
for i in 0..<3 {
|
||||
templates.element(boundBy: i).tap()
|
||||
}
|
||||
|
||||
// 4. Submit. This calls APILayer.bulkCreateTasks → POST /api/tasks/bulk/
|
||||
// The bug lives in the cache update path between this call returning
|
||||
// and the residence detail screen rendering.
|
||||
let submit = app.buttons[AccessibilityIdentifiers.Onboarding.submitTasksButton]
|
||||
XCTAssertTrue(submit.waitForExistence(timeout: 3))
|
||||
submit.tap()
|
||||
|
||||
// 5. Land on home/residences. Tap the residence we just created.
|
||||
// Critical: do NOT visit the Tasks tab — that would call getTasks()
|
||||
// and populate _allTasks via setAllTasks, masking the bug.
|
||||
let residenceCell = app.buttons[
|
||||
AccessibilityIdentifiers.Residence.cellPrefix + "UI Test Property"
|
||||
]
|
||||
XCTAssertTrue(residenceCell.waitForExistence(timeout: 10),
|
||||
"Residence cell must appear on home after onboarding submit")
|
||||
residenceCell.tap()
|
||||
|
||||
// 6. Residence detail must show ≥1 task row, NOT the empty state.
|
||||
// Generous timeout (10s) covers the network round-trip on slow
|
||||
// local Docker startups.
|
||||
let firstTaskRow = app.cells
|
||||
.matching(NSPredicate(format: "identifier BEGINSWITH %@",
|
||||
AccessibilityIdentifiers.Task.rowPrefix))
|
||||
.firstMatch
|
||||
XCTAssertTrue(
|
||||
firstTaskRow.waitForExistence(timeout: 10),
|
||||
"Tasks created during onboarding must appear on residence detail without restart (gitea#2)"
|
||||
)
|
||||
|
||||
let emptyState = app.staticTexts[AccessibilityIdentifiers.Task.noTasksLabel]
|
||||
XCTAssertFalse(
|
||||
emptyState.exists,
|
||||
"Empty 'no tasks' state must NOT show when tasks exist (gitea#2)"
|
||||
)
|
||||
|
||||
// 7. Cleanup — delete the test user via UI (or skip; clearing the
|
||||
// docker volume between runs is the cheaper reset).
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Notes for the engineer writing this test:**
|
||||
|
||||
- `UITestHelpers.register(...)` and `UITestHelpers.completeResidenceCreation(...)` may not exist verbatim — read `iosApp/HoneyDueUITests/UITestHelpers.swift` for the existing helpers. If `register(...)` exists but doesn't take a `confirmationCode:` arg, either add one or inline the verification step.
|
||||
- DO NOT use `sleep()` anywhere. Use `waitForExistence(timeout:)` everywhere. The skill `axiom-ui-testing` is loaded if you need patterns.
|
||||
- `continueAfterFailure = false` so we stop at the exact assertion that fails — easier to triage video.
|
||||
- If you can't get a residence cell identifier reliably (e.g., the home screen shows a custom layout, not standard cells), substitute `app.staticTexts["UI Test Property"]` and tap that. The point is to land on the residence detail without going through the Tasks tab.
|
||||
|
||||
**Step 4: Run the test — must FAIL**
|
||||
|
||||
Run:
|
||||
```
|
||||
xcodebuild -project iosApp/honeyDue.xcodeproj \
|
||||
-scheme HoneyDueUITests \
|
||||
-sdk iphonesimulator \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||
-only-testing:HoneyDueUITests/Suite11_TaskCacheRegressionTests/test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart \
|
||||
test 2>&1 | tail -40
|
||||
```
|
||||
|
||||
Expected: `Test Suite '...' failed.` with the assertion **"Tasks created during onboarding must appear on residence detail without restart (gitea#2)"**.
|
||||
|
||||
If it FAILS for a different reason (residence cell not found, timeout on browse tab, etc.) → that's an accessibility-identifier mismatch, not a bug repro. Fix the test/IDs and re-run. The test must fail SPECIFICALLY on the "no tasks on residence detail" assertion to be a valid bug capture.
|
||||
|
||||
If it PASSES → the bug isn't reproducing in this environment. Possibilities:
|
||||
- App was already cached with `_allTasks` from a prior run (re-run Step 1 to fully wipe simulator + DB)
|
||||
- The user navigated through the Tasks tab implicitly (check the home screen layout)
|
||||
- The bug only happens on a code path you didn't replicate (re-read the iOS-side onboarding flow)
|
||||
|
||||
**Step 5: Commit the failing test**
|
||||
|
||||
```bash
|
||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift
|
||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: failing — onboarding tasks must appear on residence detail without restart
|
||||
|
||||
Captures gitea#2 at the user-visible level. The kanban tab works but
|
||||
the residence detail screen does not, until the app is restarted. This
|
||||
test must FAIL at this commit and PASS after the cache unification work.
|
||||
Re-run gates the merge in Task 12."
|
||||
```
|
||||
|
||||
The test stays failing through Phase 1-3 commits. Don't run it on every commit — it's slow. Run it once at the end (Task 12).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — TDD: catch the bug, then fix it
|
||||
|
||||
### Task 1: Failing test — `bulkCreateTasks` must populate `_allTasks`
|
||||
|
||||
**Files:**
|
||||
- Create: `composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
This test reproduces the onboarding bug at the cache layer. We can't easily mock Ktor here without infrastructure, so we test the cache mutation contract directly: after a successful bulk-create, `_allTasks` MUST contain every returned task, regardless of prior cache state.
|
||||
|
||||
```kotlin
|
||||
package com.tt.honeyDue.data
|
||||
|
||||
import com.tt.honeyDue.models.TaskResponse
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.BeforeTest
|
||||
|
||||
class DataManagerTaskCacheTest {
|
||||
|
||||
@BeforeTest
|
||||
fun resetState() {
|
||||
DataManager.clearAllData()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateTask seeds _allTasks when cache is empty`() {
|
||||
// Given: fresh DataManager with no tasks loaded (the onboarding scenario)
|
||||
assertEquals(null, DataManager.allTasks.value)
|
||||
|
||||
// When: a new task arrives via the same path bulkCreateTasks uses
|
||||
val task = TaskResponse(
|
||||
id = 1,
|
||||
residenceId = 100,
|
||||
title = "Replace HVAC filter",
|
||||
kanbanColumn = "upcoming_tasks",
|
||||
// ... fill remaining required TaskResponse fields with sensible defaults
|
||||
)
|
||||
DataManager.updateTask(task)
|
||||
|
||||
// Then: _allTasks is populated with this task in the right column
|
||||
val allTasks = DataManager.allTasks.value
|
||||
assertNotNull(allTasks, "updateTask must seed _allTasks even when it was null")
|
||||
|
||||
val upcomingColumn = allTasks.columns.firstOrNull { it.name == "upcoming_tasks" }
|
||||
assertNotNull(upcomingColumn)
|
||||
assertTrue(
|
||||
upcomingColumn.tasks.any { it.id == 1 },
|
||||
"task must land in the upcoming_tasks column"
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You'll need to look at `TaskResponse` in `composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/CustomTask.kt` to fill the required fields. Use defaults that match an onboarding-created task (no completion, no priority, due-soon date).
|
||||
|
||||
**Step 2: Run the test — must FAIL**
|
||||
|
||||
Run: `./gradlew :composeApp:testDebugUnitTest --tests "com.tt.honeyDue.data.DataManagerTaskCacheTest.updateTask seeds _allTasks when cache is empty"`
|
||||
Expected: FAIL with `expected:<not null> but was:<null>` (or similar). This proves the test catches the bug.
|
||||
|
||||
**Step 3: Commit the failing test**
|
||||
|
||||
```bash
|
||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt
|
||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: failing — DataManager.updateTask must seed _allTasks"
|
||||
```
|
||||
|
||||
### Task 2: Make `DataManager.updateTask` a real upsert
|
||||
|
||||
**Files:**
|
||||
- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt:482-520`
|
||||
|
||||
**Step 1: Replace the conditional branch on `_allTasks` with an upsert**
|
||||
|
||||
Current (lines 484-498):
|
||||
```kotlin
|
||||
_allTasks.value?.let { current ->
|
||||
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
|
||||
val newColumns = current.columns.map { column -> ... }
|
||||
_allTasks.value = current.copy(columns = newColumns)
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```kotlin
|
||||
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
|
||||
val current = _allTasks.value ?: TaskColumnsResponse(
|
||||
columns = standardKanbanColumns(), // see Step 2
|
||||
daysThreshold = 30,
|
||||
residenceId = null
|
||||
)
|
||||
val newColumns = current.columns.map { column ->
|
||||
val filteredTasks = column.tasks.filter { it.id != task.id }
|
||||
val updatedTasks = if (column.name == targetColumn) filteredTasks + task else filteredTasks
|
||||
column.copy(tasks = updatedTasks, count = updatedTasks.size)
|
||||
}
|
||||
// If targetColumn doesn't exist in current.columns (e.g. fresh seed), append it
|
||||
val finalColumns = if (newColumns.none { it.name == targetColumn }) {
|
||||
newColumns + Column(name = targetColumn, tasks = listOf(task), count = 1, /* fill rest */)
|
||||
} else newColumns
|
||||
_allTasks.value = current.copy(columns = finalColumns)
|
||||
```
|
||||
|
||||
**Step 2: Add `standardKanbanColumns()` helper**
|
||||
|
||||
Look at the backend response — `internal/repositories/task_repo.go` `GetKanbanDataForMultipleResidences` defines the column order. Mirror it in Kotlin:
|
||||
|
||||
```kotlin
|
||||
private fun standardKanbanColumns(): List<Column> = listOf(
|
||||
Column(name = "overdue_tasks", tasks = emptyList(), count = 0, /* defaults */),
|
||||
Column(name = "due_soon_tasks", tasks = emptyList(), count = 0, /* defaults */),
|
||||
Column(name = "in_progress_tasks", tasks = emptyList(), count = 0, /* defaults */),
|
||||
Column(name = "upcoming_tasks", tasks = emptyList(), count = 0, /* defaults */),
|
||||
Column(name = "completed_tasks", tasks = emptyList(), count = 0, /* defaults */),
|
||||
// archived/cancelled are hidden from kanban — see honeyDueAPI-go/CLAUDE.md
|
||||
)
|
||||
```
|
||||
|
||||
Look at `Column` in `CustomTask.kt` for its required fields (display label, color, etc.). Fill in matching defaults.
|
||||
|
||||
**Step 3: Drop the second branch (`_tasksByResidence`) — it's going away in Phase 3**
|
||||
|
||||
Remove lines 500-515 entirely. The `_tasksByResidence` slot is still there for now (Phase 3 deletes it), but `updateTask` should not write to it any more.
|
||||
|
||||
**Step 4: Run the test — must PASS**
|
||||
|
||||
Run: `./gradlew :composeApp:testDebugUnitTest --tests "com.tt.honeyDue.data.DataManagerTaskCacheTest.updateTask seeds _allTasks when cache is empty"`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Run the full test suite to confirm no regressions**
|
||||
|
||||
Run: `./gradlew :composeApp:testDebugUnitTest`
|
||||
Expected: same green count as Task 0 baseline + 1 new test.
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt
|
||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "fix: DataManager.updateTask seeds _allTasks when cache is empty
|
||||
|
||||
Closes the silent no-op when _allTasks is null on first launch (the
|
||||
onboarding bulkCreateTasks path). The function now upserts: builds an
|
||||
empty kanban shell if needed and places the task in its target column.
|
||||
Adds an unknown column at the end for forward compatibility with future
|
||||
column names from the server.
|
||||
|
||||
Refs gitea#2"
|
||||
```
|
||||
|
||||
### Task 3: Add upsert test for `_tasksByResidence` deletion guard
|
||||
|
||||
**Files:**
|
||||
- Modify: `composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt`
|
||||
|
||||
**Step 1: Add a test asserting `updateTask` does NOT touch `_tasksByResidence` any more**
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `updateTask no longer mutates _tasksByResidence`() {
|
||||
val before = DataManager.tasksByResidence.value
|
||||
DataManager.updateTask(/* sample task */)
|
||||
assertEquals(before, DataManager.tasksByResidence.value,
|
||||
"updateTask must not touch _tasksByResidence — it's deprecated")
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run — must PASS** (we already removed the branch in Task 2)
|
||||
|
||||
Run: `./gradlew :composeApp:testDebugUnitTest --tests "com.tt.honeyDue.data.DataManagerTaskCacheTest.updateTask no longer mutates _tasksByResidence"`
|
||||
Expected: PASS
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt
|
||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: lock down that updateTask no longer writes _tasksByResidence"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Belt-and-suspenders: force-refresh after mutations
|
||||
|
||||
The Phase 1 upsert handles the fresh-cache case correctly, but it makes assumptions about kanban column placement based on the response's `kanbanColumn` field. The server is the authoritative kanban categorizer. To eliminate any drift, also force a `_allTasks` refresh after multi-task mutations.
|
||||
|
||||
### Task 4: Force `_allTasks` refresh after `bulkCreateTasks`
|
||||
|
||||
**Files:**
|
||||
- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt:647-655`
|
||||
|
||||
**Step 1: Add post-success refresh**
|
||||
|
||||
Current:
|
||||
```kotlin
|
||||
suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
|
||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
val result = taskApi.bulkCreateTasks(token, request)
|
||||
|
||||
if (result is ApiResult.Success) {
|
||||
DataManager.setTotalSummary(result.data.summary)
|
||||
result.data.tasks.forEach { DataManager.updateTask(it) }
|
||||
}
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
New:
|
||||
```kotlin
|
||||
suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
|
||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
val result = taskApi.bulkCreateTasks(token, request)
|
||||
|
||||
if (result is ApiResult.Success) {
|
||||
DataManager.setTotalSummary(result.data.summary)
|
||||
// Authoritative refresh — server knows the right kanban placement.
|
||||
// Cheap (one round-trip) and eliminates any client-side drift between
|
||||
// the per-task kanbanColumn hint and the global kanban view.
|
||||
getTasks(forceRefresh = true)
|
||||
}
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
Drop the `forEach { updateTask }` — it becomes redundant with the force-refresh.
|
||||
|
||||
**Step 2: Run the full test suite**
|
||||
|
||||
Run: `./gradlew :composeApp:testDebugUnitTest`
|
||||
Expected: all green (the Phase 1 upsert tests still pass because they exercise `updateTask` directly, not `bulkCreateTasks`).
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt
|
||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "fix: bulkCreateTasks force-refreshes _allTasks instead of merging task-by-task
|
||||
|
||||
Server is the authoritative kanban categorizer. After a bulk insert,
|
||||
re-fetch /api/tasks/ so the kanban view reflects exactly what the
|
||||
server sees, including any column re-categorizations the client's
|
||||
in-memory upsert wouldn't compute. One extra round-trip per onboarding
|
||||
submission, called once per session typically.
|
||||
|
||||
Refs gitea#2"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Collapse the dual cache
|
||||
|
||||
### Task 5: Characterization test for `getTasksForResidence`
|
||||
|
||||
`getTasksForResidence` already implements the filter we want to use everywhere. Lock it down with a test before we make it the primary path.
|
||||
|
||||
**Files:**
|
||||
- Modify: `composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt`
|
||||
|
||||
**Step 1: Add the test**
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `getTasksForResidence filters _allTasks by residence id`() {
|
||||
DataManager.setAllTasks(/* response with tasks across residences 100 and 200 */)
|
||||
|
||||
val r100 = DataManager.getTasksForResidence(100)
|
||||
assertNotNull(r100)
|
||||
assertTrue(r100.columns.flatMap { it.tasks }.all { it.residenceId == 100 })
|
||||
|
||||
val r999 = DataManager.getTasksForResidence(999)
|
||||
assertNotNull(r999)
|
||||
assertEquals(0, r999.columns.sumOf { it.tasks.size }) // valid id, just no tasks
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getTasksForResidence returns null when _allTasks is null`() {
|
||||
DataManager.clearAllData()
|
||||
assertEquals(null, DataManager.getTasksForResidence(100))
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run — must PASS** (no implementation change yet)
|
||||
|
||||
Run: `./gradlew :composeApp:testDebugUnitTest --tests "com.tt.honeyDue.data.DataManagerTaskCacheTest.getTasksForResidence*"`
|
||||
Expected: PASS for both.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt
|
||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: characterize getTasksForResidence filter contract"
|
||||
```
|
||||
|
||||
### Task 6: Simplify `APILayer.getTasksByResidence`
|
||||
|
||||
**Files:**
|
||||
- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt:591-621`
|
||||
|
||||
**Step 1: Replace the 3-path implementation with "ensure _allTasks fresh, then filter"**
|
||||
|
||||
```kotlin
|
||||
suspend fun getTasksByResidence(residenceId: Int, forceRefresh: Boolean = false): ApiResult<TaskColumnsResponse> {
|
||||
// Ensure _allTasks is loaded and reasonably fresh.
|
||||
// getTasks itself respects forceRefresh and the global tasksCacheTime.
|
||||
val allTasksResult = getTasks(forceRefresh = forceRefresh)
|
||||
if (allTasksResult is ApiResult.Error) return allTasksResult
|
||||
|
||||
val filtered = DataManager.getTasksForResidence(residenceId)
|
||||
?: return ApiResult.Error("Tasks unavailable", 0)
|
||||
return ApiResult.Success(filtered)
|
||||
}
|
||||
```
|
||||
|
||||
This deletes the per-residence cache reliance entirely. `_tasksByResidence` is no longer written by this path.
|
||||
|
||||
**Step 2: Run the test suite**
|
||||
|
||||
Run: `./gradlew :composeApp:testDebugUnitTest`
|
||||
Expected: all green.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt
|
||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "refactor: getTasksByResidence is now a thin filter over _allTasks
|
||||
|
||||
Was 3 fallback paths (per-residence cache → filter from allTasks →
|
||||
network). Now: ensure _allTasks fresh, return filter. The per-residence
|
||||
cache becomes write-only by this path, scheduled for deletion in the
|
||||
next commit."
|
||||
```
|
||||
|
||||
### Task 7: iOS — `TaskViewModel` observes `$allTasks` with filter
|
||||
|
||||
**Files:**
|
||||
- Modify: `iosApp/iosApp/Task/TaskViewModel.swift:46-74`
|
||||
|
||||
**Step 1: Replace dual-sink with single-sink + filter**
|
||||
|
||||
Current logic uses two Combine sinks: `$allTasks` (only when `currentResidenceId == nil`) and `$tasksByResidence` (only when set).
|
||||
|
||||
Replace with one sink on `$allTasks` that conditionally filters:
|
||||
|
||||
```swift
|
||||
DataManagerObservable.shared.$allTasks
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] allTasks in
|
||||
guard let self else { return }
|
||||
guard !self.isAnimatingCompletion else { return }
|
||||
|
||||
if let allTasks {
|
||||
if let resId = self.currentResidenceId {
|
||||
self.tasksResponse = self.filterByResidence(allTasks, residenceId: resId)
|
||||
} else {
|
||||
self.tasksResponse = allTasks
|
||||
}
|
||||
self.isLoadingTasks = false
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
```
|
||||
|
||||
**Step 2: Add the `filterByResidence` helper**
|
||||
|
||||
```swift
|
||||
private func filterByResidence(_ response: TaskColumnsResponse, residenceId: Int32) -> TaskColumnsResponse {
|
||||
let filteredColumns = response.columns.map { column -> Column in
|
||||
let filteredTasks = column.tasks.filter { Int32($0.residenceId ?? 0) == residenceId }
|
||||
return column.copy(tasks: filteredTasks, count: Int32(filteredTasks.count))
|
||||
}
|
||||
return response.copy(columns: filteredColumns, residenceId: String(residenceId))
|
||||
}
|
||||
```
|
||||
|
||||
(Use `.doCopy(...)` SKIE syntax if `.copy` isn't directly callable from Swift — check what other Swift code does with TaskColumnsResponse copies.)
|
||||
|
||||
**Step 3: Drop the `$tasksByResidence` subscription block entirely**
|
||||
|
||||
Remove the second `.sink` on `$tasksByResidence` (currently lines 62-74).
|
||||
|
||||
**Step 4: Build iOS**
|
||||
|
||||
Run: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build 2>&1 | tail -20`
|
||||
Expected: `** BUILD SUCCEEDED **`
|
||||
|
||||
**Step 5: Run iOS unit tests**
|
||||
|
||||
Run: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:HoneyDueTests test 2>&1 | tail -20`
|
||||
Expected: TEST SUCCEEDED.
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add iosApp/iosApp/Task/TaskViewModel.swift
|
||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "ios: TaskViewModel observes \$allTasks and filters by residence in-memory
|
||||
|
||||
Single source of truth eliminates the race window where the residence
|
||||
detail screen could mount before the per-residence cache slot existed.
|
||||
After this, every emit of _allTasks rerenders every observing view —
|
||||
kanban tab, residence detail, dashboards — atomically.
|
||||
|
||||
Refs gitea#2"
|
||||
```
|
||||
|
||||
### Task 8: Android — `ResidenceViewModel` feeds `residenceTasksState` from a combined flow
|
||||
|
||||
**Files:**
|
||||
- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModel.kt:31-94`
|
||||
|
||||
**Step 1: Replace the imperative `loadResidenceTasks` with a derived flow**
|
||||
|
||||
Look at how `_residenceTasksState` is currently populated (line 88-94). Instead of imperatively calling `APILayer.getTasksByResidence` and storing the result, derive it:
|
||||
|
||||
```kotlin
|
||||
private val _currentResidenceId = MutableStateFlow<Int?>(null)
|
||||
|
||||
val residenceTasksState: StateFlow<ApiResult<TaskColumnsResponse>> =
|
||||
combine(DataManager.allTasks, _currentResidenceId) { all, id ->
|
||||
when {
|
||||
id == null -> ApiResult.Idle
|
||||
all == null -> ApiResult.Loading
|
||||
else -> {
|
||||
val filtered = DataManager.getTasksForResidence(id)
|
||||
if (filtered != null) ApiResult.Success(filtered) else ApiResult.Loading
|
||||
}
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ApiResult.Idle)
|
||||
|
||||
fun loadResidenceTasks(residenceId: Int, forceRefresh: Boolean = false) {
|
||||
viewModelScope.launch {
|
||||
_currentResidenceId.value = residenceId
|
||||
// Trigger the underlying _allTasks refresh; the combine above
|
||||
// re-emits Success when allTasks arrives.
|
||||
APILayer.getTasks(forceRefresh = forceRefresh)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The screen contract (`residenceViewModel.residenceTasksState`) is preserved — `ResidenceDetailScreen.kt:59` doesn't need any change.
|
||||
|
||||
**Step 2: Build Android debug**
|
||||
|
||||
Run: `./gradlew :composeApp:assembleDebug`
|
||||
Expected: BUILD SUCCESSFUL.
|
||||
|
||||
**Step 3: Run commonTest again**
|
||||
|
||||
Run: `./gradlew :composeApp:testDebugUnitTest`
|
||||
Expected: all green. (`ResidenceViewModelTest` may need adjusting — check it.)
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModel.kt
|
||||
# Also stage any test fixes
|
||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "android: ResidenceViewModel.residenceTasksState derives from _allTasks
|
||||
|
||||
Same screen contract, but the data flows from DataManager.allTasks
|
||||
through a combine(...) into the existing StateFlow. No per-residence
|
||||
network call needed; the upstream getTasks() refresh propagates."
|
||||
```
|
||||
|
||||
### Task 9: Delete dead code — `_tasksByResidence` and friends
|
||||
|
||||
**Files:**
|
||||
- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt`
|
||||
- Modify: `iosApp/iosApp/Data/DataManagerObservable.swift`
|
||||
|
||||
**Step 1: In DataManager.kt, delete:**
|
||||
|
||||
- `_tasksByResidence` (line 141) and `tasksByResidence` (line 142)
|
||||
- `tasksByResidenceCacheTime` (line 65)
|
||||
- `setTasksForResidence` (lines 448-451)
|
||||
- `invalidateTasksFor` (line 417 — verify it has no other callers first via grep)
|
||||
- `_tasksByResidence` mutations in `removeTask` (lines 533-onwards) — keep only the `_allTasks` removal
|
||||
- `_tasksByResidence.value = emptyMap()` in clearAllData and similar wipes (lines 783, 836)
|
||||
- `tasksByResidenceCacheTime.clear()` in same wipes (lines 814, 849)
|
||||
|
||||
Keep `getTasksForResidence` — it's the public filter API, still used by the new `getTasksByResidence` and Android VM.
|
||||
|
||||
**Step 2: In DataManagerObservable.swift, delete:**
|
||||
|
||||
- `@Published var tasksByResidence: [Int32: TaskColumnsResponse] = [:]` (line 44)
|
||||
- The `for await tasks in DataManager.shared.tasksByResidence` task (lines 195-201)
|
||||
- The `tasksByResidence[residenceId]` reader at line 524 (replace with `DataManager.shared.getTasksForResidence(residenceId)` or its iOS-friendly equivalent if anything still calls this — grep first)
|
||||
|
||||
**Step 3: Compile both targets**
|
||||
|
||||
Run: `./gradlew :composeApp:assembleDebug && ./gradlew :composeApp:testDebugUnitTest`
|
||||
Then: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build 2>&1 | tail -20`
|
||||
Expected: both BUILD SUCCEEDED, all tests green.
|
||||
|
||||
If anything fails to compile, follow the compiler — there's likely a missed reader. Common suspects: `TaskViewModel.kt` (Kotlin VM, not Swift) line ~38-42 references `_tasksByResidenceState`; verify it's still wired correctly or also delete it.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add -A
|
||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "refactor: delete _tasksByResidence and per-residence task cache plumbing
|
||||
|
||||
All readers and writers gone after the previous commits. Single source
|
||||
of truth = DataManager._allTasks, residence views derive via
|
||||
getTasksForResidence(id). Net deletion ~100 LOC across DataManager,
|
||||
APILayer, DataManagerObservable, and iOS TaskViewModel.
|
||||
|
||||
Closes gitea#2"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Verification
|
||||
|
||||
### Task 10: Verify disk persistence is forward-compatible
|
||||
|
||||
**Files:** none (verification only)
|
||||
|
||||
**Step 1: Find the persistence model**
|
||||
|
||||
Run: `grep -rn "ignoreUnknownKeys\|Json {\|tasksByResidence" composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/PersistenceManager.kt`
|
||||
Expected: kotlinx.serialization Json config with `ignoreUnknownKeys = true`. If NOT, an existing user upgrading the app will crash on first launch when the persisted blob has the now-removed `tasksByResidence` field.
|
||||
|
||||
**Step 2: If `ignoreUnknownKeys` is missing, ADD IT BEFORE SHIPPING**
|
||||
|
||||
Edit the Json config:
|
||||
```kotlin
|
||||
val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
// ... existing config
|
||||
}
|
||||
```
|
||||
Commit separately as `chore: persistence Json must ignoreUnknownKeys`.
|
||||
|
||||
**Step 3: Manual test — wipe simulator app data, install old app, then this version**
|
||||
|
||||
If you have a TestFlight build of the previous version:
|
||||
1. Install old version → register → create residence → quit
|
||||
2. Update to this build → launch → confirm no crash, data loads
|
||||
3. Quit → relaunch → confirm persistence works correctly
|
||||
|
||||
If no old TestFlight build available, skip this empirical check but the `ignoreUnknownKeys` setting is sufficient.
|
||||
|
||||
### Task 11: Manual smoke — the actual bug repro
|
||||
|
||||
**Files:** none (manual test)
|
||||
|
||||
**Step 1: Wipe simulator state for the dev build**
|
||||
|
||||
Run: `xcrun simctl uninstall booted com.myhoneydue.honeyDue.dev`
|
||||
|
||||
**Step 2: Confirm iOS is on LOCAL (set during pre-flight, stays uncommitted)**
|
||||
|
||||
Run: `grep "CURRENT_ENV" /Users/treyt/Desktop/code/honeyDue/honeyDueKMP/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiConfig.kt`
|
||||
Expected: `val CURRENT_ENV = Environment.LOCAL`. If it's not, edit the file. DO NOT COMMIT — revert in Step 5.
|
||||
|
||||
**Step 3: Build and install**
|
||||
|
||||
Run: `./gradlew :composeApp:assembleDebug` and Xcode build to simulator.
|
||||
|
||||
**Step 4: Reproduce the original bug path**
|
||||
|
||||
1. Launch app → land on register screen
|
||||
2. Register a fresh user with a unique email
|
||||
3. Onboarding: create residence → choose 3+ tasks from the catalog → submit
|
||||
4. Land on home/dashboard
|
||||
5. Navigate to the new residence's detail screen WITHOUT visiting the Tasks tab first
|
||||
6. **Expected: tasks visible immediately. No "no tasks" state. No restart needed.**
|
||||
|
||||
If the bug still reproduces, return to Phase 1 — the upsert or refresh isn't working. Capture iOS console with `xclog launch com.myhoneydue.honeyDue.dev` and inspect.
|
||||
|
||||
**Step 5: Revert the ApiConfig change**
|
||||
|
||||
Edit `ApiConfig.kt` back to `Environment.PROD` (or whatever it was). Confirm with `git diff`. Do not commit.
|
||||
|
||||
### Task 12: Final regression sweep
|
||||
|
||||
**Files:** none (verification only)
|
||||
|
||||
**Step 1: Full Kotlin test suite green**
|
||||
|
||||
Run: `./gradlew :composeApp:testDebugUnitTest`
|
||||
|
||||
**Step 2: iOS unit tests green**
|
||||
|
||||
Run: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme HoneyDue -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:HoneyDueTests test 2>&1 | tail -20`
|
||||
|
||||
**Step 3: The Task 0.5 regression XCUITest now passes**
|
||||
|
||||
Wipe state for a clean run (mirrors Task 0.5 Step 1):
|
||||
```
|
||||
xcrun simctl uninstall booted com.myhoneydue.honeyDue.dev
|
||||
docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml down
|
||||
docker volume rm honeydueapi-go_postgres_data
|
||||
docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
Wait for `curl -fsS http://127.0.0.1:8000/api/health/` → 200. Then re-run the regression test:
|
||||
|
||||
```
|
||||
xcodebuild -project iosApp/honeyDue.xcodeproj \
|
||||
-scheme HoneyDueUITests \
|
||||
-sdk iphonesimulator \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||
-only-testing:HoneyDueUITests/Suite11_TaskCacheRegressionTests/test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart \
|
||||
test 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: **TEST SUCCEEDED**. The "Tasks created during onboarding must appear on residence detail without restart" assertion now holds.
|
||||
|
||||
**If it FAILS:** the cache fix is incomplete. Inspect the test report video (`xcrun xcresulttool get --path build/reports/...xcresult ...`) and follow the failure point. Common causes: missed updateTask call site in the dual-cache deletion, a residual reader of `_tasksByResidence` in iOS not pruned, or a race between `getTasks(forceRefresh=true)` and the residence detail's first observation. **DO NOT** weaken the test to make it pass — fix the underlying issue.
|
||||
|
||||
**Step 4: Stress run (catch flakiness before merge)**
|
||||
|
||||
Run the test 5× to confirm it's stable, not just lucky:
|
||||
```
|
||||
for i in 1 2 3 4 5; do
|
||||
xcrun simctl uninstall booted com.myhoneydue.honeyDue.dev
|
||||
docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml down >/dev/null
|
||||
docker volume rm honeydueapi-go_postgres_data >/dev/null
|
||||
docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml up -d >/dev/null
|
||||
until curl -fsS http://127.0.0.1:8000/api/health/ >/dev/null 2>&1; do sleep 2; done
|
||||
xcodebuild -project iosApp/honeyDue.xcodeproj -scheme HoneyDueUITests -sdk iphonesimulator \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||
-only-testing:HoneyDueUITests/Suite11_TaskCacheRegressionTests/test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart \
|
||||
test 2>&1 | tail -3
|
||||
echo "=== run $i done ==="
|
||||
done
|
||||
```
|
||||
|
||||
Expected: 5/5 TEST SUCCEEDED. If even one fails, treat as flaky — don't merge.
|
||||
|
||||
**Step 5: Diff summary**
|
||||
|
||||
Run: `git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP diff --stat master...HEAD`
|
||||
Expected: net deletion ~80-150 lines across the listed files. If the diff is much larger, scope creep — review commits.
|
||||
|
||||
**Step 6: Push and open a PR (only if user confirms)**
|
||||
|
||||
Don't push without asking the user. Wait for explicit go-ahead.
|
||||
|
||||
---
|
||||
|
||||
## Rollback plan
|
||||
|
||||
If anything goes sideways in production:
|
||||
1. `git revert <merge-commit-sha>` — every commit in this plan is independently revertable in reverse order, but the cleanest rollback is reverting the merge commit.
|
||||
2. Old persistence blob format is preserved by `ignoreUnknownKeys` — no migration required.
|
||||
3. Backend `/api/tasks/by-residence/:id/` was never touched, so a rolled-back client immediately starts using it again with no server change.
|
||||
|
||||
---
|
||||
|
||||
## Notes for the executing engineer
|
||||
|
||||
- **Frequent commits.** Every task ends with a commit. If you deviate from the plan, commit before deviating.
|
||||
- **Don't auto-commit any other changes.** Per `honeyDueKMP/CLAUDE.md`: "DO NOT auto-commit code changes." Commit only what's specified.
|
||||
- **Don't push to remote.** Let the user trigger the push after they review.
|
||||
- **TaskColumnsResponse fields.** The `Column` data class in `CustomTask.kt` may have more fields than shown (display label, color, sort order). Read it before writing the standard column shell in Task 2 — the test will fail on missing required constructor args.
|
||||
- **TaskResponse fields.** Same — has many fields. For test fixtures, build a small helper:
|
||||
```kotlin
|
||||
private fun sampleTask(id: Int, residenceId: Int, column: String) = TaskResponse(...)
|
||||
```
|
||||
in the test file rather than repeating the giant constructor.
|
||||
- **SKIE/Swift copy.** TaskColumnsResponse `.copy()` from Swift may need `.doCopy(...)` if SKIE renamed it. Check `iosApp/iosApp/Task/TaskViewModel.swift` line 387 onward for an existing example of how Swift copies a Kotlin data class.
|
||||
- **Don't refactor "while you're here."** This plan is laser-focused on the cache unification. Other smells you spot — log them, don't fix them in this PR.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -15,6 +15,6 @@
|
||||
<key>manageAppVersionAndBuildNumber</key>
|
||||
<true/>
|
||||
<key>teamID</key>
|
||||
<string>V3PF3M6B6U</string>
|
||||
<string>X86BR9WTLD</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -148,7 +148,7 @@ final class WidgetActionManager {
|
||||
static let shared = WidgetActionManager()
|
||||
|
||||
private let appGroupIdentifier: String = {
|
||||
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.tt.honeyDue.dev"
|
||||
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev"
|
||||
}()
|
||||
private let pendingTasksFileName = "widget_pending_tasks.json"
|
||||
private let tokenKey = "widget_auth_token"
|
||||
|
||||
@@ -111,7 +111,7 @@ class CacheManager {
|
||||
}
|
||||
|
||||
private static let appGroupIdentifier: String = {
|
||||
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.tt.honeyDue.dev"
|
||||
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev"
|
||||
}()
|
||||
private static let tasksFileName = "widget_tasks.json"
|
||||
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
}
|
||||
],
|
||||
"defaultOptions" : {
|
||||
"testTimeoutsEnabled" : true,
|
||||
"defaultTestExecutionTimeAllowance" : 300,
|
||||
"targetForVariableExpansion" : {
|
||||
"containerPath" : "container:honeyDue.xcodeproj",
|
||||
"identifier" : "D4ADB376A7A4CFB73469E173",
|
||||
"name" : "HoneyDue"
|
||||
}
|
||||
},
|
||||
"testTimeoutsEnabled" : true
|
||||
},
|
||||
"testTargets" : [
|
||||
{
|
||||
|
||||
@@ -15,8 +15,6 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
||||
("admin", "test1234")
|
||||
}
|
||||
|
||||
override var includeResetStateLaunchArgument: Bool { false }
|
||||
|
||||
// MARK: - API Session
|
||||
|
||||
private(set) var session: TestSession!
|
||||
@@ -24,12 +22,22 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override class func setUp() {
|
||||
super.setUp()
|
||||
guard TestAccountAPIClient.isBackendReachable() else { return }
|
||||
// Ensure both known test accounts exist (covers all subclass credential overrides)
|
||||
if TestAccountAPIClient.login(username: "testuser", password: "TestPass123!") == nil {
|
||||
_ = TestAccountAPIClient.createVerifiedAccount(username: "testuser", email: "testuser@honeydue.com", password: "TestPass123!")
|
||||
}
|
||||
if TestAccountAPIClient.login(username: "admin", password: "test1234") == nil {
|
||||
_ = TestAccountAPIClient.createVerifiedAccount(username: "admin", email: "admin@honeydue.com", password: "test1234")
|
||||
}
|
||||
}
|
||||
|
||||
override func setUpWithError() throws {
|
||||
if needsAPISession {
|
||||
guard TestAccountAPIClient.isBackendReachable() else {
|
||||
throw XCTSkip("Backend not reachable at \(TestAccountAPIClient.baseURL)")
|
||||
}
|
||||
}
|
||||
|
||||
try super.setUpWithError()
|
||||
|
||||
|
||||
@@ -191,19 +191,35 @@ extension XCUIElement {
|
||||
// SecureTextFields may trigger iOS strong password suggestion dialog
|
||||
// which blocks the regular keyboard. Handle them with a dedicated path.
|
||||
if elementType == .secureTextField {
|
||||
// Dismiss any open keyboard first — iOS 26 fails to transfer focus
|
||||
// from a TextField to a SecureTextField if the keyboard is already up.
|
||||
if app.keyboards.firstMatch.exists {
|
||||
let navBar = app.navigationBars.firstMatch
|
||||
if navBar.exists {
|
||||
navBar.tap()
|
||||
}
|
||||
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2)
|
||||
}
|
||||
|
||||
tap()
|
||||
// Dismiss "Choose My Own Password" or "Not Now" if iOS suggests a strong password
|
||||
let chooseOwn = app.buttons["Choose My Own Password"]
|
||||
if chooseOwn.waitForExistence(timeout: 1) {
|
||||
if chooseOwn.waitForExistence(timeout: 0.5) {
|
||||
chooseOwn.tap()
|
||||
} else {
|
||||
let notNow = app.buttons["Not Now"]
|
||||
if notNow.exists && notNow.isHittable { notNow.tap() }
|
||||
}
|
||||
if app.keyboards.firstMatch.waitForExistence(timeout: 2) {
|
||||
// Wait for keyboard after tapping SecureTextField
|
||||
if !app.keyboards.firstMatch.waitForExistence(timeout: 5) {
|
||||
// Retry tap — first tap may not have acquired focus
|
||||
tap()
|
||||
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
|
||||
}
|
||||
if app.keyboards.firstMatch.exists {
|
||||
typeText(text)
|
||||
} else {
|
||||
app.typeText(text)
|
||||
XCTFail("Keyboard did not appear after tapping SecureTextField: \(self)", file: file, line: line)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -257,8 +257,6 @@ struct RegisterScreenObject {
|
||||
|
||||
private var usernameField: XCUIElement { app.textFields[UITestID.Auth.registerUsernameField] }
|
||||
private var emailField: XCUIElement { app.textFields[UITestID.Auth.registerEmailField] }
|
||||
private var passwordField: XCUIElement { app.secureTextFields[UITestID.Auth.registerPasswordField] }
|
||||
private var confirmPasswordField: XCUIElement { app.secureTextFields[UITestID.Auth.registerConfirmPasswordField] }
|
||||
private var registerButton: XCUIElement { app.buttons[UITestID.Auth.registerButton] }
|
||||
private var cancelButton: XCUIElement { app.buttons[UITestID.Auth.registerCancelButton] }
|
||||
|
||||
@@ -268,30 +266,32 @@ struct RegisterScreenObject {
|
||||
}
|
||||
|
||||
func fill(username: String, email: String, password: String) {
|
||||
func advanceToNextField() {
|
||||
let keys = ["Next", "Return", "return", "Done", "done"]
|
||||
for key in keys {
|
||||
let button = app.keyboards.buttons[key]
|
||||
if button.waitForExistence(timeout: 1) && button.isHittable {
|
||||
button.tap()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// iOS 26 bug: SecureTextField won't gain keyboard focus when tapped directly.
|
||||
// Workaround: toggle password visibility first to convert SecureField → TextField,
|
||||
// then use focusAndType() on all regular TextFields.
|
||||
usernameField.waitForExistenceOrFail(timeout: 10)
|
||||
|
||||
// Scroll down to reveal the password toggle buttons (they're below the fold)
|
||||
let scrollView = app.scrollViews.firstMatch
|
||||
if scrollView.exists { scrollView.swipeUp() }
|
||||
|
||||
// Toggle both password visibility buttons (converts SecureField → TextField)
|
||||
let toggleButtons = app.buttons.matching(NSPredicate(format: "label == 'Toggle password visibility'"))
|
||||
for i in 0..<toggleButtons.count {
|
||||
let toggle = toggleButtons.element(boundBy: i)
|
||||
if toggle.exists && toggle.isHittable {
|
||||
toggle.tap()
|
||||
}
|
||||
}
|
||||
|
||||
// After toggling, password fields are regular TextFields.
|
||||
// Don't swipeDown — it dismisses the sheet. focusAndType() auto-scrolls via tap().
|
||||
let passwordField = app.textFields[UITestID.Auth.registerPasswordField]
|
||||
let confirmPasswordField = app.textFields[UITestID.Auth.registerConfirmPasswordField]
|
||||
|
||||
usernameField.focusAndType(username, app: app)
|
||||
advanceToNextField()
|
||||
|
||||
emailField.waitForExistenceOrFail(timeout: 10)
|
||||
emailField.focusAndType(email, app: app)
|
||||
advanceToNextField()
|
||||
|
||||
passwordField.waitForExistenceOrFail(timeout: 10)
|
||||
passwordField.focusAndType(password, app: app)
|
||||
advanceToNextField()
|
||||
|
||||
confirmPasswordField.waitForExistenceOrFail(timeout: 10)
|
||||
confirmPasswordField.focusAndType(password, app: app)
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,28 @@ struct TaskListScreen {
|
||||
}
|
||||
|
||||
func findTask(title: String) -> XCUIElement {
|
||||
app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", title)).firstMatch
|
||||
let predicate = NSPredicate(format: "label CONTAINS %@", title)
|
||||
let match = app.staticTexts.containing(predicate).firstMatch
|
||||
|
||||
// If found immediately, return
|
||||
if match.waitForExistence(timeout: 3) { return match }
|
||||
|
||||
// Scroll through kanban columns (swipe left up to 6 times)
|
||||
let scrollView = app.scrollViews.firstMatch
|
||||
guard scrollView.exists else { return match }
|
||||
|
||||
for _ in 0..<6 {
|
||||
scrollView.swipeLeft()
|
||||
if match.waitForExistence(timeout: 1) { return match }
|
||||
}
|
||||
|
||||
// Scroll back and try right direction
|
||||
for _ in 0..<6 {
|
||||
scrollView.swipeRight()
|
||||
if match.waitForExistence(timeout: 1) { return match }
|
||||
}
|
||||
|
||||
return match
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import XCTest
|
||||
|
||||
/// Suite 11 — captures the gitea#2 regression at the user-visible level:
|
||||
/// after onboarding (register → name residence → bulk-create tasks → land
|
||||
/// on home), tapping the residence cell shows "no tasks" even though the
|
||||
/// server has them. Restarting the app fixes it. This test reproduces the
|
||||
/// flow without an app restart and asserts that tasks render on the
|
||||
/// residence detail screen.
|
||||
///
|
||||
/// CRITICAL: this test must FAIL at the cache-unification fix's first
|
||||
/// commit and must PASS after Phase 1-3 lands. The failing assertion is
|
||||
/// pinned to a specific message so the regression is unambiguous.
|
||||
///
|
||||
/// The test deliberately does NOT visit the Tasks tab between onboarding
|
||||
/// and tapping the residence cell. Visiting the Tasks tab would prime
|
||||
/// `_allTasks` and mask the bug — the bug is that residence detail
|
||||
/// cannot recover from the empty-cache + sink-timing window on its own.
|
||||
final class Suite11_TaskCacheRegressionTests: BaseUITestCase {
|
||||
// We need to start at the onboarding welcome screen, not the standalone
|
||||
// login screen — `completeOnboarding` would skip the entire flow.
|
||||
override var completeOnboarding: Bool { false }
|
||||
// Single test in this suite — relaunch isn't necessary, but we want a
|
||||
// clean state every time (handled by the default --reset-state).
|
||||
override var relaunchBetweenTests: Bool { true }
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
/// DEBUG_FIXED_CODES=true on the local Go API hardcodes this code.
|
||||
private let debugVerificationCode = "123456"
|
||||
|
||||
/// Stable name for the residence we create in onboarding. Used both for
|
||||
/// the form input and to address the cell on the home screen via
|
||||
/// `app.staticTexts[residenceName]` if the id-based identifier doesn't
|
||||
/// resolve in time.
|
||||
private let residenceName = "UI Test Property"
|
||||
|
||||
// MARK: - Test
|
||||
|
||||
/// Reproduces gitea#2: tasks created via the onboarding bulk endpoint
|
||||
/// must appear on the residence detail screen without an app restart
|
||||
/// and without first visiting the Tasks tab.
|
||||
@MainActor
|
||||
func test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart() throws {
|
||||
// Step 1 — Register a fresh user via the onboarding Start Fresh flow.
|
||||
// The flow is: Welcome → ValueProps → NameResidence → CreateAccount
|
||||
// → VerifyEmail → HomeProfile → FirstTask → main app.
|
||||
let createAccount = TestFlows.navigateStartFreshToCreateAccount(
|
||||
app: app,
|
||||
residenceName: residenceName
|
||||
)
|
||||
createAccount.waitForLoad(timeout: navigationTimeout)
|
||||
|
||||
// Step 2 — Fill the create-account form. We address the onboarding
|
||||
// form's fields (not the standalone register sheet's fields).
|
||||
let creds = TestAccountManager.uniqueCredentials(prefix: "gitea2")
|
||||
|
||||
createAccount.expandEmailSignup()
|
||||
|
||||
// Use the same focusAndType path that OnboardingTests uses — it
|
||||
// already handles SecureTextField + iOS strong-password panel.
|
||||
// Under --ui-testing, OrganicOnboardingSecureField defaults to
|
||||
// visibility=ON (renders as TextField) to dodge the iOS 26 SecureField
|
||||
// keyboard bug. Query textFields, not secureTextFields.
|
||||
let usernameField = app.textFields[AccessibilityIdentifiers.Onboarding.usernameField]
|
||||
let emailField = app.textFields[AccessibilityIdentifiers.Onboarding.emailField]
|
||||
let passwordField = app.textFields[AccessibilityIdentifiers.Onboarding.passwordField]
|
||||
let confirmPasswordField = app.textFields[AccessibilityIdentifiers.Onboarding.confirmPasswordField]
|
||||
|
||||
usernameField.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||
usernameField.focusAndType(creds.username, app: app)
|
||||
emailField.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||
emailField.focusAndType(creds.email, app: app)
|
||||
passwordField.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||
passwordField.focusAndType(creds.password, app: app)
|
||||
confirmPasswordField.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||
confirmPasswordField.focusAndType(creds.password, app: app)
|
||||
|
||||
let createAccountButton = app.descendants(matching: .any)
|
||||
.matching(identifier: AccessibilityIdentifiers.Onboarding.createAccountButton)
|
||||
.firstMatch
|
||||
createAccountButton.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||
createAccountButton.forceTap()
|
||||
|
||||
// Step 3 — Verify email with the debug fixed code.
|
||||
let verification = VerificationScreen(app: app)
|
||||
verification.waitForLoad(timeout: loginTimeout)
|
||||
verification.enterCode(debugVerificationCode)
|
||||
// Many onboarding verification screens auto-submit on a 6-digit
|
||||
// code. If a verify button still exists and a code field is still
|
||||
// visible, tap it to push past edge cases.
|
||||
if verification.codeField.waitForExistence(timeout: 1) && verification.verifyButton.exists {
|
||||
verification.submitCode()
|
||||
}
|
||||
|
||||
// Step 4 — Skip the home-profile step. The home-profile screen has
|
||||
// its own Skip button (the shared onboarding skip in the nav bar)
|
||||
// which routes to the first-task step without making us pick climate
|
||||
// / appliance fields.
|
||||
let onboardingSkipButton = app.buttons[AccessibilityIdentifiers.Onboarding.skipButton]
|
||||
XCTAssertTrue(
|
||||
onboardingSkipButton.waitForExistence(timeout: loginTimeout),
|
||||
"Onboarding skip button should exist on the home-profile screen"
|
||||
)
|
||||
// The skip button can briefly be non-hittable during the screen-in
|
||||
// transition. Use forceTap() to bypass the strict hittable check.
|
||||
// We confirmed existence above; if the tap doesn't land on the
|
||||
// intended button the next assertion (Browse All tab) will catch it.
|
||||
onboardingSkipButton.forceTap()
|
||||
|
||||
// Step 5 — Switch to the "Browse All" tab on the First-Task screen.
|
||||
// "For You" suggestions can be empty for a fresh residence with no
|
||||
// home-profile data, so deterministic browsing is required.
|
||||
// The tab bar is a SwiftUI segmented Picker — its segments are
|
||||
// exposed as buttons with the segment label, regardless of an
|
||||
// identifier on the parent.
|
||||
let browseAllTab = app.buttons["Browse All"]
|
||||
XCTAssertTrue(
|
||||
browseAllTab.waitForExistence(timeout: loginTimeout),
|
||||
"Browse All tab should appear on the first-task screen"
|
||||
)
|
||||
browseAllTab.tap()
|
||||
|
||||
// Step 6 — Pick 3 templates by accessibility identifier prefix.
|
||||
// The catalog is loaded via GET /api/tasks/templates/grouped/, so
|
||||
// we need to wait for at least one row to render before tapping.
|
||||
let templateRowQuery = app.buttons.matching(
|
||||
NSPredicate(format: "identifier BEGINSWITH %@",
|
||||
AccessibilityIdentifiers.Onboarding.templateRowPrefix)
|
||||
)
|
||||
|
||||
// Wait for the catalog to load. The grouped endpoint returns first
|
||||
// category expanded by default in the view, so rows should appear
|
||||
// shortly after Browse All becomes visible. Network call: 10s.
|
||||
let firstRow = templateRowQuery.element(boundBy: 0)
|
||||
XCTAssertTrue(
|
||||
firstRow.waitForExistence(timeout: loginTimeout),
|
||||
"At least one template row must render on the Browse All tab. " +
|
||||
"If no rows appear, the catalog endpoint failed — bug repro is invalid."
|
||||
)
|
||||
|
||||
// Tap the first 3 visible rows. Some categories may collapse rows
|
||||
// we never see; we only need at least 1, so the floor is 1 with a
|
||||
// soft cap of 3.
|
||||
let rowCount = templateRowQuery.count
|
||||
let toPick = min(3, rowCount)
|
||||
XCTAssertGreaterThanOrEqual(toPick, 1, "Expected at least one template row")
|
||||
for index in 0..<toPick {
|
||||
let row = templateRowQuery.element(boundBy: index)
|
||||
row.waitUntilHittable(timeout: navigationTimeout)
|
||||
row.tap()
|
||||
}
|
||||
|
||||
// Step 7 — Submit the bulk-create. This is the
|
||||
// POST /api/tasks/bulk/ call that produces the inconsistent client
|
||||
// cache state at the heart of gitea#2.
|
||||
let submitButton = app.buttons[AccessibilityIdentifiers.Onboarding.submitTasksButton]
|
||||
XCTAssertTrue(
|
||||
submitButton.waitForExistence(timeout: navigationTimeout),
|
||||
"Submit-tasks button must exist on the first-task screen"
|
||||
)
|
||||
submitButton.waitUntilHittable(timeout: navigationTimeout).tap()
|
||||
|
||||
// Step 8 — Land on the main app (Residences tab is selected by
|
||||
// default). CRITICAL: do NOT tap the Tasks tab. Tapping it would
|
||||
// populate `_allTasks` and mask the bug.
|
||||
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
let reachedMain = mainTabs.waitForExistence(timeout: loginTimeout)
|
||||
|| tabBar.waitForExistence(timeout: navigationTimeout)
|
||||
XCTAssertTrue(reachedMain, "App should reach main tabs after onboarding submit")
|
||||
|
||||
// Step 9 — Tap the residence cell directly. Prefer the
|
||||
// identifier-prefix match for any cell; fall back to the static
|
||||
// text match by name.
|
||||
let residenceCellQuery = app.buttons.matching(
|
||||
NSPredicate(format: "identifier BEGINSWITH %@",
|
||||
AccessibilityIdentifiers.Residence.cellPrefix)
|
||||
)
|
||||
let residenceCell = residenceCellQuery.firstMatch
|
||||
if residenceCell.waitForExistence(timeout: navigationTimeout) && residenceCell.isHittable {
|
||||
residenceCell.tap()
|
||||
} else {
|
||||
// Fallback: tap the static text inside the card. The
|
||||
// NavigationLink wraps the entire card so a tap on the name
|
||||
// still routes into the detail view.
|
||||
let residenceText = app.staticTexts[residenceName]
|
||||
XCTAssertTrue(
|
||||
residenceText.waitForExistence(timeout: navigationTimeout),
|
||||
"Residence cell or name '\(residenceName)' must exist on the residences list"
|
||||
)
|
||||
residenceText.tap()
|
||||
}
|
||||
|
||||
// Step 10 — THE BUG ASSERTION. With the bug present:
|
||||
// - `_allTasks` is null on the client (never primed).
|
||||
// - `_tasksByResidence[id]` is empty (cache miss).
|
||||
// - residence detail attempts to load, hits the iOS Combine sink
|
||||
// timing window, and renders the empty state.
|
||||
// With the fix, both `_allTasks` is populated by `bulkCreateTasks`
|
||||
// and residence detail filters from it in-memory, so the empty
|
||||
// state must not appear.
|
||||
let taskRowQuery = app.descendants(matching: .any).matching(
|
||||
NSPredicate(format: "identifier BEGINSWITH %@",
|
||||
AccessibilityIdentifiers.Task.rowPrefix)
|
||||
)
|
||||
let firstTaskRow = taskRowQuery.element(boundBy: 0)
|
||||
let anyTaskAppeared = firstTaskRow.waitForExistence(timeout: 10)
|
||||
|
||||
let emptyState = app.otherElements[AccessibilityIdentifiers.Task.noTasksLabel]
|
||||
let emptyStateVisible = emptyState.exists
|
||||
|
||||
// Pin the failure message so the bug-capture is unambiguous. This
|
||||
// is the assertion that should FAIL at this commit and PASS after
|
||||
// the cache fix lands. Don't change the message — Task 12 grep's
|
||||
// for it.
|
||||
XCTAssertTrue(
|
||||
anyTaskAppeared && !emptyStateVisible,
|
||||
"Tasks created during onboarding must appear on residence detail without restart (gitea#2)"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import XCTest
|
||||
/// Tests verify both positive AND negative conditions to ensure robust validation
|
||||
final class Suite1_RegistrationTests: BaseUITestCase {
|
||||
override var completeOnboarding: Bool { true }
|
||||
override var includeResetStateLaunchArgument: Bool { false }
|
||||
override var relaunchBetweenTests: Bool { true }
|
||||
|
||||
|
||||
@@ -154,8 +153,26 @@ final class Suite1_RegistrationTests: BaseUITestCase {
|
||||
return app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Verify'")).firstMatch
|
||||
}
|
||||
|
||||
/// Dismiss keyboard safely — use the Done button if available, or tap
|
||||
/// a non-interactive area. Avoid nav bar (has Cancel button) and Return key (triggers onSubmit).
|
||||
/// Submit the registration form after filling it. Uses keyboard "Go" button
|
||||
/// or falls back to dismissing keyboard and tapping the register button.
|
||||
private func submitRegistrationForm() {
|
||||
let goButton = app.keyboards.buttons["Go"]
|
||||
if goButton.waitForExistence(timeout: 2) && goButton.isHittable {
|
||||
goButton.tap()
|
||||
return
|
||||
}
|
||||
// Fallback: dismiss keyboard, then tap register button
|
||||
dismissKeyboard()
|
||||
let registerButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
|
||||
registerButton.waitForExistenceOrFail(timeout: 5)
|
||||
if !registerButton.isHittable {
|
||||
let scrollView = app.scrollViews.firstMatch
|
||||
if scrollView.exists { registerButton.scrollIntoView(in: scrollView) }
|
||||
}
|
||||
registerButton.forceTap()
|
||||
}
|
||||
|
||||
/// Dismiss keyboard safely by tapping a neutral area.
|
||||
private func dismissKeyboard() {
|
||||
guard app.keyboards.firstMatch.exists else { return }
|
||||
// Try toolbar Done button first
|
||||
@@ -165,63 +182,44 @@ final class Suite1_RegistrationTests: BaseUITestCase {
|
||||
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2)
|
||||
return
|
||||
}
|
||||
// Tap the sheet title area (safe neutral zone in the registration form)
|
||||
let title = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Create' OR label CONTAINS[c] 'Register' OR label CONTAINS[c] 'Account'")).firstMatch
|
||||
if title.exists && title.isHittable {
|
||||
title.tap()
|
||||
// Try navigation bar (works on most screens)
|
||||
let navBar = app.navigationBars.firstMatch
|
||||
if navBar.exists && navBar.isHittable {
|
||||
navBar.tap()
|
||||
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2)
|
||||
return
|
||||
}
|
||||
// Last resort: tap the form area above the keyboard
|
||||
let formArea = app.scrollViews.firstMatch
|
||||
if formArea.exists {
|
||||
let topCenter = formArea.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1))
|
||||
topCenter.tap()
|
||||
}
|
||||
// Fallback: tap top-center of the app
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap()
|
||||
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2)
|
||||
}
|
||||
|
||||
/// Fill registration form with given credentials
|
||||
/// Fill registration form with given credentials.
|
||||
/// Uses Return key (\n) to trigger SwiftUI's .onSubmit / @FocusState field
|
||||
/// transitions. Direct field taps fail on iOS 26 when transitioning from
|
||||
/// TextField to SecureTextField (keyboard never appears).
|
||||
private func fillRegistrationForm(username: String, email: String, password: String, confirmPassword: String) {
|
||||
// iOS 26 bug: SecureTextField won't gain keyboard focus when tapped directly.
|
||||
// Workaround: toggle password visibility first to convert SecureField → TextField.
|
||||
let scrollView = app.scrollViews.firstMatch
|
||||
if scrollView.exists { scrollView.swipeUp() }
|
||||
|
||||
let toggleButtons = app.buttons.matching(NSPredicate(format: "label == 'Toggle password visibility'"))
|
||||
for i in 0..<toggleButtons.count {
|
||||
let toggle = toggleButtons.element(boundBy: i)
|
||||
if toggle.exists && toggle.isHittable { toggle.tap() }
|
||||
}
|
||||
|
||||
// Don't swipeDown — it dismisses the sheet. focusAndType() auto-scrolls via tap().
|
||||
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
|
||||
let emailField = app.textFields[AccessibilityIdentifiers.Authentication.registerEmailField]
|
||||
let passwordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerPasswordField]
|
||||
let confirmPasswordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField]
|
||||
|
||||
// STRICT: All fields must exist and be hittable
|
||||
XCTAssertTrue(usernameField.isHittable, "Username field must be hittable")
|
||||
XCTAssertTrue(emailField.isHittable, "Email field must be hittable")
|
||||
XCTAssertTrue(passwordField.isHittable, "Password field must be hittable")
|
||||
XCTAssertTrue(confirmPasswordField.isHittable, "Confirm password field must be hittable")
|
||||
let passwordField = app.textFields[AccessibilityIdentifiers.Authentication.registerPasswordField]
|
||||
let confirmPasswordField = app.textFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField]
|
||||
|
||||
usernameField.focusAndType(username, app: app)
|
||||
|
||||
emailField.focusAndType(email, app: app)
|
||||
|
||||
// SecureTextFields: tap, handle strong password suggestion, type directly
|
||||
passwordField.tap()
|
||||
let chooseOwn = app.buttons["Choose My Own Password"]
|
||||
if chooseOwn.waitForExistence(timeout: 2) { chooseOwn.tap() }
|
||||
let notNow = app.buttons["Not Now"]
|
||||
if notNow.exists && notNow.isHittable { notNow.tap() }
|
||||
_ = app.keyboards.firstMatch.waitForExistence(timeout: 2)
|
||||
app.typeText(password)
|
||||
|
||||
// Use Next keyboard button to advance to confirm password (avoids tap-interception)
|
||||
let nextButton = app.keyboards.buttons["Next"]
|
||||
let goButton = app.keyboards.buttons["Go"]
|
||||
if nextButton.exists && nextButton.isHittable {
|
||||
nextButton.tap()
|
||||
} else if goButton.exists && goButton.isHittable {
|
||||
// Don't tap Go — it would submit the form. Tap the field instead.
|
||||
confirmPasswordField.tap()
|
||||
} else {
|
||||
confirmPasswordField.tap()
|
||||
}
|
||||
if chooseOwn.waitForExistence(timeout: 2) { chooseOwn.tap() }
|
||||
if notNow.exists && notNow.isHittable { notNow.tap() }
|
||||
_ = app.keyboards.firstMatch.waitForExistence(timeout: 2)
|
||||
app.typeText(confirmPassword)
|
||||
passwordField.focusAndType(password, app: app)
|
||||
confirmPasswordField.focusAndType(confirmPassword, app: app)
|
||||
}
|
||||
|
||||
// MARK: - 1. UI/Element Tests (no backend, pure UI verification)
|
||||
@@ -386,43 +384,19 @@ final class Suite1_RegistrationTests: BaseUITestCase {
|
||||
let username = testUsername
|
||||
let email = testEmail
|
||||
|
||||
// Use the proven RegisterScreenObject approach (navigates + fills via screen object)
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
login.tapSignUp()
|
||||
// Use the same proven flow as tests 09-12
|
||||
navigateToRegistration()
|
||||
fillRegistrationForm(
|
||||
username: username,
|
||||
email: email,
|
||||
password: testPassword,
|
||||
confirmPassword: testPassword
|
||||
)
|
||||
|
||||
let register = RegisterScreenObject(app: app)
|
||||
register.waitForLoad(timeout: navigationTimeout)
|
||||
register.fill(username: username, email: email, password: testPassword)
|
||||
submitRegistrationForm()
|
||||
|
||||
// Dismiss keyboard, then scroll to and tap the register button
|
||||
let registerButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
|
||||
registerButton.waitForExistenceOrFail(timeout: defaultTimeout, message: "Register button should exist")
|
||||
if !registerButton.isHittable {
|
||||
let scrollView = app.scrollViews.firstMatch
|
||||
if scrollView.exists { registerButton.scrollIntoView(in: scrollView) }
|
||||
}
|
||||
// Try keyboard Go button first (confirm password has .submitLabel(.go) + .onSubmit { register() })
|
||||
let goButton = app.keyboards.buttons["Go"]
|
||||
if goButton.exists && goButton.isHittable {
|
||||
goButton.tap()
|
||||
} else {
|
||||
// Fallback: scroll to and tap the register button
|
||||
if !registerButton.isHittable {
|
||||
let scrollView = app.scrollViews.firstMatch
|
||||
if scrollView.exists { registerButton.scrollIntoView(in: scrollView) }
|
||||
}
|
||||
registerButton.forceTap()
|
||||
}
|
||||
|
||||
// Wait for form to dismiss (API call completes and navigates to verification)
|
||||
let regUsernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
|
||||
XCTAssertTrue(regUsernameField.waitForNonExistence(timeout: 15),
|
||||
"Registration form must disappear. If this fails consistently, iOS Strong Password autofill " +
|
||||
"may be interfering with SecureTextField input in the simulator.")
|
||||
|
||||
// STRICT: Verification screen must appear
|
||||
XCTAssertTrue(waitForVerificationScreen(timeout: 10), "Verification screen must appear after registration")
|
||||
// Wait for verification screen to appear (registration form may still exist underneath)
|
||||
XCTAssertTrue(waitForVerificationScreen(timeout: 15), "Verification screen must appear after registration")
|
||||
|
||||
// NEGATIVE CHECK: Tab bar should NOT be hittable while on verification
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
@@ -430,55 +404,43 @@ final class Suite1_RegistrationTests: BaseUITestCase {
|
||||
XCTAssertFalse(tabBar.isHittable, "Tab bar should NOT be interactive while verification is required")
|
||||
}
|
||||
|
||||
// Enter verification code
|
||||
// Enter verification code — the verification screen auto-submits when 6 digits are typed.
|
||||
// IMPORTANT: Do NOT use focusAndType() here — it taps the nav bar to dismiss the keyboard,
|
||||
// which can accidentally hit the logout button in the toolbar.
|
||||
let codeField = verificationCodeField()
|
||||
XCTAssertTrue(codeField.waitForExistence(timeout: 5), "Verification code field must exist")
|
||||
XCTAssertTrue(codeField.isHittable, "Verification code field must be tappable")
|
||||
codeField.tap()
|
||||
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
|
||||
codeField.typeText(testVerificationCode)
|
||||
|
||||
codeField.focusAndType(testVerificationCode, app: app)
|
||||
// Auto-submit: typing 6 digits triggers verifyEmail() and navigates to main app.
|
||||
// Wait for the main app to appear (RootView sets ui.root.mainTabs when showing MainTabView).
|
||||
let mainTabs = app.otherElements["ui.root.mainTabs"]
|
||||
let mainAppAppeared = mainTabs.waitForExistence(timeout: 15)
|
||||
|
||||
dismissKeyboard()
|
||||
let verifyButton = verificationButton()
|
||||
XCTAssertTrue(verifyButton.exists && verifyButton.isHittable, "Verify button must be tappable")
|
||||
verifyButton.tap()
|
||||
if !mainAppAppeared {
|
||||
// Diagnostic: capture what's on screen
|
||||
let screenshot = XCTAttachment(screenshot: app.screenshot())
|
||||
screenshot.name = "post-verification-no-main-tabs"
|
||||
screenshot.lifetime = .keepAlways
|
||||
add(screenshot)
|
||||
|
||||
// STRICT: Verification screen must DISAPPEAR
|
||||
XCTAssertTrue(waitForElementToDisappear(codeField, timeout: 15), "Verification code field MUST disappear after successful verification")
|
||||
// Check if we're stuck on verification screen or login
|
||||
let stillOnVerify = codeField.exists
|
||||
let onLogin = app.textFields[AccessibilityIdentifiers.Authentication.usernameField].exists
|
||||
XCTFail("Main app did not appear after verification. StillOnVerify=\(stillOnVerify), OnLogin=\(onLogin)")
|
||||
return
|
||||
}
|
||||
|
||||
// STRICT: Must be on main app screen
|
||||
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
||||
XCTAssertTrue(residencesTab.waitForExistence(timeout: 15), "Tab bar must appear after verification")
|
||||
XCTAssertTrue(waitForElementToBeHittable(residencesTab, timeout: 5), "Residences tab MUST be tappable after verification")
|
||||
// STRICT: Tab bar must exist and be interactive
|
||||
XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 5), "Tab bar must exist in main app")
|
||||
|
||||
// NEGATIVE CHECK: Verification screen should be completely gone
|
||||
XCTAssertFalse(codeField.exists, "Verification code field must NOT exist after successful verification")
|
||||
|
||||
// Verify we can interact with the app (tap tab)
|
||||
// Cleanup: Logout via profile tab → settings → logout
|
||||
dismissKeyboard()
|
||||
residencesTab.tap()
|
||||
|
||||
// Cleanup: Logout via settings button on Residences tab
|
||||
dismissKeyboard()
|
||||
residencesTab.tap()
|
||||
|
||||
let settingsButton = app.buttons[AccessibilityIdentifiers.Navigation.settingsButton]
|
||||
XCTAssertTrue(settingsButton.waitForExistence(timeout: 5) && settingsButton.isHittable, "Settings button must be tappable")
|
||||
settingsButton.tap()
|
||||
|
||||
let logoutButton = app.buttons[AccessibilityIdentifiers.Profile.logoutButton].firstMatch
|
||||
XCTAssertTrue(logoutButton.waitForExistence(timeout: 5) && logoutButton.isHittable, "Logout button must be tappable")
|
||||
dismissKeyboard()
|
||||
logoutButton.tap()
|
||||
|
||||
let alertLogout = app.alerts.buttons["Log Out"]
|
||||
if alertLogout.waitForExistence(timeout: 3) {
|
||||
dismissKeyboard()
|
||||
alertLogout.tap()
|
||||
}
|
||||
|
||||
// STRICT: Must return to login screen
|
||||
let welcomeText = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||||
XCTAssertTrue(welcomeText.waitForExistence(timeout: 5), "Must return to login screen after logout")
|
||||
ensureLoggedOut()
|
||||
}
|
||||
|
||||
// MARK: - 4. Server-Side Validation Tests (NOW a user exists from test07)
|
||||
@@ -526,25 +488,23 @@ final class Suite1_RegistrationTests: BaseUITestCase {
|
||||
confirmPassword: testPassword
|
||||
)
|
||||
|
||||
dismissKeyboard()
|
||||
app.buttons[AccessibilityIdentifiers.Authentication.registerButton].tap()
|
||||
submitRegistrationForm()
|
||||
|
||||
// Wait for verification screen
|
||||
XCTAssertTrue(waitForVerificationScreen(timeout: 10), "Must navigate to verification screen")
|
||||
|
||||
// Enter INVALID code
|
||||
// Enter INVALID code — auto-submits at 6 digits
|
||||
// Don't use focusAndType() — it taps nav bar which can hit the logout button
|
||||
let codeField = verificationCodeField()
|
||||
XCTAssertTrue(codeField.waitForExistence(timeout: 5) && codeField.isHittable)
|
||||
codeField.focusAndType("000000", app: app) // Wrong code
|
||||
XCTAssertTrue(codeField.waitForExistence(timeout: 5))
|
||||
codeField.tap()
|
||||
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
|
||||
codeField.typeText("000000") // Wrong code → auto-submit → API error
|
||||
|
||||
let verifyButton = verificationButton()
|
||||
dismissKeyboard()
|
||||
verifyButton.tap()
|
||||
|
||||
// STRICT: Error message must appear
|
||||
let errorPredicate = NSPredicate(format: "label CONTAINS[c] 'invalid' OR label CONTAINS[c] 'error' OR label CONTAINS[c] 'incorrect' OR label CONTAINS[c] 'wrong'")
|
||||
// STRICT: Error message must appear (auto-submit verifies with wrong code)
|
||||
let errorPredicate = NSPredicate(format: "label CONTAINS[c] 'invalid' OR label CONTAINS[c] 'error' OR label CONTAINS[c] 'incorrect' OR label CONTAINS[c] 'wrong' OR label CONTAINS[c] 'expired'")
|
||||
let errorMessage = app.staticTexts.containing(errorPredicate).firstMatch
|
||||
XCTAssertTrue(errorMessage.waitForExistence(timeout: 5), "Error message MUST appear for invalid verification code")
|
||||
XCTAssertTrue(errorMessage.waitForExistence(timeout: 10), "Error message MUST appear for invalid verification code")
|
||||
}
|
||||
|
||||
func test10_verificationCodeFieldValidation() {
|
||||
@@ -559,26 +519,20 @@ final class Suite1_RegistrationTests: BaseUITestCase {
|
||||
confirmPassword: testPassword
|
||||
)
|
||||
|
||||
dismissKeyboard()
|
||||
app.buttons[AccessibilityIdentifiers.Authentication.registerButton].tap()
|
||||
submitRegistrationForm()
|
||||
|
||||
XCTAssertTrue(waitForVerificationScreen(timeout: 10))
|
||||
|
||||
// Enter incomplete code (only 3 digits)
|
||||
// Enter incomplete code (only 3 digits — won't trigger auto-submit)
|
||||
// Don't use focusAndType() — it taps nav bar which can hit the logout button
|
||||
let codeField = verificationCodeField()
|
||||
XCTAssertTrue(codeField.waitForExistence(timeout: 5) && codeField.isHittable)
|
||||
codeField.focusAndType("123", app: app) // Incomplete
|
||||
XCTAssertTrue(codeField.waitForExistence(timeout: 5))
|
||||
codeField.tap()
|
||||
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
|
||||
codeField.typeText("123") // Incomplete
|
||||
|
||||
let verifyButton = verificationButton()
|
||||
|
||||
// Button might be disabled with incomplete code
|
||||
if verifyButton.isEnabled {
|
||||
dismissKeyboard()
|
||||
verifyButton.tap()
|
||||
}
|
||||
|
||||
// STRICT: Must still be on verification screen
|
||||
XCTAssertTrue(codeField.exists && codeField.isHittable, "Must remain on verification screen with incomplete code")
|
||||
// STRICT: Must still be on verification screen (3 digits won't auto-submit)
|
||||
XCTAssertTrue(codeField.exists, "Must remain on verification screen with incomplete code")
|
||||
|
||||
// NEGATIVE CHECK: Should NOT have navigated to main app
|
||||
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
||||
@@ -588,7 +542,7 @@ final class Suite1_RegistrationTests: BaseUITestCase {
|
||||
}
|
||||
|
||||
func test11_appRelaunchWithUnverifiedUser() {
|
||||
// This test verifies the fix for: user kills app on verification screen, relaunches, should see verification again
|
||||
// This test verifies: user kills app on verification screen, relaunches, should see verification again
|
||||
|
||||
let username = testUsername
|
||||
let email = testEmail
|
||||
@@ -601,35 +555,37 @@ final class Suite1_RegistrationTests: BaseUITestCase {
|
||||
confirmPassword: testPassword
|
||||
)
|
||||
|
||||
dismissKeyboard()
|
||||
app.buttons[AccessibilityIdentifiers.Authentication.registerButton].tap()
|
||||
submitRegistrationForm()
|
||||
|
||||
// Wait for verification screen
|
||||
XCTAssertTrue(waitForVerificationScreen(timeout: 10), "Must reach verification screen")
|
||||
XCTAssertTrue(waitForVerificationScreen(timeout: 20), "Must reach verification screen")
|
||||
|
||||
// Simulate app kill and relaunch (terminate and launch)
|
||||
// Relaunch WITHOUT --reset-state so the unverified session persists.
|
||||
// Keep --ui-testing and --disable-animations but remove --reset-state and --complete-onboarding.
|
||||
app.terminate()
|
||||
app.launchArguments = ["--ui-testing", "--disable-animations"]
|
||||
app.launch()
|
||||
|
||||
// Wait for app to fully initialize
|
||||
app.otherElements["ui.app.ready"].waitForExistenceOrFail(timeout: 15)
|
||||
|
||||
// STRICT: After relaunch, unverified user MUST see verification screen, NOT main app
|
||||
let authCodeFieldAfterRelaunch = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField]
|
||||
let onboardingCodeFieldAfterRelaunch = app.textFields[AccessibilityIdentifiers.Onboarding.verificationCodeField]
|
||||
let loginScreen = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
|
||||
// Wait for app to settle
|
||||
// Wait for one of the expected screens to appear
|
||||
_ = authCodeFieldAfterRelaunch.waitForExistence(timeout: 10)
|
||||
|| onboardingCodeFieldAfterRelaunch.waitForExistence(timeout: 10)
|
||||
|| loginScreen.waitForExistence(timeout: 10)
|
||||
|| onboardingCodeFieldAfterRelaunch.waitForExistence(timeout: 5)
|
||||
|| loginScreen.waitForExistence(timeout: 5)
|
||||
|
||||
// User should either be on verification screen OR login screen (if token expired)
|
||||
// They should NEVER be on main app with unverified email
|
||||
// User should NEVER be on main app with unverified email
|
||||
if tabBar.exists && tabBar.isHittable {
|
||||
// If tab bar is accessible, that's a FAILURE - unverified user should not access main app
|
||||
XCTFail("CRITICAL: Unverified user should NOT have access to main app after relaunch. Tab bar is hittable!")
|
||||
}
|
||||
|
||||
// Acceptable states: verification screen OR login screen
|
||||
// Acceptable states: verification screen OR login screen (if token expired)
|
||||
let onVerificationScreen =
|
||||
(authCodeFieldAfterRelaunch.exists && authCodeFieldAfterRelaunch.isHittable)
|
||||
|| (onboardingCodeFieldAfterRelaunch.exists && onboardingCodeFieldAfterRelaunch.isHittable)
|
||||
@@ -638,10 +594,10 @@ final class Suite1_RegistrationTests: BaseUITestCase {
|
||||
XCTAssertTrue(onVerificationScreen || onLoginScreen,
|
||||
"After relaunch, unverified user must be on verification screen or login screen, NOT main app")
|
||||
|
||||
// Cleanup
|
||||
// Cleanup: logout from whatever screen we're on
|
||||
if onVerificationScreen {
|
||||
let logoutButton = app.buttons[AccessibilityIdentifiers.Profile.logoutButton].firstMatch
|
||||
if logoutButton.exists && logoutButton.isHittable {
|
||||
let logoutButton = app.buttons[AccessibilityIdentifiers.Authentication.verificationLogoutButton]
|
||||
if logoutButton.waitForExistence(timeout: 3) && logoutButton.isHittable {
|
||||
dismissKeyboard()
|
||||
logoutButton.tap()
|
||||
}
|
||||
@@ -660,18 +616,15 @@ final class Suite1_RegistrationTests: BaseUITestCase {
|
||||
confirmPassword: testPassword
|
||||
)
|
||||
|
||||
dismissKeyboard()
|
||||
app.buttons[AccessibilityIdentifiers.Authentication.registerButton].tap()
|
||||
submitRegistrationForm()
|
||||
|
||||
// Wait for verification screen
|
||||
XCTAssertTrue(waitForVerificationScreen(timeout: 10), "Must navigate to verification screen")
|
||||
|
||||
// STRICT: Logout button must exist and be tappable
|
||||
let logoutButton = app.buttons[AccessibilityIdentifiers.Profile.logoutButton].firstMatch
|
||||
// STRICT: Logout button must exist and be tappable (uses dedicated verify screen ID)
|
||||
let logoutButton = app.buttons[AccessibilityIdentifiers.Authentication.verificationLogoutButton]
|
||||
XCTAssertTrue(logoutButton.waitForExistence(timeout: 5), "Logout button MUST exist on verification screen")
|
||||
XCTAssertTrue(logoutButton.isHittable, "Logout button MUST be tappable on verification screen")
|
||||
|
||||
dismissKeyboard()
|
||||
logoutButton.waitUntilHittable(timeout: 5)
|
||||
logoutButton.tap()
|
||||
|
||||
// STRICT: Verification screen must disappear
|
||||
|
||||
@@ -100,18 +100,18 @@ final class Suite5_TaskTests: AuthenticatedUITestCase {
|
||||
// Wait for form to dismiss
|
||||
_ = saveButton.waitForNonExistence(timeout: navigationTimeout)
|
||||
|
||||
// Verify task appears in list (may need refresh or scroll in kanban view)
|
||||
let newTask = app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", taskTitle)).firstMatch
|
||||
if !newTask.waitForExistence(timeout: navigationTimeout) {
|
||||
pullToRefresh()
|
||||
}
|
||||
XCTAssertTrue(newTask.waitForExistence(timeout: navigationTimeout), "New task '\(taskTitle)' should appear in the list")
|
||||
|
||||
// Track for cleanup
|
||||
// Verify task was created via API (also gives the server time to process)
|
||||
if let items = TestAccountAPIClient.listTasks(token: session.token),
|
||||
let created = items.first(where: { $0.title.contains(taskTitle) }) {
|
||||
cleaner.trackTask(created.id)
|
||||
}
|
||||
|
||||
// Navigate to tasks tab and refresh to pick up the newly created task
|
||||
navigateToTasks()
|
||||
refreshTasks()
|
||||
let taskListScreen = TaskListScreen(app: app)
|
||||
let newTask = taskListScreen.findTask(title: taskTitle)
|
||||
XCTAssertTrue(newTask.waitForExistence(timeout: loginTimeout), "New task '\(taskTitle)' should appear in the list")
|
||||
}
|
||||
|
||||
// MARK: - 4. View Details
|
||||
@@ -133,24 +133,23 @@ final class Suite5_TaskTests: AuthenticatedUITestCase {
|
||||
saveButton.tap()
|
||||
_ = saveButton.waitForNonExistence(timeout: navigationTimeout)
|
||||
|
||||
// Find and tap the task (may need refresh)
|
||||
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", taskTitle)).firstMatch
|
||||
if !taskCard.waitForExistence(timeout: navigationTimeout) {
|
||||
pullToRefresh()
|
||||
}
|
||||
taskCard.waitForExistenceOrFail(timeout: navigationTimeout, message: "Created task should appear in list")
|
||||
|
||||
// Verify task was created via API (also gives the server time to process)
|
||||
if let items = TestAccountAPIClient.listTasks(token: session.token),
|
||||
let created = items.first(where: { $0.title.contains(taskTitle) }) {
|
||||
cleaner.trackTask(created.id)
|
||||
}
|
||||
|
||||
taskCard.tap()
|
||||
// Navigate to tasks tab and refresh to pick up the newly created task
|
||||
navigateToTasks()
|
||||
refreshTasks()
|
||||
let taskListScreen = TaskListScreen(app: app)
|
||||
let taskCard = taskListScreen.findTask(title: taskTitle)
|
||||
taskCard.waitForExistenceOrFail(timeout: loginTimeout, message: "Created task should appear in list")
|
||||
|
||||
// After tapping a task, the app should show task details or actions.
|
||||
// The navigation bar title or a detail view element should appear.
|
||||
let navBar = app.navigationBars.firstMatch
|
||||
XCTAssertTrue(navBar.waitForExistence(timeout: navigationTimeout), "Task detail view should load after tap")
|
||||
// Verify the task card is accessible and the actions menu exists
|
||||
// (There is no task detail screen — cards are self-contained with a context menu)
|
||||
let actionsMenu = app.buttons["Task actions"].firstMatch
|
||||
XCTAssertTrue(actionsMenu.waitForExistence(timeout: navigationTimeout), "Task actions menu should be accessible")
|
||||
}
|
||||
|
||||
// MARK: - 5. Navigation
|
||||
|
||||
@@ -22,9 +22,25 @@ final class Suite6_ComprehensiveTaskTests: AuthenticatedUITestCase {
|
||||
|
||||
// Test data tracking
|
||||
var createdTaskTitles: [String] = []
|
||||
private static var hasCleanedStaleData = false
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
|
||||
// Dismiss any open form from previous test
|
||||
let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton].firstMatch
|
||||
if cancelButton.exists { cancelButton.tap() }
|
||||
|
||||
// One-time cleanup of stale tasks from previous test runs
|
||||
if !Self.hasCleanedStaleData {
|
||||
Self.hasCleanedStaleData = true
|
||||
if let stale = TestAccountAPIClient.listTasks(token: session.token) {
|
||||
for task in stale {
|
||||
_ = TestAccountAPIClient.deleteTask(token: session.token, id: task.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure at least one residence exists (task add button requires it)
|
||||
if let residences = TestAccountAPIClient.listResidences(token: session.token),
|
||||
residences.isEmpty {
|
||||
@@ -109,6 +125,9 @@ final class Suite6_ComprehensiveTaskTests: AuthenticatedUITestCase {
|
||||
cleaner.trackTask(created.id)
|
||||
}
|
||||
|
||||
// Navigate to tasks tab to trigger list refresh and reset scroll position
|
||||
navigateToTasks()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -272,7 +291,11 @@ final class Suite6_ComprehensiveTaskTests: AuthenticatedUITestCase {
|
||||
|
||||
let task = findTask(title: originalTitle)
|
||||
XCTAssertTrue(task.waitForExistence(timeout: defaultTimeout), "Task should exist")
|
||||
task.tap()
|
||||
|
||||
// Open the task actions menu on the card (edit is inside a Menu, not a detail screen)
|
||||
let actionsMenu = app.buttons["Task actions"].firstMatch
|
||||
if actionsMenu.waitForExistence(timeout: defaultTimeout) {
|
||||
actionsMenu.tap()
|
||||
|
||||
let editButton = app.buttons[AccessibilityIdentifiers.Task.editButton].firstMatch
|
||||
if editButton.waitForExistence(timeout: defaultTimeout) {
|
||||
@@ -296,6 +319,7 @@ final class Suite6_ComprehensiveTaskTests: AuthenticatedUITestCase {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// test10_updateAllTaskFields removed — requires Actions menu accessibility identifiers
|
||||
|
||||
|
||||
@@ -14,10 +14,21 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
||||
|
||||
// Test data tracking
|
||||
var createdContractorNames: [String] = []
|
||||
private static var hasCleanedStaleData = false
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
|
||||
// One-time cleanup of stale contractors from previous test runs
|
||||
if !Self.hasCleanedStaleData {
|
||||
Self.hasCleanedStaleData = true
|
||||
if let stale = TestAccountAPIClient.listContractors(token: session.token) {
|
||||
for contractor in stale {
|
||||
_ = TestAccountAPIClient.deleteContractor(token: session.token, id: contractor.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dismiss any open form from previous test
|
||||
let cancelButton = app.buttons[AccessibilityIdentifiers.Contractor.formCancelButton].firstMatch
|
||||
if cancelButton.exists { cancelButton.tap() }
|
||||
@@ -133,6 +144,9 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
||||
let created = items.first(where: { $0.name.contains(name) }) {
|
||||
cleaner.trackContractor(created.id)
|
||||
}
|
||||
|
||||
// Navigate to contractors tab to trigger list refresh and reset scroll position
|
||||
navigateToContractors()
|
||||
}
|
||||
|
||||
private func findContractor(name: String, scrollIfNeeded: Bool = true) -> XCUIElement {
|
||||
@@ -248,9 +262,10 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
||||
}
|
||||
|
||||
for (index, specialty) in specialties.enumerated() {
|
||||
navigateToContractors()
|
||||
let contractorName = "\(specialty) Expert \(timestamp)_\(index)"
|
||||
let contractor = findContractor(name: contractorName)
|
||||
XCTAssertTrue(contractor.exists, "\(specialty) contractor should exist in list")
|
||||
XCTAssertTrue(contractor.waitForExistence(timeout: 10), "\(specialty) contractor should exist in list")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,9 +305,10 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
||||
}
|
||||
|
||||
for (index, (_, format)) in phoneFormats.enumerated() {
|
||||
navigateToContractors()
|
||||
let contractorName = "\(format) Phone \(timestamp)_\(index)"
|
||||
let contractor = findContractor(name: contractorName)
|
||||
XCTAssertTrue(contractor.exists, "Contractor with \(format) phone should exist")
|
||||
XCTAssertTrue(contractor.waitForExistence(timeout: 10), "Contractor with \(format) phone should exist")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,18 +128,35 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
||||
}
|
||||
|
||||
private func submitForm(file: StaticString = #filePath, line: UInt = #line) {
|
||||
// Dismiss keyboard so submit button is visible
|
||||
dismissKeyboard()
|
||||
// Dismiss keyboard by tapping outside form fields
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15)).tap()
|
||||
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3)
|
||||
|
||||
// If keyboard still showing (can happen with long text / autocorrect), try Return key
|
||||
if app.keyboards.firstMatch.exists {
|
||||
app.typeText("\n")
|
||||
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2)
|
||||
}
|
||||
|
||||
let submitButton = docForm.saveButton
|
||||
if !submitButton.exists || !submitButton.isHittable {
|
||||
app.swipeUp()
|
||||
_ = submitButton.waitForExistence(timeout: defaultTimeout)
|
||||
_ = submitButton.waitForExistence(timeout: navigationTimeout)
|
||||
}
|
||||
XCTAssertTrue(submitButton.exists && submitButton.isEnabled, "Submit button should exist and be enabled", file: file, line: line)
|
||||
|
||||
// First tap attempt
|
||||
if submitButton.isHittable {
|
||||
submitButton.tap()
|
||||
// Wait for form to dismiss after submit
|
||||
submitButton.waitForNonExistence(timeout: navigationTimeout, file: file, line: line)
|
||||
} else {
|
||||
submitButton.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
}
|
||||
|
||||
// Wait for form to dismiss — retry tap if button doesn't disappear
|
||||
if !submitButton.waitForNonExistence(timeout: loginTimeout) && submitButton.exists {
|
||||
submitButton.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
_ = submitButton.waitForNonExistence(timeout: loginTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up a just-created document by title and track it for API cleanup.
|
||||
@@ -770,10 +787,17 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
||||
|
||||
submitForm()
|
||||
|
||||
// Just verify it was created (partial match)
|
||||
// Track via API (also gives server time to process)
|
||||
trackDocumentForCleanup(title: longTitle)
|
||||
|
||||
// Re-navigate to refresh the list after creation
|
||||
navigateToDocuments()
|
||||
switchToDocumentsTab()
|
||||
|
||||
// Verify it was created (partial match with wait)
|
||||
let partialTitle = String(longTitle.prefix(30))
|
||||
let documentExists = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] '\(partialTitle)'")).firstMatch.exists
|
||||
XCTAssertTrue(documentExists, "Document with long title should be created")
|
||||
let documentCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] '\(partialTitle)'")).firstMatch
|
||||
XCTAssertTrue(documentCard.waitForExistence(timeout: loginTimeout), "Document with long title should be created")
|
||||
}
|
||||
|
||||
func test23_CreateWarrantyWithSpecialCharacters() {
|
||||
@@ -792,9 +816,17 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
||||
|
||||
submitForm()
|
||||
|
||||
// Track via API (also gives server time to process)
|
||||
trackDocumentForCleanup(title: specialTitle)
|
||||
|
||||
// Re-navigate to refresh the list after creation
|
||||
navigateToDocuments()
|
||||
switchToWarrantiesTab()
|
||||
|
||||
// Verify it was created (partial match with wait)
|
||||
let partialTitle = String(specialTitle.prefix(20))
|
||||
let warrantyExists = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(partialTitle)'")).firstMatch.exists
|
||||
XCTAssertTrue(warrantyExists, "Warranty with special characters should be created")
|
||||
let warrantyCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(partialTitle)'")).firstMatch
|
||||
XCTAssertTrue(warrantyCard.waitForExistence(timeout: loginTimeout), "Warranty with special characters should be created")
|
||||
}
|
||||
|
||||
func test24_RapidTabSwitching() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -663,7 +663,7 @@
|
||||
0248CABA5A5197845F2E5C26 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
APP_GROUP_IDENTIFIER = group.com.tt.honeyDue;
|
||||
APP_GROUP_IDENTIFIER = group.com.myhoneydue.honeyDue;
|
||||
ARCHS = arm64;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
@@ -671,14 +671,15 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||
DEVELOPMENT_TEAM = X86BR9WTLD;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = iosApp/Info.plist;
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO;
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs access to your camera to take photos of completed tasks";
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs access to your photo library to select photos of completed tasks";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs camera access to take photos of tasks, documents, and receipts.";
|
||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "honeyDue needs permission to save photos to your library.";
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs photo library access to attach photos to tasks and documents.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
@@ -688,7 +689,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@@ -698,14 +699,14 @@
|
||||
1C0789552EBC218D00392B46 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
APP_GROUP_IDENTIFIER = group.com.tt.honeyDue.dev;
|
||||
APP_GROUP_IDENTIFIER = group.com.myhoneydue.honeyDue.dev;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = HoneyDueExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||
DEVELOPMENT_TEAM = X86BR9WTLD;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = HoneyDue/Info.plist;
|
||||
@@ -719,7 +720,7 @@
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_SWIFT_FLAGS = "-DWIDGET_EXTENSION";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.dev.HoneyDueExtension;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.dev.HoneyDueExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -734,14 +735,14 @@
|
||||
1C0789562EBC218D00392B46 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
APP_GROUP_IDENTIFIER = group.com.tt.honeyDue;
|
||||
APP_GROUP_IDENTIFIER = group.com.myhoneydue.honeyDue;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = HoneyDueExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||
DEVELOPMENT_TEAM = X86BR9WTLD;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = HoneyDue/Info.plist;
|
||||
@@ -755,7 +756,7 @@
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_SWIFT_FLAGS = "-DWIDGET_EXTENSION";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.HoneyDueExtension;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.HoneyDueExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -774,12 +775,12 @@
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||
DEVELOPMENT_TEAM = X86BR9WTLD;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.HoneyDueTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.HoneyDueTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
@@ -800,12 +801,12 @@
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||
DEVELOPMENT_TEAM = X86BR9WTLD;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.HoneyDueTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.HoneyDueTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
@@ -825,7 +826,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||
DEVELOPMENT_TEAM = X86BR9WTLD;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = HoneyDueQLPreview/Info.plist;
|
||||
@@ -838,7 +839,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.dev.HoneyDueQLPreview;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.dev.HoneyDueQLPreview;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -856,7 +857,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||
DEVELOPMENT_TEAM = X86BR9WTLD;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = HoneyDueQLPreview/Info.plist;
|
||||
@@ -869,7 +870,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.HoneyDueQLPreview;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.HoneyDueQLPreview;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -887,7 +888,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||
DEVELOPMENT_TEAM = X86BR9WTLD;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = HoneyDueQLThumbnail/Info.plist;
|
||||
@@ -900,7 +901,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.dev.HoneyDueQLThumbnail;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.dev.HoneyDueQLThumbnail;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -918,7 +919,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||
DEVELOPMENT_TEAM = X86BR9WTLD;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = HoneyDueQLThumbnail/Info.plist;
|
||||
@@ -931,7 +932,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.HoneyDueQLThumbnail;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.HoneyDueQLThumbnail;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -948,13 +949,13 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||
DEVELOPMENT_TEAM = X86BR9WTLD;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.1;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.HoneyDueUITests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.HoneyDueUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -974,13 +975,13 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||
DEVELOPMENT_TEAM = X86BR9WTLD;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.1;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.HoneyDueUITests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.HoneyDueUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -1123,7 +1124,7 @@
|
||||
E767E942685C7832D51FF978 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
APP_GROUP_IDENTIFIER = group.com.tt.honeyDue.dev;
|
||||
APP_GROUP_IDENTIFIER = group.com.myhoneydue.honeyDue.dev;
|
||||
ARCHS = arm64;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
@@ -1131,14 +1132,15 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||
DEVELOPMENT_TEAM = X86BR9WTLD;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = iosApp/Info.plist;
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO;
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs access to your camera to take photos of completed tasks";
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs access to your photo library to select photos of completed tasks";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs camera access to take photos of tasks, documents, and receipts.";
|
||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "honeyDue needs permission to save photos to your library.";
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs photo library access to attach photos to tasks and documents.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
@@ -1148,7 +1150,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.dev;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.dev;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
|
||||
@@ -17,6 +17,16 @@ enum AnalyticsEvent {
|
||||
// MARK: - Task
|
||||
case taskCreated(residenceId: Int32)
|
||||
|
||||
// MARK: - Onboarding (First Task screen)
|
||||
// Event names must stay in lockstep with
|
||||
// composeApp/.../analytics/Analytics.kt so PostHog funnels join cleanly
|
||||
// across iOS and Android.
|
||||
case onboardingSuggestionsLoaded(count: Int, profileCompleteness: Double)
|
||||
case onboardingSuggestionAccepted(templateId: Int32, relevanceScore: Double)
|
||||
case onboardingBrowseTemplateAccepted(templateId: Int32, categoryId: Int32?)
|
||||
case onboardingTasksCreated(count: Int)
|
||||
case onboardingTaskStepSkipped(reason: String)
|
||||
|
||||
// MARK: - Contractor
|
||||
case contractorCreated
|
||||
case contractorShared
|
||||
@@ -64,6 +74,26 @@ enum AnalyticsEvent {
|
||||
case .taskCreated(let residenceId):
|
||||
return ("task_created", ["residence_id": residenceId])
|
||||
|
||||
// Onboarding
|
||||
case .onboardingSuggestionsLoaded(let count, let completeness):
|
||||
return ("onboarding_suggestions_loaded", [
|
||||
"count": count,
|
||||
"profile_completeness": completeness
|
||||
])
|
||||
case .onboardingSuggestionAccepted(let templateId, let relevance):
|
||||
return ("onboarding_suggestion_accepted", [
|
||||
"template_id": templateId,
|
||||
"relevance_score": relevance
|
||||
])
|
||||
case .onboardingBrowseTemplateAccepted(let templateId, let categoryId):
|
||||
var props: [String: Any] = ["template_id": templateId]
|
||||
if let categoryId { props["category_id"] = categoryId }
|
||||
return ("onboarding_browse_template_accepted", props)
|
||||
case .onboardingTasksCreated(let count):
|
||||
return ("onboarding_tasks_created", ["count": count])
|
||||
case .onboardingTaskStepSkipped(let reason):
|
||||
return ("onboarding_task_step_skipped", ["reason": reason])
|
||||
|
||||
// Contractor
|
||||
case .contractorCreated:
|
||||
return ("contractor_created", nil)
|
||||
|
||||
@@ -91,6 +91,47 @@ final class AnalyticsManager {
|
||||
PostHogSDK.shared.capture("screen_viewed", properties: props)
|
||||
}
|
||||
|
||||
// MARK: - Exception / Crash Capture
|
||||
|
||||
/// Capture a Swift Error as a PostHog `$exception` event.
|
||||
func captureException(_ error: Error, properties: [String: Any]? = nil) {
|
||||
guard isConfigured else { return }
|
||||
var props: [String: Any] = [
|
||||
"$exception_type": String(describing: type(of: error)),
|
||||
"$exception_message": error.localizedDescription,
|
||||
"$exception_stack_trace_raw": Thread.callStackSymbols.joined(separator: "\n")
|
||||
]
|
||||
if let properties { props.merge(properties) { _, new in new } }
|
||||
PostHogSDK.shared.capture("$exception", properties: props)
|
||||
}
|
||||
|
||||
/// Capture an NSException as a PostHog `$exception` event.
|
||||
func captureNSException(_ exception: NSException, isFatal: Bool = false) {
|
||||
guard isConfigured else { return }
|
||||
PostHogSDK.shared.capture("$exception", properties: [
|
||||
"$exception_type": exception.name.rawValue,
|
||||
"$exception_message": exception.reason ?? "No reason",
|
||||
"$exception_stack_trace_raw": exception.callStackSymbols.joined(separator: "\n"),
|
||||
"$exception_is_fatal": isFatal
|
||||
])
|
||||
}
|
||||
|
||||
/// Install an uncaught NSException handler that captures crashes to PostHog
|
||||
/// before the app terminates. Call this once after configure().
|
||||
func setupExceptionHandler() {
|
||||
NSSetUncaughtExceptionHandler { exception in
|
||||
// Cannot use AnalyticsManager.shared here (may deadlock on @MainActor),
|
||||
// so call PostHogSDK directly.
|
||||
PostHogSDK.shared.capture("$exception", properties: [
|
||||
"$exception_type": exception.name.rawValue,
|
||||
"$exception_message": exception.reason ?? "No reason",
|
||||
"$exception_stack_trace_raw": exception.callStackSymbols.joined(separator: "\n"),
|
||||
"$exception_is_fatal": true
|
||||
])
|
||||
PostHogSDK.shared.flush()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Flush & Reset
|
||||
|
||||
func flush() {
|
||||
|
||||
@@ -16,7 +16,7 @@ final class BackgroundTaskManager {
|
||||
static let shared = BackgroundTaskManager()
|
||||
|
||||
/// Background task identifier - must match Info.plist BGTaskSchedulerPermittedIdentifiers
|
||||
static let taskIdentifier = "com.tt.honeyDue.refresh"
|
||||
static let taskIdentifier = "com.myhoneydue.honeyDue.refresh"
|
||||
|
||||
/// Time window for overnight refresh (12:00 AM - 4:00 AM)
|
||||
private let refreshWindowStartHour = 0 // 12:00 AM
|
||||
@@ -187,7 +187,7 @@ final class BackgroundTaskManager {
|
||||
|
||||
/// Force a background refresh for testing (only works in debug builds with Xcode)
|
||||
/// Usage: In Xcode debugger console:
|
||||
/// e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.tt.honeyDue.refresh"]
|
||||
/// e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.myhoneydue.honeyDue.refresh"]
|
||||
func debugInfo() -> String {
|
||||
return """
|
||||
Background Task Debug Info:
|
||||
|
||||
@@ -38,6 +38,7 @@ struct AuthenticatedImage: View {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: contentMode)
|
||||
.accessibilityLabel("Image")
|
||||
case .failure:
|
||||
errorView
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ struct TaskSummaryCard: View {
|
||||
.font(.headline)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
|
||||
ForEach(filteredCategories, id: \.name) { category in
|
||||
TaskCategoryRow(category: category)
|
||||
@@ -80,6 +81,7 @@ struct TaskCategoryRow: View {
|
||||
.padding(12)
|
||||
.background(categoryColor.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,11 +76,13 @@ struct ContractorCard: View {
|
||||
.foregroundColor(contractor.isFavorite ? Color.appAccent : Color.appTextSecondary.opacity(0.7))
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.accessibilityLabel(contractor.isFavorite ? "Remove \(contractor.name) from favorites" : "Add \(contractor.name) to favorites")
|
||||
|
||||
// Chevron
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appTextSecondary.opacity(0.7))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
.background(Color.appBackgroundSecondary)
|
||||
|
||||
@@ -64,6 +64,7 @@ struct ContractorDetailView: View {
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.menuButton)
|
||||
}
|
||||
.accessibilityLabel("Contractor actions")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,6 +176,7 @@ struct ContractorDetailView: View {
|
||||
Text(contractor.name)
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
|
||||
// Company
|
||||
if let company = contractor.company {
|
||||
|
||||
@@ -79,6 +79,7 @@ struct ContractorFormSheet: View {
|
||||
}
|
||||
} header: {
|
||||
Text(L10n.Contractors.basicInfoSection)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
} footer: {
|
||||
Text(L10n.Contractors.basicInfoFooter)
|
||||
.font(.caption)
|
||||
@@ -155,6 +156,7 @@ struct ContractorFormSheet: View {
|
||||
}
|
||||
} header: {
|
||||
Text(L10n.Contractors.contactInfoSection)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
}
|
||||
.sectionBackground()
|
||||
|
||||
@@ -226,6 +228,7 @@ struct ContractorFormSheet: View {
|
||||
}
|
||||
} header: {
|
||||
Text(L10n.Contractors.addressSection)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
}
|
||||
.sectionBackground()
|
||||
|
||||
|
||||
@@ -19,9 +19,10 @@ class ContractorViewModel: ObservableObject {
|
||||
|
||||
// MARK: - Private Properties
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
/// Guards against redundant detail reloads immediately after a mutation that already
|
||||
/// set selectedContractor from its response.
|
||||
private var suppressNextDetailReload = false
|
||||
/// Timestamp of the last mutation that already set selectedContractor from its response.
|
||||
/// Used to suppress redundant detail reloads within 1 second of a mutation.
|
||||
/// Unlike a boolean flag, this naturally expires and can never get stuck.
|
||||
private var lastMutationTime: Date?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
@@ -39,8 +40,10 @@ class ContractorViewModel: ObservableObject {
|
||||
if let self = self,
|
||||
let currentId = self.selectedContractor?.id,
|
||||
contractors.contains(where: { $0.id == currentId }) {
|
||||
if self.suppressNextDetailReload {
|
||||
self.suppressNextDetailReload = false
|
||||
// Skip reload if a mutation just updated selectedContractor within the last second
|
||||
if let mutationTime = self.lastMutationTime,
|
||||
Date().timeIntervalSince(mutationTime) < 1.0 {
|
||||
// Mutation was recent, no need to reload
|
||||
} else {
|
||||
self.reloadSelectedContractorQuietly(id: currentId)
|
||||
}
|
||||
@@ -120,7 +123,7 @@ class ContractorViewModel: ObservableObject {
|
||||
self.successMessage = "Contractor added successfully"
|
||||
self.isCreating = false
|
||||
// Update selectedContractor with the newly created contractor
|
||||
self.suppressNextDetailReload = true
|
||||
self.lastMutationTime = Date()
|
||||
self.selectedContractor = success.data
|
||||
completion(true)
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
@@ -152,7 +155,7 @@ class ContractorViewModel: ObservableObject {
|
||||
self.successMessage = "Contractor updated successfully"
|
||||
self.isUpdating = false
|
||||
// Update selectedContractor immediately so detail views stay fresh
|
||||
self.suppressNextDetailReload = true
|
||||
self.lastMutationTime = Date()
|
||||
self.selectedContractor = success.data
|
||||
completion(true)
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
@@ -209,7 +212,7 @@ class ContractorViewModel: ObservableObject {
|
||||
|
||||
if let success = result as? ApiResultSuccess<Contractor> {
|
||||
// Update selectedContractor immediately so detail views stay fresh
|
||||
self.suppressNextDetailReload = true
|
||||
self.lastMutationTime = Date()
|
||||
self.selectedContractor = success.data
|
||||
completion(true)
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
|
||||
@@ -119,6 +119,7 @@ struct ContractorsListView: View {
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(showFavoritesOnly ? Color.appAccent : Color.appTextSecondary)
|
||||
}
|
||||
.accessibilityLabel(showFavoritesOnly ? "Show all contractors" : "Show favorites only")
|
||||
|
||||
// Specialty Filter
|
||||
Menu {
|
||||
@@ -142,6 +143,7 @@ struct ContractorsListView: View {
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(selectedSpecialty != nil ? Color.appPrimary : Color.appTextSecondary)
|
||||
}
|
||||
.accessibilityLabel("Filter by specialty")
|
||||
|
||||
// Add Button
|
||||
Button(action: {
|
||||
@@ -156,6 +158,7 @@ struct ContractorsListView: View {
|
||||
OrganicToolbarButton(systemName: "plus", isPrimary: true)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.addButton)
|
||||
.accessibilityLabel("Add contractor")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -354,10 +357,12 @@ private struct OrganicContractorCard: View {
|
||||
.foregroundColor(contractor.isFavorite ? Color.appAccent : Color.appTextSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(contractor.isFavorite ? "Remove \(contractor.name) from favorites" : "Add \(contractor.name) to favorites")
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundColor(Color.appTextSecondary.opacity(0.5))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
|
||||
@@ -41,7 +41,6 @@ class DataManagerObservable: ObservableObject {
|
||||
// MARK: - Tasks
|
||||
|
||||
@Published var allTasks: TaskColumnsResponse?
|
||||
@Published var tasksByResidence: [Int32: TaskColumnsResponse] = [:]
|
||||
|
||||
// MARK: - Documents
|
||||
|
||||
@@ -191,15 +190,6 @@ class DataManagerObservable: ObservableObject {
|
||||
}
|
||||
observationTasks.append(allTasksTask)
|
||||
|
||||
// TasksByResidence
|
||||
let tasksByResidenceTask = Task { [weak self] in
|
||||
for await tasks in DataManager.shared.tasksByResidence {
|
||||
guard let self else { return }
|
||||
self.tasksByResidence = self.convertIntMap(tasks)
|
||||
}
|
||||
}
|
||||
observationTasks.append(tasksByResidenceTask)
|
||||
|
||||
// Documents
|
||||
let documentsTask = Task { [weak self] in
|
||||
for await docs in DataManager.shared.documents {
|
||||
@@ -389,6 +379,8 @@ class DataManagerObservable: ObservableObject {
|
||||
}
|
||||
|
||||
var result: [Int32: V] = [:]
|
||||
var failedKeys = 0
|
||||
let totalKeys = nsDict.allKeys.count
|
||||
|
||||
for key in nsDict.allKeys {
|
||||
guard let value = nsDict[key], let typedValue = value as? V else { continue }
|
||||
@@ -398,9 +390,16 @@ class DataManagerObservable: ObservableObject {
|
||||
result[kotlinKey.int32Value] = typedValue
|
||||
} else if let nsNumberKey = key as? NSNumber {
|
||||
result[nsNumberKey.int32Value] = typedValue
|
||||
} else {
|
||||
failedKeys += 1
|
||||
print("DataManagerObservable: convertIntMap failed to convert key of type \(type(of: key)): \(key)")
|
||||
}
|
||||
}
|
||||
|
||||
if failedKeys > 0 {
|
||||
print("DataManagerObservable: Warning: \(failedKeys) of \(totalKeys) keys failed to convert in convertIntMap")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -416,6 +415,8 @@ class DataManagerObservable: ObservableObject {
|
||||
}
|
||||
|
||||
var result: [Int32: [V]] = [:]
|
||||
var failedKeys = 0
|
||||
let totalKeys = nsDict.allKeys.count
|
||||
|
||||
for key in nsDict.allKeys {
|
||||
guard let value = nsDict[key] else { continue }
|
||||
@@ -435,9 +436,16 @@ class DataManagerObservable: ObservableObject {
|
||||
result[kotlinKey.int32Value] = typedValue
|
||||
} else if let nsNumberKey = key as? NSNumber {
|
||||
result[nsNumberKey.int32Value] = typedValue
|
||||
} else {
|
||||
failedKeys += 1
|
||||
print("DataManagerObservable: convertIntArrayMap failed to convert key of type \(type(of: key)): \(key)")
|
||||
}
|
||||
}
|
||||
|
||||
if failedKeys > 0 {
|
||||
print("DataManagerObservable: Warning: \(failedKeys) of \(totalKeys) keys failed to convert in convertIntArrayMap")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -501,9 +509,27 @@ class DataManagerObservable: ObservableObject {
|
||||
|
||||
// MARK: - Task Helpers
|
||||
|
||||
/// Get tasks for a specific residence
|
||||
/// Get tasks for a specific residence — derived from `_allTasks`
|
||||
/// (single source of truth) by filtering in-memory.
|
||||
func tasks(for residenceId: Int32) -> TaskColumnsResponse? {
|
||||
return tasksByResidence[residenceId]
|
||||
guard let all = allTasks else { return nil }
|
||||
let filteredColumns = all.columns.map { column -> TaskColumn in
|
||||
let filtered = column.tasks.filter { Int32($0.residenceId) == residenceId }
|
||||
return TaskColumn(
|
||||
name: column.name,
|
||||
displayName: column.displayName,
|
||||
buttonTypes: column.buttonTypes,
|
||||
icons: column.icons,
|
||||
color: column.color,
|
||||
tasks: filtered,
|
||||
count: Int32(filtered.count)
|
||||
)
|
||||
}
|
||||
return TaskColumnsResponse(
|
||||
columns: filteredColumns,
|
||||
daysThreshold: all.daysThreshold,
|
||||
residenceId: String(residenceId)
|
||||
)
|
||||
}
|
||||
|
||||
/// Get documents for a specific residence
|
||||
|
||||
@@ -37,6 +37,7 @@ struct DocumentCard: View {
|
||||
.frame(width: 56, height: 56)
|
||||
})
|
||||
.padding(AppSpacing.md)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
@@ -77,11 +78,13 @@ struct DocumentCard: View {
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.font(.system(size: 14))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
.background(Color.appBackgroundSecondary)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private func getDocTypeDisplayName(_ type: String) -> String {
|
||||
|
||||
@@ -115,6 +115,7 @@ struct WarrantyCard: View {
|
||||
.background(Color.appBackgroundSecondary)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private func getCategoryDisplayName(_ category: String) -> String {
|
||||
|
||||
@@ -68,6 +68,7 @@ struct DocumentDetailView: View {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Document.menuButton)
|
||||
}
|
||||
.accessibilityLabel("Document actions")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -483,6 +484,7 @@ struct DocumentDetailView: View {
|
||||
Text(title)
|
||||
.font(.system(size: 16, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@@ -163,6 +163,7 @@ struct DocumentFormView: View {
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Document.providerContactField)
|
||||
} header: {
|
||||
Text(L10n.Documents.warrantyDetails)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
} footer: {
|
||||
Text(L10n.Documents.requiredWarrantyFields)
|
||||
.font(.caption)
|
||||
@@ -363,6 +364,7 @@ struct DocumentFormView: View {
|
||||
.keyboardDismissToolbar()
|
||||
} header: {
|
||||
Text(L10n.Documents.basicInformation)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
} footer: {
|
||||
Text(L10n.Documents.requiredTitle)
|
||||
.font(.caption)
|
||||
|
||||
@@ -130,14 +130,23 @@ class DocumentViewModel: ObservableObject {
|
||||
|
||||
if let success = result as? ApiResultSuccess<Document> {
|
||||
if !images.isEmpty {
|
||||
guard let documentId = success.data?.id?.int32Value else {
|
||||
self.errorMessage = "Document created, but image upload could not start"
|
||||
guard let document = success.data,
|
||||
let documentId = document.id?.int32Value,
|
||||
document.title != nil else {
|
||||
let missingFields = [
|
||||
success.data == nil ? "data" : nil,
|
||||
success.data?.id == nil ? "id" : nil,
|
||||
success.data?.title == nil ? "title" : nil
|
||||
].compactMap { $0 }.joined(separator: ", ")
|
||||
print("DocumentViewModel: Document creation returned incomplete data (missing: \(missingFields)), skipping image upload")
|
||||
self.errorMessage = "Document created with incomplete data — images were not uploaded"
|
||||
self.isLoading = false
|
||||
completion(false, self.errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if let uploadError = await self.uploadImages(documentId: documentId, images: images) {
|
||||
print("DocumentViewModel: Image upload failed for document \(documentId): \(uploadError)")
|
||||
self.errorMessage = uploadError
|
||||
self.isLoading = false
|
||||
completion(false, self.errorMessage)
|
||||
|
||||
@@ -118,6 +118,7 @@ struct DocumentsWarrantiesView: View {
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(showActiveOnly ? Color.appPrimary : Color.appTextSecondary)
|
||||
}
|
||||
.accessibilityLabel(showActiveOnly ? "Show all warranties" : "Show active warranties only")
|
||||
}
|
||||
|
||||
// Filter Menu
|
||||
@@ -160,6 +161,7 @@ struct DocumentsWarrantiesView: View {
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor((selectedCategory != nil || selectedDocType != nil) ? Color.appPrimary : Color.appTextSecondary)
|
||||
}
|
||||
.accessibilityLabel("Filter documents")
|
||||
|
||||
// Add Button
|
||||
Button(action: {
|
||||
@@ -174,6 +176,7 @@ struct DocumentsWarrantiesView: View {
|
||||
OrganicDocToolbarButton()
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Document.addButton)
|
||||
.accessibilityLabel("Add document")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,6 +290,8 @@ private struct OrganicSegmentButton: View {
|
||||
.background(isSelected ? Color.appPrimary : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
.accessibilityLabel(title)
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,11 +22,13 @@ struct AccessibilityIdentifiers {
|
||||
static let registerConfirmPasswordField = "Register.ConfirmPasswordField"
|
||||
static let registerButton = "Register.RegisterButton"
|
||||
static let registerCancelButton = "Register.CancelButton"
|
||||
static let registerErrorMessage = "Register.ErrorMessage"
|
||||
|
||||
// Verification
|
||||
static let verificationCodeField = "Verification.CodeField"
|
||||
static let verifyButton = "Verification.VerifyButton"
|
||||
static let resendCodeButton = "Verification.ResendButton"
|
||||
static let verificationLogoutButton = "Verification.LogoutButton"
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
@@ -46,6 +48,9 @@ struct AccessibilityIdentifiers {
|
||||
static let addButton = "Residence.AddButton"
|
||||
static let residencesList = "Residence.List"
|
||||
static let residenceCard = "Residence.Card"
|
||||
/// Prefix for individual residence cells in the list. Suffix with the
|
||||
/// residence id to address a specific cell (e.g. "Residence.Cell.42").
|
||||
static let cellPrefix = "Residence.Cell"
|
||||
static let emptyStateView = "Residence.EmptyState"
|
||||
static let emptyStateButton = "Residence.EmptyState.AddButton"
|
||||
|
||||
@@ -85,7 +90,15 @@ struct AccessibilityIdentifiers {
|
||||
static let refreshButton = "Task.RefreshButton"
|
||||
static let tasksList = "Task.List"
|
||||
static let taskCard = "Task.Card"
|
||||
/// Prefix for individual task rows. Suffix with the task id to
|
||||
/// address a specific row (e.g. "Task.Row.42"). Use `BEGINSWITH`
|
||||
/// in tests to detect "any task row exists".
|
||||
static let rowPrefix = "Task.Row"
|
||||
static let emptyStateView = "Task.EmptyState"
|
||||
/// Label rendered when a residence-detail tasks section has no tasks
|
||||
/// in any kanban column. Asserted ABSENT after onboarding bulk-create
|
||||
/// in the gitea#2 regression test.
|
||||
static let noTasksLabel = "Task.NoTasksLabel"
|
||||
static let kanbanView = "Task.KanbanView"
|
||||
static let overdueColumn = "Task.Column.Overdue"
|
||||
static let upcomingColumn = "Task.Column.Upcoming"
|
||||
@@ -227,8 +240,24 @@ struct AccessibilityIdentifiers {
|
||||
static let taskSelectionCounter = "Onboarding.TaskSelectionCounter"
|
||||
static let addPopularTasksButton = "Onboarding.AddPopularTasksButton"
|
||||
static let addTasksContinueButton = "Onboarding.AddTasksContinueButton"
|
||||
/// Submit/continue button at the bottom of the First-Task screen.
|
||||
/// Triggers `POST /api/tasks/bulk/` for the selected templates.
|
||||
static let submitTasksButton = "Onboarding.SubmitTasksButton"
|
||||
/// Tab bar control above the task list. The "Browse All" segment is
|
||||
/// addressed via `app.buttons["Browse All"]` from the segmented
|
||||
/// picker once this identifier is set.
|
||||
static let firstTaskTabBar = "Onboarding.FirstTaskTabBar"
|
||||
/// Tab segment that shows the full template catalog.
|
||||
/// Tap from a test by addressing the Picker's segment label
|
||||
/// "Browse All" within the element identified above.
|
||||
static let browseAllTab = "Onboarding.BrowseAllTab"
|
||||
static let taskCategorySection = "Onboarding.TaskCategorySection"
|
||||
static let taskTemplateRow = "Onboarding.TaskTemplateRow"
|
||||
/// Prefix for individual template rows on the First-Task screen
|
||||
/// (Browse All tab). Suffix with the backend template id —
|
||||
/// e.g. `"Onboarding.TemplateRow.123"`. Tests use `BEGINSWITH` to
|
||||
/// pick the first N rows deterministically without knowing ids.
|
||||
static let templateRowPrefix = "Onboarding.TemplateRow"
|
||||
|
||||
// Subscription Screen
|
||||
static let subscriptionTitle = "Onboarding.SubscriptionTitle"
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
|
||||
/// Centralized accessibility labels for VoiceOver
|
||||
/// These labels provide human-readable descriptions for screen reader users
|
||||
struct A11y {
|
||||
|
||||
// MARK: - Authentication
|
||||
struct Auth {
|
||||
static let loginButton = "Sign in"
|
||||
static let appleSignIn = "Sign in with Apple"
|
||||
static let googleSignIn = "Sign in with Google"
|
||||
static let forgotPassword = "Forgot password"
|
||||
static let signUp = "Create account"
|
||||
static let passwordToggle = "Toggle password visibility"
|
||||
static let appLogo = "honeyDue app logo"
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
struct Navigation {
|
||||
static let residencesTab = "Properties"
|
||||
static let tasksTab = "Tasks"
|
||||
static let contractorsTab = "Contractors"
|
||||
static let documentsTab = "Documents"
|
||||
static let settingsButton = "Settings"
|
||||
static let addButton = "Add"
|
||||
static let backButton = "Back"
|
||||
static let closeButton = "Close"
|
||||
static let editButton = "Edit"
|
||||
static let deleteButton = "Delete"
|
||||
static let saveButton = "Save"
|
||||
static let cancelButton = "Cancel"
|
||||
}
|
||||
|
||||
// MARK: - Residence
|
||||
struct Residence {
|
||||
static func card(name: String, taskCount: Int, overdueCount: Int) -> String {
|
||||
"\(name), \(taskCount) tasks, \(overdueCount) overdue"
|
||||
}
|
||||
static let addProperty = "Add new property"
|
||||
static let primaryBadge = "Primary property"
|
||||
static func openInMaps(address: String) -> String { "Open \(address) in Maps" }
|
||||
static func shareCode(code: String) -> String { "Share code: \(code)" }
|
||||
static let copyShareCode = "Copy share code"
|
||||
static let generateShareCode = "Generate new share code"
|
||||
static func removeUser(name: String) -> String { "Remove \(name) from property" }
|
||||
}
|
||||
|
||||
// MARK: - Task
|
||||
struct Task {
|
||||
static func card(title: String, priority: String, dueDate: String) -> String {
|
||||
"\(title), \(priority) priority, due \(dueDate)"
|
||||
}
|
||||
static let addTask = "Add new task"
|
||||
static let taskActions = "Task actions"
|
||||
static func priorityBadge(level: String) -> String { "Priority: \(level)" }
|
||||
static func statusBadge(status: String) -> String { "Status: \(status)" }
|
||||
static func completionCount(count: Int) -> String { "View \(count) completions" }
|
||||
static func rating(value: Int) -> String { "Rated \(value) out of 5" }
|
||||
static let markInProgress = "Mark as in progress"
|
||||
static let completeTask = "Complete task"
|
||||
static let archiveTask = "Archive task"
|
||||
static let cancelTask = "Cancel task"
|
||||
}
|
||||
|
||||
// MARK: - Contractor
|
||||
struct Contractor {
|
||||
static func card(name: String, company: String?, specialty: String) -> String {
|
||||
[name, company, specialty].compactMap { $0 }.joined(separator: ", ")
|
||||
}
|
||||
static let addContractor = "Add new contractor"
|
||||
static func toggleFavorite(name: String, isFavorite: Bool) -> String {
|
||||
isFavorite ? "Remove \(name) from favorites" : "Add \(name) to favorites"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Document
|
||||
struct Document {
|
||||
static func card(title: String, type: String) -> String { "\(title), \(type)" }
|
||||
static let addDocument = "Add new document"
|
||||
}
|
||||
|
||||
// MARK: - Common
|
||||
struct Common {
|
||||
static func stat(value: String, label: String) -> String { "\(value) \(label)" }
|
||||
static let decorative = "" // For .accessibilityHidden(true)
|
||||
static let retryButton = "Try again"
|
||||
static let dismissError = "Dismiss error"
|
||||
static func photo(index: Int) -> String { "Photo \(index)" }
|
||||
static let removePhoto = "Remove photo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// Memory-efficient image resizer for upload preprocessing.
|
||||
///
|
||||
/// Why not `UIImage.jpegData(compressionQuality:)` directly? UIImage decodes
|
||||
/// the entire source bitmap into RAM before re-encoding — a 12 MP iPhone
|
||||
/// photo decompresses to ~50 MB regardless of how big the JPEG is. With
|
||||
/// multiple selected images this can blow up memory on older devices.
|
||||
///
|
||||
/// `CGImageSourceCreateThumbnailAtIndex` reads the source incrementally and
|
||||
/// only allocates the *resized* bitmap, paying memory proportional to the
|
||||
/// output size (a 2048×1536 thumbnail is ~12 MB, but the source is never
|
||||
/// fully decoded).
|
||||
///
|
||||
/// Reference: https://nshipster.com/image-resizing/ — section "Image I/O".
|
||||
enum ImageDownsampler {
|
||||
|
||||
/// Settings tuned per upload category. Edit here, not at call sites.
|
||||
struct Profile {
|
||||
/// Largest dimension (in points-after-scale, i.e. pixels) of the
|
||||
/// downsampled image. The shorter edge is set proportionally.
|
||||
let maxPixelEdge: CGFloat
|
||||
|
||||
/// JPEG quality, 0...1. 0.85 is the WhatsApp / Slack default —
|
||||
/// visually indistinguishable from quality 1.0 at typical viewing
|
||||
/// sizes; cuts file size by ~3x.
|
||||
let jpegQuality: CGFloat
|
||||
|
||||
static let completion = Profile(maxPixelEdge: 2048, jpegQuality: 0.85)
|
||||
static let documentImage = Profile(maxPixelEdge: 2560, jpegQuality: 0.90)
|
||||
}
|
||||
|
||||
/// Downsample raw image bytes (e.g. from a `PHPickerResult`'s
|
||||
/// `loadDataRepresentation`) into a JPEG `Data` ready for upload.
|
||||
///
|
||||
/// - Returns: encoded JPEG bytes, or nil if decoding failed.
|
||||
static func downsample(data: Data, profile: Profile) -> Data? {
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageSourceShouldCache: false, // don't keep the full image around
|
||||
kCGImageSourceTypeIdentifierHint: UTType.jpeg.identifier as CFString, // best-effort hint
|
||||
]
|
||||
guard let source = CGImageSourceCreateWithData(data as CFData, options as CFDictionary) else {
|
||||
return nil
|
||||
}
|
||||
return downsample(source: source, profile: profile)
|
||||
}
|
||||
|
||||
/// Downsample from a file URL (e.g. PhotosPicker's
|
||||
/// `loadFileRepresentation`). Avoids materializing the full image in
|
||||
/// memory before resize.
|
||||
static func downsample(url: URL, profile: Profile) -> Data? {
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageSourceShouldCache: false,
|
||||
]
|
||||
guard let source = CGImageSourceCreateWithURL(url as CFURL, options as CFDictionary) else {
|
||||
return nil
|
||||
}
|
||||
return downsample(source: source, profile: profile)
|
||||
}
|
||||
|
||||
/// Convenience for callers that already have a `UIImage` (e.g. from
|
||||
/// `UIImagePickerController`). We round-trip through PNG to get raw
|
||||
/// data, then use the data path. Slightly less efficient than starting
|
||||
/// from URL/Data, but still avoids the JPEG re-encode penalty for the
|
||||
/// resize step itself.
|
||||
static func downsample(uiImage: UIImage, profile: Profile) -> Data? {
|
||||
// Use PNG for the intermediate to avoid double-JPEG quality loss.
|
||||
// Even though PNG is larger, this stays in memory only briefly.
|
||||
guard let intermediate = uiImage.pngData() else { return nil }
|
||||
return downsample(data: intermediate, profile: profile)
|
||||
}
|
||||
|
||||
// MARK: - Internal
|
||||
|
||||
private static func downsample(source: CGImageSource, profile: Profile) -> Data? {
|
||||
// Compute the max pixel size in screen-resolution-aware units. We
|
||||
// use a fixed pixel cap because uploads are about bytes, not display.
|
||||
let scale: CGFloat = 1.0
|
||||
let maxDimensionInPixels = profile.maxPixelEdge * scale
|
||||
|
||||
let downsampleOptions: [CFString: Any] = [
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceShouldCacheImmediately: true, // decode on the calling thread
|
||||
kCGImageSourceCreateThumbnailWithTransform: true, // honor EXIF orientation
|
||||
kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels,
|
||||
]
|
||||
|
||||
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(
|
||||
source, 0, downsampleOptions as CFDictionary
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let uiImage = UIImage(cgImage: cgImage)
|
||||
return uiImage.jpegData(compressionQuality: profile.jpegQuality)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import Foundation
|
||||
import ComposeApp
|
||||
|
||||
/// Three-step direct-to-B2 image upload.
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. POST /api/uploads/presign → server returns a B2 POST policy + form
|
||||
/// fields scoped to a single object key with a content-length-range
|
||||
/// condition that B2 enforces at the protocol level.
|
||||
/// 2. Multipart POST the bytes directly to B2, no API server in the data
|
||||
/// path. B2 rejects the upload if the bytes don't match the policy.
|
||||
/// 3. Caller passes the returned `uploadId` to /api/task-completions/ or
|
||||
/// /api/documents/ via `upload_ids[]`. The server HEADs the object,
|
||||
/// confirms the size, and creates the linked entity rows.
|
||||
///
|
||||
/// All errors map to `PresignedUploaderError` — the Swift call site can
|
||||
/// translate to user-facing copy without parsing nested HTTP details.
|
||||
enum PresignedUploaderError: Error, LocalizedError {
|
||||
case notAuthenticated
|
||||
case presignFailed(status: Int, body: String)
|
||||
case uploadFailed(status: Int, body: String)
|
||||
case sessionError(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notAuthenticated:
|
||||
return "You're not signed in."
|
||||
case .presignFailed(let status, _):
|
||||
switch status {
|
||||
case 413: return "That photo is too large after resizing. Try a different one."
|
||||
case 422: return "That image format isn't supported."
|
||||
case 429: return "You're uploading too many photos. Try again in a few minutes."
|
||||
default: return "Couldn't start upload (server returned \(status))."
|
||||
}
|
||||
case .uploadFailed(let status, _):
|
||||
return "Upload failed (B2 returned \(status))."
|
||||
case .sessionError(let err):
|
||||
return err.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Category passed to the presign endpoint. Matches the Go server's
|
||||
/// `UploadCategory` constants in `internal/models/pending_upload.go`.
|
||||
enum UploadCategory: String {
|
||||
case completion = "completion"
|
||||
case documentImage = "document_image"
|
||||
case documentFile = "document_file"
|
||||
}
|
||||
|
||||
/// Presigned-URL upload helper. Stateless — instantiate freely.
|
||||
///
|
||||
/// Concurrency: each `upload(...)` call runs to completion sequentially.
|
||||
/// For multiple images the caller can run several uploads in parallel via
|
||||
/// `withTaskGroup`; the server's per-user concurrency cap (10 in-flight
|
||||
/// presigns) is enforced server-side.
|
||||
final class PresignedUploader {
|
||||
|
||||
/// API base URL — read from KMP's ApiConfig so iOS and Android stay
|
||||
/// in sync (LOCAL vs DEV vs PROD without divergent constants).
|
||||
private let apiBaseURL: String
|
||||
|
||||
/// Bearer token. Read once at init; if the user re-auths mid-session,
|
||||
/// the caller should construct a fresh PresignedUploader.
|
||||
private let authToken: String
|
||||
|
||||
private let session: URLSession
|
||||
|
||||
init?(session: URLSession = .shared) {
|
||||
// ApiConfig.shared.getBaseUrl() resolves Environment (LOCAL/DEV/PROD).
|
||||
// DataManager.shared.authToken is a StateFlow<String?> — read the
|
||||
// current value via .value (SKIE-exposed property).
|
||||
let baseUrl = ApiConfig.shared.getBaseUrl()
|
||||
guard let token = DataManager.shared.authToken.value as String? else {
|
||||
return nil
|
||||
}
|
||||
self.apiBaseURL = baseUrl
|
||||
self.authToken = token
|
||||
self.session = session
|
||||
}
|
||||
|
||||
/// Upload `data` to B2 in the named category. Returns the
|
||||
/// pending_uploads.id the caller passes via `upload_ids[]` to attach
|
||||
/// the object to a real entity.
|
||||
func upload(
|
||||
data: Data,
|
||||
category: UploadCategory,
|
||||
contentType: String = "image/jpeg",
|
||||
fileName: String = "image.jpg"
|
||||
) async throws -> Int32 {
|
||||
// Step 1: presign
|
||||
let presigned = try await requestPresign(
|
||||
category: category,
|
||||
contentType: contentType,
|
||||
contentLength: Int64(data.count)
|
||||
)
|
||||
|
||||
// Step 2: direct POST to B2
|
||||
try await postToStorage(
|
||||
uploadURL: presigned.uploadUrl,
|
||||
fields: presigned.fields,
|
||||
data: data,
|
||||
contentType: contentType,
|
||||
fileName: fileName
|
||||
)
|
||||
|
||||
return Int32(presigned.id)
|
||||
}
|
||||
|
||||
/// Upload several images in parallel, returning their upload_ids in
|
||||
/// input order. Stops at the first failure and surfaces it.
|
||||
func uploadAll(
|
||||
items: [(Data, String)],
|
||||
category: UploadCategory,
|
||||
contentType: String = "image/jpeg"
|
||||
) async throws -> [Int32] {
|
||||
try await withThrowingTaskGroup(of: (Int, Int32).self) { group in
|
||||
for (idx, item) in items.enumerated() {
|
||||
let (data, name) = item
|
||||
group.addTask { [self] in
|
||||
let id = try await upload(
|
||||
data: data,
|
||||
category: category,
|
||||
contentType: contentType,
|
||||
fileName: name
|
||||
)
|
||||
return (idx, id)
|
||||
}
|
||||
}
|
||||
var pairs: [(Int, Int32)] = []
|
||||
for try await pair in group {
|
||||
pairs.append(pair)
|
||||
}
|
||||
return pairs.sorted { $0.0 < $1.0 }.map { $0.1 }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Step 1: presign
|
||||
|
||||
private struct PresignBody: Encodable {
|
||||
let category: String
|
||||
let content_type: String
|
||||
let content_length: Int64
|
||||
}
|
||||
|
||||
private struct PresignResponse: Decodable {
|
||||
let id: Int
|
||||
let upload_url: String
|
||||
let fields: [String: String]
|
||||
let key: String
|
||||
let expires_at: String
|
||||
|
||||
// Map snake_case to nicer Swift names at the call site.
|
||||
var uploadUrl: String { upload_url }
|
||||
}
|
||||
|
||||
private func requestPresign(
|
||||
category: UploadCategory,
|
||||
contentType: String,
|
||||
contentLength: Int64
|
||||
) async throws -> PresignResponse {
|
||||
guard var url = URL(string: apiBaseURL) else {
|
||||
throw PresignedUploaderError.presignFailed(status: 0, body: "invalid base url")
|
||||
}
|
||||
url.appendPathComponent("uploads/presign/")
|
||||
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue("Token \(authToken)", forHTTPHeaderField: "Authorization")
|
||||
req.httpBody = try JSONEncoder().encode(PresignBody(
|
||||
category: category.rawValue,
|
||||
content_type: contentType,
|
||||
content_length: contentLength
|
||||
))
|
||||
|
||||
let (body, response): (Data, URLResponse)
|
||||
do {
|
||||
(body, response) = try await session.data(for: req)
|
||||
} catch {
|
||||
throw PresignedUploaderError.sessionError(error)
|
||||
}
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw PresignedUploaderError.presignFailed(status: 0, body: "no response")
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw PresignedUploaderError.presignFailed(
|
||||
status: http.statusCode,
|
||||
body: String(data: body, encoding: .utf8) ?? ""
|
||||
)
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(PresignResponse.self, from: body)
|
||||
} catch {
|
||||
throw PresignedUploaderError.presignFailed(status: http.statusCode, body: "decode failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Step 2: POST to B2
|
||||
|
||||
private func postToStorage(
|
||||
uploadURL: String,
|
||||
fields: [String: String],
|
||||
data: Data,
|
||||
contentType: String,
|
||||
fileName: String
|
||||
) async throws {
|
||||
guard let url = URL(string: uploadURL) else {
|
||||
throw PresignedUploaderError.uploadFailed(status: 0, body: "invalid upload url")
|
||||
}
|
||||
|
||||
// Build a multipart/form-data body with all policy fields followed
|
||||
// by a single "file" part (S3 POST policy mandates the file part
|
||||
// come last).
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
var body = Data()
|
||||
let crlf = "\r\n"
|
||||
let appendString: (String) -> Void = { s in
|
||||
body.append(s.data(using: .utf8) ?? Data())
|
||||
}
|
||||
|
||||
// Stable order: ensure "key" and "Content-Type" appear before the
|
||||
// file part so the policy signature validates. Unspecified order
|
||||
// for the rest — S3 accepts any.
|
||||
let orderedKeys = ["key", "Content-Type", "policy", "x-amz-algorithm",
|
||||
"x-amz-credential", "x-amz-date", "x-amz-signature",
|
||||
"x-amz-meta-uid"]
|
||||
var emitted = Set<String>()
|
||||
for k in orderedKeys {
|
||||
if let v = fields[k] {
|
||||
appendString("--\(boundary)\(crlf)")
|
||||
appendString("Content-Disposition: form-data; name=\"\(k)\"\(crlf)\(crlf)")
|
||||
appendString(v)
|
||||
appendString(crlf)
|
||||
emitted.insert(k)
|
||||
}
|
||||
}
|
||||
for (k, v) in fields where !emitted.contains(k) {
|
||||
appendString("--\(boundary)\(crlf)")
|
||||
appendString("Content-Disposition: form-data; name=\"\(k)\"\(crlf)\(crlf)")
|
||||
appendString(v)
|
||||
appendString(crlf)
|
||||
}
|
||||
|
||||
// file part — must be last
|
||||
appendString("--\(boundary)\(crlf)")
|
||||
appendString("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\(crlf)")
|
||||
appendString("Content-Type: \(contentType)\(crlf)\(crlf)")
|
||||
body.append(data)
|
||||
appendString(crlf)
|
||||
appendString("--\(boundary)--\(crlf)")
|
||||
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = body
|
||||
|
||||
let (respBody, response): (Data, URLResponse)
|
||||
do {
|
||||
(respBody, response) = try await session.data(for: req)
|
||||
} catch {
|
||||
throw PresignedUploaderError.sessionError(error)
|
||||
}
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw PresignedUploaderError.uploadFailed(status: 0, body: "no response")
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw PresignedUploaderError.uploadFailed(
|
||||
status: http.statusCode,
|
||||
body: String(data: respBody, encoding: .utf8) ?? ""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ enum ThemeID: String, CaseIterable, Codable {
|
||||
|
||||
// MARK: - Shared App Group UserDefaults
|
||||
private let appGroupID: String = {
|
||||
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.tt.honeyDue.dev"
|
||||
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev"
|
||||
}()
|
||||
private let sharedDefaults: UserDefaults = {
|
||||
guard let defaults = UserDefaults(suiteName: appGroupID) else {
|
||||
|
||||
@@ -56,11 +56,13 @@ enum UITestRuntime {
|
||||
DataManager.shared.clear()
|
||||
OnboardingState.shared.reset()
|
||||
ThemeManager.shared.currentTheme = .bright
|
||||
UserDefaults.standard.removeObject(forKey: "ui_test_user_verified")
|
||||
|
||||
// Re-apply onboarding completion after reset so tests that need
|
||||
// both --reset-state and --complete-onboarding work correctly.
|
||||
// Re-apply onboarding completion after reset. Set the flag directly
|
||||
// because completeOnboarding() has an auth guard that fails here
|
||||
// (DataManager was just cleared, so isAuthenticated is false).
|
||||
if shouldCompleteOnboarding {
|
||||
OnboardingState.shared.completeOnboarding()
|
||||
OnboardingState.shared.hasCompletedOnboarding = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ final class WidgetActionProcessor {
|
||||
notes: "Completed from widget",
|
||||
actualCost: nil,
|
||||
rating: nil,
|
||||
imageUrls: nil
|
||||
uploadIds: nil
|
||||
)
|
||||
|
||||
let result = try await APILayer.shared.createTaskCompletion(request: request)
|
||||
|
||||
@@ -21,7 +21,7 @@ final class WidgetDataManager {
|
||||
static let cancelledColumn = "cancelled_tasks"
|
||||
|
||||
private let appGroupIdentifier: String = {
|
||||
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.tt.honeyDue.dev"
|
||||
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev"
|
||||
}()
|
||||
private let tasksFileName = "widget_tasks.json"
|
||||
private let actionsFileName = "widget_pending_actions.json"
|
||||
|
||||
@@ -6,14 +6,8 @@
|
||||
<string>$(APP_GROUP_IDENTIFIER)</string>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>com.tt.honeyDue.refresh</string>
|
||||
<string>com.myhoneydue.honeyDue.refresh</string>
|
||||
</array>
|
||||
<key>HONEYDUE_IAP_ANNUAL_PRODUCT_ID</key>
|
||||
<string>com.tt.honeyDue.pro.annual</string>
|
||||
<key>HONEYDUE_IAP_MONTHLY_PRODUCT_ID</key>
|
||||
<string>com.tt.honeyDue.pro.monthly</string>
|
||||
<key>HONEYDUE_GOOGLE_WEB_CLIENT_ID</key>
|
||||
<string></string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -40,17 +34,17 @@
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>HONEYDUE_GOOGLE_WEB_CLIENT_ID</key>
|
||||
<string></string>
|
||||
<key>HONEYDUE_IAP_ANNUAL_PRODUCT_ID</key>
|
||||
<string>com.myhoneydue.honeyDue.pro.annual</string>
|
||||
<key>HONEYDUE_IAP_MONTHLY_PRODUCT_ID</key>
|
||||
<string>com.myhoneydue.honeyDue.pro.monthly</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>honeyDue needs camera access to take photos of tasks, documents, and receipts.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>honeyDue needs photo library access to attach photos to tasks and documents.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>honeyDue needs permission to save photos to your library.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"strings" : {
|
||||
"" : {
|
||||
|
||||
},
|
||||
" %@" : {
|
||||
"comment" : "A chevron up and down symbol. The argument is the “chevron.up.chevron.down” symbol.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"*" : {
|
||||
|
||||
@@ -46,6 +50,54 @@
|
||||
"comment" : "A message displayed when a contractor is successfully imported to the user's contacts. The placeholder is replaced with the name of the imported contractor.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"%@ plan, %@%@%@" : {
|
||||
"comment" : "A label describing a subscription plan. The first argument is the plan title. The second argument is the price of the plan. The third argument is the billing period. The fourth argument is the savings information, if available.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$@ plan, %2$@%3$@%4$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@, %@" : {
|
||||
"comment" : "Accessibility label and value that describe the task and its selection state.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$@, %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@, %@, %lld%% match" : {
|
||||
"comment" : "A row that displays a suggestion with a title, frequency, and relevance percentage.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$@, %2$@, %3$lld%% match"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@, %@%@" : {
|
||||
"comment" : "A button that displays the name of a product and its price.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$@, %2$@%3$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@: %@" : {
|
||||
"comment" : "An error message displayed when there was an issue loading tasks for a residence.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
@@ -58,6 +110,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@: Free: %@, Pro: %@" : {
|
||||
"comment" : "A label that describes a comparison between a free and a pro feature.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$@: Free: %2$@, Pro: %3$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%d" : {
|
||||
"comment" : "A badge displaying the number of tasks in a category. The argument is the count of tasks in the category.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -92,16 +156,20 @@
|
||||
"%lld common tasks" : {
|
||||
|
||||
},
|
||||
"%lld/%lld tasks selected" : {
|
||||
"%lld task%@ selected" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$lld/%2$lld tasks selected"
|
||||
"value" : "%1$lld task%2$@ selected"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld%%" : {
|
||||
"comment" : "A badge that shows the relevance of a suggestion. The argument is the relevance percentage.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"•" : {
|
||||
"comment" : "A separator between different pieces of information in a text.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -149,6 +217,10 @@
|
||||
"comment" : "A label for the actions menu in the task card.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Add %@ to favorites" : {
|
||||
"comment" : "A label for the favorite button. The argument is the name of the contractor.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Add %lld Task%@ & Continue" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@@ -159,8 +231,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Add Most Popular" : {
|
||||
"Add contractor" : {
|
||||
"comment" : "A label for the button that adds a new contractor.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Add document" : {
|
||||
|
||||
},
|
||||
"Add new property" : {
|
||||
"comment" : "A label displayed as a button in the toolbar.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Add new task" : {
|
||||
"comment" : "A label for a button that adds a new task.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Add task" : {
|
||||
"comment" : "A button that adds a task.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Add your first property to get started!" : {
|
||||
"comment" : "A description below the image in the \"No properties yet\" view, encouraging the user to add their first property.",
|
||||
@@ -169,6 +257,9 @@
|
||||
"Adds a subtle hexagonal grid overlay" : {
|
||||
"comment" : "A description of the Honeycomb Pattern feature.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"All optional -- helps us personalize your plan" : {
|
||||
|
||||
},
|
||||
"Already have an account?" : {
|
||||
|
||||
@@ -315,6 +406,10 @@
|
||||
"comment" : "The text of a button that archives a task.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Archive task" : {
|
||||
"comment" : "A label displayed as a button that archives a task.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Archive Task" : {
|
||||
"comment" : "A button that archives a task. The text \"Archive Task\" is a placeholder and should be replaced with the actual translation.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -333,6 +428,10 @@
|
||||
},
|
||||
"Are you sure you want to remove %@ from this residence?" : {
|
||||
|
||||
},
|
||||
"Attached photo" : {
|
||||
"comment" : "A label for an attached photo.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"auth_account_info" : {
|
||||
"extractionState" : "manual",
|
||||
@@ -4270,6 +4369,10 @@
|
||||
},
|
||||
"Back" : {
|
||||
|
||||
},
|
||||
"Back to all photos" : {
|
||||
"comment" : "A button that dismisses a sheet and returns to the previous screen.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Back to Login" : {
|
||||
"comment" : "A button label that takes the user back to the login screen.",
|
||||
@@ -4288,6 +4391,10 @@
|
||||
},
|
||||
"Cancel anytime in Settings • No commitment" : {
|
||||
|
||||
},
|
||||
"Cancel task" : {
|
||||
"comment" : "A button that cancels a task.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Cancel Task" : {
|
||||
|
||||
@@ -5330,6 +5437,10 @@
|
||||
"comment" : "A button label that indicates a task has been completed.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Complete task" : {
|
||||
"comment" : "A label displayed as a button that completes a task.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Complete Task" : {
|
||||
"comment" : "A button label that says \"Complete Task\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -5338,9 +5449,23 @@
|
||||
"comment" : "The title of the view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Completion Animation, %@" : {
|
||||
|
||||
},
|
||||
"Completion history for %@" : {
|
||||
"comment" : "A sheet that shows a user's completions history for a task. The argument is the name of the task.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Completion photo%@" : {
|
||||
"comment" : "A label for the photo view. The argument is the caption of the photo.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Completion Photos" : {
|
||||
"comment" : "The title for the view that shows a user's photo submissions.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Completions (%lld)" : {
|
||||
|
||||
},
|
||||
"completions at %@" : {
|
||||
"comment" : "A subheading describing the content of the honeycomb view.",
|
||||
@@ -5354,6 +5479,10 @@
|
||||
},
|
||||
"Continue with Free" : {
|
||||
|
||||
},
|
||||
"Contractor actions" : {
|
||||
"comment" : "A label for the menu button that appears in the navigation bar.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Contractor Imported" : {
|
||||
|
||||
@@ -9002,6 +9131,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Copy share code" : {
|
||||
"comment" : "A button that copies the share code to the user's clipboard.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Cost: %@" : {
|
||||
"comment" : "A label displaying the cost of a task completion. The argument is the cost of the completion.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -9017,6 +9150,10 @@
|
||||
},
|
||||
"Creating Account..." : {
|
||||
|
||||
},
|
||||
"Delete property" : {
|
||||
"comment" : "A button that deletes a residence.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Didn't receive a code? Check your spam folder or re-register" : {
|
||||
"comment" : "A hint instructing the user to check their spam folder if they haven't received the verification code.",
|
||||
@@ -9030,6 +9167,10 @@
|
||||
"comment" : "A label displayed above the documents tab in the main tab view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Document actions" : {
|
||||
"comment" : "A label for the menu button that appears in the navigation bar.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"documents_active" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -17032,6 +17173,63 @@
|
||||
"comment" : "A button that dismisses an image viewer sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Double tap to %@ completions" : {
|
||||
|
||||
},
|
||||
"Double tap to archive this task" : {
|
||||
"comment" : "A hint for the user to double tap a task to archive it.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Double tap to cancel this task" : {
|
||||
"comment" : "A hint for the user to double tap a task to cancel it.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Double tap to complete this task" : {
|
||||
"comment" : "A hint for the user to double tap a task to complete it.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Double tap to continue to the next step" : {
|
||||
"comment" : "A hint that describes the action to be taken.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Double tap to create account" : {
|
||||
|
||||
},
|
||||
"Double tap to edit this task" : {
|
||||
"comment" : "A hint for the user to double tap a task to edit it.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Double tap to join an existing property with a share code" : {
|
||||
|
||||
},
|
||||
"Double tap to log in to your existing account" : {
|
||||
|
||||
},
|
||||
"Double tap to mark this task as in progress" : {
|
||||
"comment" : "A hint for the user to double tap a task to mark it as in progress.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Double tap to restore this task" : {
|
||||
"comment" : "A hint for the user to double tap a task to restore it.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Double tap to send a verification code to your email" : {
|
||||
|
||||
},
|
||||
"Double tap to sign in" : {
|
||||
|
||||
},
|
||||
"Double tap to start setting up your property" : {
|
||||
|
||||
},
|
||||
"Double tap to unarchive this task" : {
|
||||
"comment" : "A hint for unarchive task buttons.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Double tap to use this template" : {
|
||||
"comment" : "A hint for using a task template.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Downloading..." : {
|
||||
|
||||
},
|
||||
@@ -17043,6 +17241,14 @@
|
||||
"comment" : "A label for an edit action.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Edit property" : {
|
||||
"comment" : "A label for the edit button in the residences list.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Edit task" : {
|
||||
"comment" : "A label for a button that edits a task.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Edit Task" : {
|
||||
"comment" : "A label for an \"Edit Task\" button.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -17053,10 +17259,25 @@
|
||||
"Email Address" : {
|
||||
"comment" : "A label for the user to input their email address.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Enter 6-character share code" : {
|
||||
|
||||
},
|
||||
"Enter 6-digit code" : {
|
||||
"comment" : "A placeholder text for a text field where a user can enter a 6-digit code.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Enter 6-digit verification code" : {
|
||||
|
||||
},
|
||||
"Enter 6-digit verification code from your email" : {
|
||||
|
||||
},
|
||||
"Enter a password with at least 8 characters" : {
|
||||
|
||||
},
|
||||
"Enter a unique username" : {
|
||||
|
||||
},
|
||||
"Enter new password" : {
|
||||
|
||||
@@ -17072,13 +17293,31 @@
|
||||
"Enter the 6-digit code from your email" : {
|
||||
"comment" : "A footer label explaining that users should enter the 6-digit code they received in their email.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Enter the name of your property" : {
|
||||
|
||||
},
|
||||
"Enter the number of days between each occurrence" : {
|
||||
|
||||
},
|
||||
"Enter your account email address" : {
|
||||
|
||||
},
|
||||
"Enter your email address" : {
|
||||
|
||||
},
|
||||
"Enter your email address and we'll send you a verification code" : {
|
||||
"comment" : "A description below the email input field, instructing the user to enter their email address to receive a password reset code.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Enter your first name" : {
|
||||
|
||||
},
|
||||
"Enter your last name" : {
|
||||
|
||||
},
|
||||
"Enter your ZIP code" : {
|
||||
|
||||
},
|
||||
"Enter your ZIP code so we can suggest\nmaintenance tasks for your climate region." : {
|
||||
|
||||
@@ -17423,6 +17662,13 @@
|
||||
"Feature" : {
|
||||
"comment" : "The header for a feature in the feature comparison table.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Filter by specialty" : {
|
||||
"comment" : "A button that filters the list of contractors by specialty.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Filter documents" : {
|
||||
|
||||
},
|
||||
"Forgot Password?" : {
|
||||
"comment" : "A title for the \"Forgot Password?\" screen.",
|
||||
@@ -17439,18 +17685,45 @@
|
||||
"comment" : "A button label that generates a new invitation code.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Generate maintenance report" : {
|
||||
"comment" : "A button that generates a maintenance report.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Generate New Code" : {
|
||||
"comment" : "A button label that appears when a user wants to generate a new invitation code.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Generate new share code" : {
|
||||
"comment" : "A button that generates a new share code.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Get notified when someone joins your property" : {
|
||||
|
||||
},
|
||||
"Get notified when tasks are assigned to you" : {
|
||||
|
||||
},
|
||||
"Get notified when tasks are completed by others" : {
|
||||
|
||||
},
|
||||
"Get notified when tasks are due soon" : {
|
||||
|
||||
},
|
||||
"Get notified when tasks are overdue" : {
|
||||
|
||||
},
|
||||
"Get notified when warranties are about to expire" : {
|
||||
|
||||
},
|
||||
"Go back" : {
|
||||
"comment" : "A label for the back button.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Google Sign-In Error" : {
|
||||
|
||||
},
|
||||
"Help improve honeyDue by sharing anonymous usage data" : {
|
||||
|
||||
},
|
||||
"Here are tasks recommended for your area.\nPick the ones you'd like to track!" : {
|
||||
|
||||
},
|
||||
"Honeycomb Pattern" : {
|
||||
"comment" : "A feature that adds a subtle hexagonal grid overlay to the app's interface.",
|
||||
@@ -17475,6 +17748,10 @@
|
||||
"comment" : "A button label that indicates the user is ready to use honeyDue.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Image" : {
|
||||
"comment" : "A label describing an image.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Image %lld of %lld" : {
|
||||
"comment" : "A navigation title that shows the current image index and the total number of images.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
@@ -17503,6 +17780,10 @@
|
||||
"comment" : "A header that suggests inviting others to join the app.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Join a property" : {
|
||||
"comment" : "A button that opens a sheet for joining a residence.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Join a Residence" : {
|
||||
"comment" : "A button label that instructs the user to join an existing residence.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -17534,6 +17815,9 @@
|
||||
},
|
||||
"Let's give your place a name!" : {
|
||||
|
||||
},
|
||||
"Loading" : {
|
||||
|
||||
},
|
||||
"Loading..." : {
|
||||
"comment" : "A placeholder text indicating that content is loading.",
|
||||
@@ -17547,6 +17831,10 @@
|
||||
},
|
||||
"Manage at honeyDue.treytartt.com" : {
|
||||
|
||||
},
|
||||
"Manage users" : {
|
||||
"comment" : "A button that opens a sheet for managing users in a residence.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Manage your subscription at honeyDue.treytartt.com" : {
|
||||
"comment" : "A description of how to manage a subscription on a third-party platform.",
|
||||
@@ -17554,6 +17842,10 @@
|
||||
},
|
||||
"Manage your subscription on your Android device" : {
|
||||
|
||||
},
|
||||
"Mark as in progress" : {
|
||||
"comment" : "A hint for the user to mark a task as in progress.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Mark Task In Progress" : {
|
||||
"comment" : "A button label that says \"Mark Task In Progress\".",
|
||||
@@ -17594,6 +17886,14 @@
|
||||
"comment" : "A message displayed when no task templates match a search query.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"None" : {
|
||||
"comment" : "A button that clears the selection.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"not selected" : {
|
||||
"comment" : "A label that describes the selection state of a task.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Notification Time" : {
|
||||
"comment" : "The title of the sheet where a user can select the time for receiving notifications.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -17610,9 +17910,16 @@
|
||||
"comment" : "A button that dismisses the success dialog.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Open %@ in Maps" : {
|
||||
"comment" : "A label for the accessibility element that opens the address in Maps.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Open honeyDue.treytartt.com" : {
|
||||
"comment" : "A button label that opens the user's subscription management page in a web browser.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Opens email to contact support" : {
|
||||
|
||||
},
|
||||
"or" : {
|
||||
|
||||
@@ -17632,10 +17939,18 @@
|
||||
"comment" : "A title for a view that displays a single photo.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Photo%@" : {
|
||||
"comment" : "A label for a photo. The argument is the caption of the photo.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Primary" : {
|
||||
"comment" : "A label indicating that a residence is the user's primary residence.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Primary property" : {
|
||||
"comment" : "A label for the star icon.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Privacy" : {
|
||||
|
||||
},
|
||||
@@ -21692,12 +22007,43 @@
|
||||
},
|
||||
"Quick Start" : {
|
||||
|
||||
},
|
||||
"Rated %@ out of 5" : {
|
||||
"comment" : "A label that describes the rating of a task completion. The argument is the rating.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Rated 4.9 stars by 10K+ homeowners" : {
|
||||
|
||||
},
|
||||
"Rating: %lld out of 5 stars" : {
|
||||
|
||||
},
|
||||
"Re-enter new password" : {
|
||||
|
||||
},
|
||||
"Re-enter your password to confirm" : {
|
||||
|
||||
},
|
||||
"Receive a daily summary of upcoming tasks" : {
|
||||
|
||||
},
|
||||
"Receive email notifications when tasks are completed" : {
|
||||
|
||||
},
|
||||
"Refresh tasks" : {
|
||||
"comment" : "A button that refreshes the tasks.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Remove" : {
|
||||
|
||||
},
|
||||
"Remove %@ from favorites" : {
|
||||
"comment" : "A label for the favorite button. The argument is the name of the contractor.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Remove %@ from property" : {
|
||||
"comment" : "A button that removes a user from a property. The argument is the username of the user to be removed.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Remove User" : {
|
||||
|
||||
@@ -24845,6 +25191,10 @@
|
||||
"comment" : "A button label that allows users to restore previous purchases.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Restore task" : {
|
||||
"comment" : "A button that restores a task.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Restore Task" : {
|
||||
"comment" : "A button that restores a cancelled or archived task.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -24866,6 +25216,10 @@
|
||||
},
|
||||
"Save your home to your account" : {
|
||||
|
||||
},
|
||||
"Search task templates by name" : {
|
||||
"comment" : "A hint for the search bar in the task templates browser.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Search templates..." : {
|
||||
"comment" : "A placeholder text for a search bar in the task templates browser.",
|
||||
@@ -24875,6 +25229,14 @@
|
||||
"comment" : "A label displayed above the picker for selecting the notification time.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"selected" : {
|
||||
"comment" : "A label that describes the selection state of a task.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Selected" : {
|
||||
"comment" : "A label that describes a selected option.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Send a .honeydue file via Messages, Email, or AirDrop. They just tap to join." : {
|
||||
|
||||
},
|
||||
@@ -24899,6 +25261,10 @@
|
||||
},
|
||||
"Set New Password" : {
|
||||
|
||||
},
|
||||
"Settings" : {
|
||||
"comment" : "A button that opens a settings screen.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"settings_language" : {
|
||||
"extractionState" : "manual",
|
||||
@@ -25037,12 +25403,33 @@
|
||||
"comment" : "A label displayed above the share code section of the view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Share code: %@" : {
|
||||
"comment" : "A label for the share code, with the share code as the value.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Share this 6-character code. They can enter it in the app to join." : {
|
||||
"comment" : "A description of how to share the invitation code with others.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Shared Users (%lld)" : {
|
||||
|
||||
},
|
||||
"Show active warranties only" : {
|
||||
|
||||
},
|
||||
"Show all contractors" : {
|
||||
"comment" : "A label for a button that shows all contractors.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Show all warranties" : {
|
||||
|
||||
},
|
||||
"Show favorites only" : {
|
||||
"comment" : "A label for a button that filters contractors to show only the ones that the user has marked as favorites.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Sign in with Apple" : {
|
||||
|
||||
},
|
||||
"Sign in with Google" : {
|
||||
|
||||
@@ -25054,6 +25441,10 @@
|
||||
"comment" : "A button label that allows users to skip the current onboarding step.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Skip for now" : {
|
||||
"comment" : "A button label that skips onboarding.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Skip for Now" : {
|
||||
|
||||
},
|
||||
@@ -25072,6 +25463,10 @@
|
||||
"comment" : "A description below the title of the screen.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Step %lld of 5" : {
|
||||
"comment" : "A label that describes the current step in the onboarding flow. The argument is the step number.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Subscription Active" : {
|
||||
"comment" : "The title of an alert that appears when a user successfully upgrades to a premium subscription.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -25087,6 +25482,10 @@
|
||||
"comment" : "A description of an action a user can take to add a property.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Task actions" : {
|
||||
"comment" : "A label displayed as a button in the task card that opens a menu of task actions.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Task Templates" : {
|
||||
"comment" : "The title of the view that lists all predefined task templates.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -30193,6 +30592,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Tell us about your home" : {
|
||||
|
||||
},
|
||||
"Templates will appear here once loaded" : {
|
||||
"comment" : "A description text displayed when there are no task templates available.",
|
||||
@@ -30203,6 +30605,10 @@
|
||||
},
|
||||
"The Smith Residence" : {
|
||||
|
||||
},
|
||||
"Toggle password visibility" : {
|
||||
"comment" : "A button that toggles the visibility of a password.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Try a different search term" : {
|
||||
"comment" : "A description below the \"No Templates Found\" message in the search results section of the task templates browser.",
|
||||
@@ -30216,6 +30622,10 @@
|
||||
"comment" : "A button that unarchives a task.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Unarchive task" : {
|
||||
"comment" : "A button that unarchives a task.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Unarchive Task" : {
|
||||
|
||||
},
|
||||
@@ -30249,6 +30659,14 @@
|
||||
"comment" : "A message displayed while waiting for the email verification process to complete.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"View %d completions" : {
|
||||
"comment" : "A label that describes the action of viewing the completions of a task. The argument is the number of completions.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"View %lld photos" : {
|
||||
"comment" : "A button that opens a new screen showing a list of photos. The number in parentheses is replaced with the actual number of photos.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"View Photos (%lld)" : {
|
||||
"comment" : "A button that, when tapped, opens a view displaying photos taken during a task completion. The number in parentheses is replaced with the actual number of photos.",
|
||||
"isCommentAutoGenerated" : true
|
||||
|
||||
@@ -6,6 +6,7 @@ struct LoginView: View {
|
||||
@StateObject private var appleSignInViewModel = AppleSignInViewModel()
|
||||
@FocusState private var focusedField: Field?
|
||||
@State private var showingRegister = false
|
||||
@State private var registrationVerified = false
|
||||
@State private var showVerification = false
|
||||
@State private var showPasswordReset = false
|
||||
@State private var isPasswordVisible = false
|
||||
@@ -69,6 +70,7 @@ struct LoginView: View {
|
||||
Text(L10n.Auth.welcomeBack)
|
||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
|
||||
Text(L10n.Auth.signInSubtitle)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
@@ -148,6 +150,7 @@ struct LoginView: View {
|
||||
action: viewModel.login
|
||||
)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.loginButton)
|
||||
.accessibilityHint("Double tap to sign in")
|
||||
|
||||
// Divider
|
||||
HStack(spacing: 12) {
|
||||
@@ -180,6 +183,7 @@ struct LoginView: View {
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.appleSignInButton)
|
||||
.accessibilityLabel("Sign in with Apple")
|
||||
|
||||
// Apple Sign In loading indicator
|
||||
if appleSignInViewModel.isLoading {
|
||||
@@ -216,6 +220,7 @@ struct LoginView: View {
|
||||
)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.googleSignInButton)
|
||||
.accessibilityLabel("Sign in with Google")
|
||||
|
||||
// Apple Sign In Error
|
||||
if let appleError = appleSignInViewModel.errorMessage {
|
||||
@@ -310,8 +315,18 @@ struct LoginView: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showingRegister) {
|
||||
RegisterView()
|
||||
.sheet(isPresented: $showingRegister, onDismiss: {
|
||||
// Sheet is fully removed from the UIKit presentation stack.
|
||||
// Set auth state now that no UIKit presentations block the
|
||||
// RootView hierarchy swap.
|
||||
if registrationVerified {
|
||||
registrationVerified = false
|
||||
AuthenticationManager.shared.login(verified: true)
|
||||
}
|
||||
}) {
|
||||
RegisterView(isPresented: $showingRegister, onVerified: {
|
||||
registrationVerified = true
|
||||
})
|
||||
}
|
||||
.sheet(isPresented: $showPasswordReset) {
|
||||
PasswordResetFlow(resetToken: activeResetToken, onLoginSuccess: { isVerified in
|
||||
|
||||
@@ -64,11 +64,20 @@ struct OnboardingCoordinator: View {
|
||||
return
|
||||
}
|
||||
|
||||
let postalCode = onboardingState.pendingPostalCode.isEmpty ? nil : onboardingState.pendingPostalCode
|
||||
print("🏠 ONBOARDING: Creating residence with name: \(onboardingState.pendingResidenceName), zip: \(postalCode ?? "none")")
|
||||
let postalCode: String? = nil
|
||||
print("🏠 ONBOARDING: Creating residence with name: \(onboardingState.pendingResidenceName)")
|
||||
|
||||
isCreatingResidence = true
|
||||
|
||||
// Collect home profile booleans — only send true values
|
||||
let hasPool = onboardingState.pendingHasPool ? KotlinBoolean(bool: true) : nil
|
||||
let hasSprinkler = onboardingState.pendingHasSprinklerSystem ? KotlinBoolean(bool: true) : nil
|
||||
let hasSeptic = onboardingState.pendingHasSeptic ? KotlinBoolean(bool: true) : nil
|
||||
let hasFireplace = onboardingState.pendingHasFireplace ? KotlinBoolean(bool: true) : nil
|
||||
let hasGarage = onboardingState.pendingHasGarage ? KotlinBoolean(bool: true) : nil
|
||||
let hasBasement = onboardingState.pendingHasBasement ? KotlinBoolean(bool: true) : nil
|
||||
let hasAttic = onboardingState.pendingHasAttic ? KotlinBoolean(bool: true) : nil
|
||||
|
||||
let request = ResidenceCreateRequest(
|
||||
name: onboardingState.pendingResidenceName,
|
||||
propertyTypeId: nil,
|
||||
@@ -86,7 +95,21 @@ struct OnboardingCoordinator: View {
|
||||
description: nil,
|
||||
purchaseDate: nil,
|
||||
purchasePrice: nil,
|
||||
isPrimary: KotlinBoolean(bool: true)
|
||||
isPrimary: KotlinBoolean(bool: true),
|
||||
heatingType: onboardingState.pendingHeatingType,
|
||||
coolingType: onboardingState.pendingCoolingType,
|
||||
waterHeaterType: onboardingState.pendingWaterHeaterType,
|
||||
roofType: onboardingState.pendingRoofType,
|
||||
hasPool: hasPool,
|
||||
hasSprinklerSystem: hasSprinkler,
|
||||
hasSeptic: hasSeptic,
|
||||
hasFireplace: hasFireplace,
|
||||
hasGarage: hasGarage,
|
||||
hasBasement: hasBasement,
|
||||
hasAttic: hasAttic,
|
||||
exteriorType: onboardingState.pendingExteriorType,
|
||||
flooringPrimary: onboardingState.pendingFlooringPrimary,
|
||||
landscapingType: onboardingState.pendingLandscapingType
|
||||
)
|
||||
|
||||
residenceViewModel.createResidence(request: request) { (residence: ResidenceResponse?) in
|
||||
@@ -103,7 +126,7 @@ struct OnboardingCoordinator: View {
|
||||
}
|
||||
|
||||
/// Current step index for progress indicator (0-based)
|
||||
/// Flow: Welcome → Features → Name → Account → Verify → Location → Tasks → Upsell
|
||||
/// Flow: Welcome → Features → Name → Account → Verify → Home Profile → Tasks → Upsell
|
||||
private var currentProgressStep: Int {
|
||||
switch onboardingState.currentStep {
|
||||
case .welcome: return 0
|
||||
@@ -113,6 +136,7 @@ struct OnboardingCoordinator: View {
|
||||
case .verifyEmail: return 4
|
||||
case .joinResidence: return 4
|
||||
case .residenceLocation: return 4
|
||||
case .homeProfile: return 4
|
||||
case .firstTask: return 4
|
||||
case .subscriptionUpsell: return 4
|
||||
}
|
||||
@@ -121,7 +145,7 @@ struct OnboardingCoordinator: View {
|
||||
/// Whether to show the back button
|
||||
private var showBackButton: Bool {
|
||||
switch onboardingState.currentStep {
|
||||
case .welcome, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
|
||||
case .welcome, .joinResidence, .residenceLocation, .homeProfile, .firstTask, .subscriptionUpsell:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
@@ -131,7 +155,7 @@ struct OnboardingCoordinator: View {
|
||||
/// Whether to show the skip button
|
||||
private var showSkipButton: Bool {
|
||||
switch onboardingState.currentStep {
|
||||
case .valueProps, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
|
||||
case .valueProps, .joinResidence, .homeProfile, .firstTask, .subscriptionUpsell:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -141,7 +165,7 @@ struct OnboardingCoordinator: View {
|
||||
/// Whether to show the progress indicator
|
||||
private var showProgressIndicator: Bool {
|
||||
switch onboardingState.currentStep {
|
||||
case .welcome, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
|
||||
case .welcome, .joinResidence, .residenceLocation, .homeProfile, .firstTask, .subscriptionUpsell:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
@@ -173,12 +197,13 @@ struct OnboardingCoordinator: View {
|
||||
switch onboardingState.currentStep {
|
||||
case .valueProps:
|
||||
goForward()
|
||||
case .residenceLocation:
|
||||
// Skipping location — still need to create residence (without postal code)
|
||||
case .homeProfile:
|
||||
// Skipping home profile — create residence without profile data, go to tasks
|
||||
createResidenceIfNeeded(thenNavigateTo: .firstTask)
|
||||
case .joinResidence, .firstTask:
|
||||
goForward()
|
||||
case .subscriptionUpsell:
|
||||
case .joinResidence:
|
||||
onboardingState.completeOnboarding()
|
||||
onComplete()
|
||||
case .firstTask, .subscriptionUpsell:
|
||||
onboardingState.completeOnboarding()
|
||||
onComplete()
|
||||
default:
|
||||
@@ -197,6 +222,7 @@ struct OnboardingCoordinator: View {
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.backButton)
|
||||
.accessibilityLabel("Go back")
|
||||
.frame(width: 44, alignment: .leading)
|
||||
.opacity(showBackButton ? 1 : 0)
|
||||
.disabled(!showBackButton)
|
||||
@@ -207,6 +233,7 @@ struct OnboardingCoordinator: View {
|
||||
if showProgressIndicator {
|
||||
OnboardingProgressIndicator(currentStep: currentProgressStep, totalSteps: 5)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.progressIndicator)
|
||||
.accessibilityLabel("Step \(currentProgressStep + 1) of 5")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -219,6 +246,7 @@ struct OnboardingCoordinator: View {
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.skipButton)
|
||||
.accessibilityLabel("Skip")
|
||||
.frame(width: 44, alignment: .trailing)
|
||||
.opacity(showSkipButton ? 1 : 0)
|
||||
.disabled(!showSkipButton)
|
||||
@@ -273,7 +301,7 @@ struct OnboardingCoordinator: View {
|
||||
if onboardingState.userIntent == .joinExisting {
|
||||
goForward(to: .joinResidence)
|
||||
} else {
|
||||
goForward(to: .residenceLocation)
|
||||
goForward(to: .homeProfile)
|
||||
}
|
||||
} else {
|
||||
goForward()
|
||||
@@ -289,7 +317,7 @@ struct OnboardingCoordinator: View {
|
||||
if onboardingState.userIntent == .joinExisting {
|
||||
goForward(to: .joinResidence)
|
||||
} else {
|
||||
goForward(to: .residenceLocation)
|
||||
goForward(to: .homeProfile)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -298,17 +326,22 @@ struct OnboardingCoordinator: View {
|
||||
case .joinResidence:
|
||||
OnboardingJoinResidenceContent(
|
||||
onJoined: {
|
||||
goForward()
|
||||
onboardingState.completeOnboarding()
|
||||
onComplete()
|
||||
}
|
||||
)
|
||||
.transition(navigationTransition)
|
||||
|
||||
case .residenceLocation:
|
||||
OnboardingLocationContent(
|
||||
onLocationDetected: { zip in
|
||||
// Load regional templates in background while creating residence
|
||||
onboardingState.loadRegionalTemplates(zip: zip)
|
||||
// Create residence with postal code, then go to first task
|
||||
// Location step removed — skip to home profile if we land here
|
||||
EmptyView()
|
||||
.onAppear { goForward(to: .homeProfile) }
|
||||
.transition(navigationTransition)
|
||||
|
||||
case .homeProfile:
|
||||
OnboardingHomeProfileContent(
|
||||
onContinue: {
|
||||
// Create residence with all collected data, then go to tasks
|
||||
createResidenceIfNeeded(thenNavigateTo: .firstTask)
|
||||
},
|
||||
onSkip: {
|
||||
@@ -321,19 +354,20 @@ struct OnboardingCoordinator: View {
|
||||
OnboardingFirstTaskContent(
|
||||
residenceName: onboardingState.pendingResidenceName,
|
||||
onTaskAdded: {
|
||||
goForward()
|
||||
onboardingState.completeOnboarding()
|
||||
onComplete()
|
||||
}
|
||||
)
|
||||
.transition(navigationTransition)
|
||||
|
||||
case .subscriptionUpsell:
|
||||
OnboardingSubscriptionContent(
|
||||
onSubscribe: {
|
||||
// Handle subscription flow
|
||||
// Subscription removed from onboarding — app is free
|
||||
// Immediately complete if we somehow land here
|
||||
EmptyView()
|
||||
.onAppear {
|
||||
onboardingState.completeOnboarding()
|
||||
onComplete()
|
||||
}
|
||||
)
|
||||
.transition(navigationTransition)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ struct OnboardingCreateAccountContent: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
.a11yDecorative()
|
||||
|
||||
// Decorative blobs
|
||||
GeometryReader { geo in
|
||||
@@ -56,6 +57,7 @@ struct OnboardingCreateAccountContent: View {
|
||||
.offset(x: geo.size.width * 0.55, y: geo.size.height * 0.05)
|
||||
.blur(radius: 20)
|
||||
}
|
||||
.a11yDecorative()
|
||||
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: OrganicSpacing.comfortable) {
|
||||
@@ -108,6 +110,7 @@ struct OnboardingCreateAccountContent: View {
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.createAccountTitle)
|
||||
.a11yHeader()
|
||||
|
||||
Text("Your data will be synced across devices")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
@@ -226,6 +229,7 @@ struct OnboardingCreateAccountContent: View {
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.textContentType(.username)
|
||||
.accessibilityHint("Enter a unique username")
|
||||
|
||||
OrganicOnboardingTextField(
|
||||
icon: "envelope.fill",
|
||||
@@ -239,6 +243,7 @@ struct OnboardingCreateAccountContent: View {
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.emailAddress)
|
||||
.textContentType(.emailAddress)
|
||||
.accessibilityHint("Enter your email address")
|
||||
|
||||
OrganicOnboardingSecureField(
|
||||
icon: "lock.fill",
|
||||
@@ -248,6 +253,7 @@ struct OnboardingCreateAccountContent: View {
|
||||
accessibilityIdentifier: AccessibilityIdentifiers.Onboarding.passwordField
|
||||
)
|
||||
.focused($focusedField, equals: .password)
|
||||
.accessibilityHint("Enter a password with at least 8 characters")
|
||||
|
||||
OrganicOnboardingSecureField(
|
||||
icon: "lock.fill",
|
||||
@@ -257,6 +263,7 @@ struct OnboardingCreateAccountContent: View {
|
||||
accessibilityIdentifier: AccessibilityIdentifiers.Onboarding.confirmPasswordField
|
||||
)
|
||||
.focused($focusedField, equals: .confirmPassword)
|
||||
.accessibilityHint("Re-enter your password to confirm")
|
||||
|
||||
// Password Requirements
|
||||
if !viewModel.password.isEmpty {
|
||||
@@ -359,7 +366,12 @@ struct OnboardingCreateAccountContent: View {
|
||||
}
|
||||
.onChange(of: viewModel.isRegistered) { _, isRegistered in
|
||||
if isRegistered {
|
||||
// Registration successful - user is authenticated but not verified
|
||||
// Registration successful — server gave us a token, so we ARE
|
||||
// authenticated (just not verified yet). Mark the iOS-side auth
|
||||
// state to match, otherwise OnboardingState.completeOnboarding's
|
||||
// auth guard silently no-ops at the end of the flow and the
|
||||
// user gets stuck on the firstTask screen.
|
||||
AuthenticationManager.shared.login(verified: false)
|
||||
onAccountCreated(false)
|
||||
}
|
||||
}
|
||||
@@ -444,7 +456,13 @@ private struct OrganicOnboardingSecureField: View {
|
||||
@Binding var text: String
|
||||
var isFocused: Bool = false
|
||||
var accessibilityIdentifier: String? = nil
|
||||
@State private var showPassword = false
|
||||
// iOS 26 has a known bug where tapping a SwiftUI SecureField with
|
||||
// `.textContentType(.password)` doesn't reliably bring up the keyboard
|
||||
// — the strong-password autofill panel steals focus. Under UI tests
|
||||
// we force the visibility toggle ON, rendering as a plain TextField,
|
||||
// which has reliable focus behavior. The plaintext isn't a security
|
||||
// concern in test mode (test creds are throwaway).
|
||||
@State private var showPassword = UITestRuntime.isEnabled
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,515 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Screen: Home profile — systems, features, exterior, interior
|
||||
struct OnboardingHomeProfileContent: View {
|
||||
var onContinue: () -> Void
|
||||
var onSkip: () -> Void
|
||||
|
||||
@ObservedObject private var onboardingState = OnboardingState.shared
|
||||
@State private var isAnimating = false
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
.a11yDecorative()
|
||||
|
||||
// Decorative blobs
|
||||
GeometryReader { geo in
|
||||
OrganicBlobShape(variation: 2)
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color.appAccent.opacity(0.08),
|
||||
Color.appAccent.opacity(0.02),
|
||||
Color.clear
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: geo.size.width * 0.35
|
||||
)
|
||||
)
|
||||
.frame(width: geo.size.width * 0.6, height: geo.size.height * 0.35)
|
||||
.offset(x: -geo.size.width * 0.15, y: geo.size.height * 0.05)
|
||||
.blur(radius: 25)
|
||||
|
||||
OrganicBlobShape(variation: 0)
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color.appPrimary.opacity(0.06),
|
||||
Color.appPrimary.opacity(0.01),
|
||||
Color.clear
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: geo.size.width * 0.3
|
||||
)
|
||||
)
|
||||
.frame(width: geo.size.width * 0.5, height: geo.size.height * 0.3)
|
||||
.offset(x: geo.size.width * 0.55, y: geo.size.height * 0.6)
|
||||
.blur(radius: 20)
|
||||
}
|
||||
.a11yDecorative()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: OrganicSpacing.comfortable) {
|
||||
// Header
|
||||
VStack(spacing: 16) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [Color.appPrimary.opacity(0.15), Color.clear],
|
||||
center: .center,
|
||||
startRadius: 30,
|
||||
endRadius: 80
|
||||
)
|
||||
)
|
||||
.frame(width: 160, height: 160)
|
||||
.offset(x: -20, y: -20)
|
||||
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
||||
.animation(
|
||||
isAnimating
|
||||
? Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true)
|
||||
: .default,
|
||||
value: isAnimating
|
||||
)
|
||||
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [Color.appAccent.opacity(0.15), Color.clear],
|
||||
center: .center,
|
||||
startRadius: 30,
|
||||
endRadius: 80
|
||||
)
|
||||
)
|
||||
.frame(width: 160, height: 160)
|
||||
.offset(x: 20, y: 20)
|
||||
.scaleEffect(isAnimating ? 0.95 : 1.05)
|
||||
.animation(
|
||||
isAnimating
|
||||
? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true).delay(0.5)
|
||||
: .default,
|
||||
value: isAnimating
|
||||
)
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appSecondary],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: 90, height: 90)
|
||||
|
||||
Image(systemName: "house.lodge.fill")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.naturalShadow(.pronounced)
|
||||
}
|
||||
|
||||
Text("Tell us about your home")
|
||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
.a11yHeader()
|
||||
|
||||
Text("All optional -- helps us personalize your plan")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineSpacing(4)
|
||||
}
|
||||
.padding(.top, OrganicSpacing.cozy)
|
||||
|
||||
// Systems section
|
||||
ProfileSection(title: "Systems", icon: "gearshape.2.fill", color: .appPrimary) {
|
||||
VStack(spacing: 12) {
|
||||
ProfilePicker(
|
||||
label: "Heating",
|
||||
icon: "flame.fill",
|
||||
selection: $onboardingState.pendingHeatingType,
|
||||
options: HomeProfileOptions.heatingTypes
|
||||
)
|
||||
ProfilePicker(
|
||||
label: "Cooling",
|
||||
icon: "snowflake",
|
||||
selection: $onboardingState.pendingCoolingType,
|
||||
options: HomeProfileOptions.coolingTypes
|
||||
)
|
||||
ProfilePicker(
|
||||
label: "Water Heater",
|
||||
icon: "drop.fill",
|
||||
selection: $onboardingState.pendingWaterHeaterType,
|
||||
options: HomeProfileOptions.waterHeaterTypes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Features section
|
||||
ProfileSection(title: "Features", icon: "star.fill", color: .appAccent) {
|
||||
HomeFeatureChipGrid(
|
||||
features: [
|
||||
FeatureToggle(label: "Pool", icon: "figure.pool.swim", isOn: $onboardingState.pendingHasPool),
|
||||
FeatureToggle(label: "Sprinklers", icon: "sprinkler.and.droplets.fill", isOn: $onboardingState.pendingHasSprinklerSystem),
|
||||
FeatureToggle(label: "Fireplace", icon: "fireplace.fill", isOn: $onboardingState.pendingHasFireplace),
|
||||
FeatureToggle(label: "Garage", icon: "car.fill", isOn: $onboardingState.pendingHasGarage),
|
||||
FeatureToggle(label: "Basement", icon: "arrow.down.square.fill", isOn: $onboardingState.pendingHasBasement),
|
||||
FeatureToggle(label: "Attic", icon: "arrow.up.square.fill", isOn: $onboardingState.pendingHasAttic),
|
||||
FeatureToggle(label: "Septic", icon: "drop.triangle.fill", isOn: $onboardingState.pendingHasSeptic),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// Exterior section
|
||||
ProfileSection(title: "Exterior", icon: "house.fill", color: Color(hex: "#34C759") ?? .green) {
|
||||
VStack(spacing: 12) {
|
||||
ProfilePicker(
|
||||
label: "Roof Type",
|
||||
icon: "triangle.fill",
|
||||
selection: $onboardingState.pendingRoofType,
|
||||
options: HomeProfileOptions.roofTypes
|
||||
)
|
||||
ProfilePicker(
|
||||
label: "Exterior",
|
||||
icon: "square.stack.3d.up.fill",
|
||||
selection: $onboardingState.pendingExteriorType,
|
||||
options: HomeProfileOptions.exteriorTypes
|
||||
)
|
||||
ProfilePicker(
|
||||
label: "Landscaping",
|
||||
icon: "leaf.fill",
|
||||
selection: $onboardingState.pendingLandscapingType,
|
||||
options: HomeProfileOptions.landscapingTypes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Interior section
|
||||
ProfileSection(title: "Interior", icon: "sofa.fill", color: Color(hex: "#AF52DE") ?? .purple) {
|
||||
ProfilePicker(
|
||||
label: "Primary Flooring",
|
||||
icon: "square.grid.3x3.fill",
|
||||
selection: $onboardingState.pendingFlooringPrimary,
|
||||
options: HomeProfileOptions.flooringTypes
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 140) // Space for button
|
||||
}
|
||||
|
||||
// Bottom action area
|
||||
VStack(spacing: 14) {
|
||||
Button(action: onContinue) {
|
||||
HStack(spacing: 10) {
|
||||
Text("Continue")
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appSecondary],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.naturalShadow(.medium)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||
.padding(.bottom, OrganicSpacing.airy)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.appBackgroundPrimary.opacity(0), Color.appBackgroundPrimary],
|
||||
startPoint: .top,
|
||||
endPoint: .center
|
||||
)
|
||||
.frame(height: 60)
|
||||
.offset(y: -60)
|
||||
, alignment: .top
|
||||
)
|
||||
}
|
||||
}
|
||||
.onAppear { isAnimating = true }
|
||||
.onDisappear { isAnimating = false }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Profile Section Card
|
||||
|
||||
private struct ProfileSection<Content: View>: View {
|
||||
let title: String
|
||||
let icon: String
|
||||
let color: Color
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
// Section header
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [color, color.opacity(0.7)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: 32, height: 32)
|
||||
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: 16, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
}
|
||||
|
||||
content
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
ZStack {
|
||||
Color.appBackgroundSecondary
|
||||
GrainTexture(opacity: 0.01)
|
||||
}
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.naturalShadow(.subtle)
|
||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Profile Picker (compact dropdown)
|
||||
|
||||
private struct ProfilePicker: View {
|
||||
let label: String
|
||||
let icon: String
|
||||
@Binding var selection: String?
|
||||
let options: [HomeProfileOption]
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.frame(width: 24)
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Menu {
|
||||
Button("None") {
|
||||
withAnimation(.spring(response: 0.3)) {
|
||||
selection = nil
|
||||
}
|
||||
}
|
||||
ForEach(options, id: \.value) { option in
|
||||
Button(option.display) {
|
||||
withAnimation(.spring(response: 0.3)) {
|
||||
selection = option.value
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text(displayValue)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(selection != nil ? Color.appPrimary : Color.appTextSecondary)
|
||||
+
|
||||
Text(" \(Image(systemName: "chevron.up.chevron.down"))")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
(selection != nil ? Color.appPrimary : Color.appTextSecondary).opacity(0.1)
|
||||
)
|
||||
.clipShape(Capsule())
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
|
||||
private var displayValue: String {
|
||||
guard let selection = selection else { return "Select" }
|
||||
return options.first { $0.value == selection }?.display ?? selection
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Feature Chip Toggle Grid
|
||||
|
||||
private struct FeatureToggle: Identifiable {
|
||||
let id = UUID()
|
||||
let label: String
|
||||
let icon: String
|
||||
@Binding var isOn: Bool
|
||||
}
|
||||
|
||||
private struct HomeFeatureChipGrid: View {
|
||||
let features: [FeatureToggle]
|
||||
|
||||
var body: some View {
|
||||
FlowLayout(spacing: 10) {
|
||||
ForEach(features) { feature in
|
||||
HomeFeatureChip(
|
||||
label: feature.label,
|
||||
icon: feature.icon,
|
||||
isSelected: feature.isOn,
|
||||
onTap: {
|
||||
withAnimation(.spring(response: 0.2)) {
|
||||
feature.isOn.toggle()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HomeFeatureChip: View {
|
||||
let label: String
|
||||
let icon: String
|
||||
let isSelected: Bool
|
||||
var onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(isSelected ? .white : Color.appTextPrimary)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
isSelected
|
||||
? AnyShapeStyle(LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appSecondary],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
))
|
||||
: AnyShapeStyle(Color.appTextSecondary.opacity(0.1))
|
||||
)
|
||||
.clipShape(Capsule())
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(
|
||||
isSelected ? Color.clear : Color.appTextSecondary.opacity(0.2),
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(label)
|
||||
.accessibilityValue(isSelected ? "selected" : "not selected")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Home Profile Options
|
||||
|
||||
struct HomeProfileOption {
|
||||
let value: String
|
||||
let display: String
|
||||
}
|
||||
|
||||
enum HomeProfileOptions {
|
||||
static let heatingTypes: [HomeProfileOption] = [
|
||||
HomeProfileOption(value: "gas_furnace", display: "Gas Furnace"),
|
||||
HomeProfileOption(value: "electric_furnace", display: "Electric Furnace"),
|
||||
HomeProfileOption(value: "heat_pump", display: "Heat Pump"),
|
||||
HomeProfileOption(value: "boiler", display: "Boiler"),
|
||||
HomeProfileOption(value: "radiant", display: "Radiant"),
|
||||
HomeProfileOption(value: "other", display: "Other"),
|
||||
]
|
||||
|
||||
static let coolingTypes: [HomeProfileOption] = [
|
||||
HomeProfileOption(value: "central_ac", display: "Central AC"),
|
||||
HomeProfileOption(value: "window_ac", display: "Window AC"),
|
||||
HomeProfileOption(value: "heat_pump", display: "Heat Pump"),
|
||||
HomeProfileOption(value: "evaporative", display: "Evaporative"),
|
||||
HomeProfileOption(value: "none", display: "None"),
|
||||
HomeProfileOption(value: "other", display: "Other"),
|
||||
]
|
||||
|
||||
static let waterHeaterTypes: [HomeProfileOption] = [
|
||||
HomeProfileOption(value: "tank_gas", display: "Tank (Gas)"),
|
||||
HomeProfileOption(value: "tank_electric", display: "Tank (Electric)"),
|
||||
HomeProfileOption(value: "tankless_gas", display: "Tankless (Gas)"),
|
||||
HomeProfileOption(value: "tankless_electric", display: "Tankless (Electric)"),
|
||||
HomeProfileOption(value: "heat_pump", display: "Heat Pump"),
|
||||
HomeProfileOption(value: "solar", display: "Solar"),
|
||||
HomeProfileOption(value: "other", display: "Other"),
|
||||
]
|
||||
|
||||
static let roofTypes: [HomeProfileOption] = [
|
||||
HomeProfileOption(value: "asphalt_shingle", display: "Asphalt Shingle"),
|
||||
HomeProfileOption(value: "metal", display: "Metal"),
|
||||
HomeProfileOption(value: "tile", display: "Tile"),
|
||||
HomeProfileOption(value: "slate", display: "Slate"),
|
||||
HomeProfileOption(value: "wood_shake", display: "Wood Shake"),
|
||||
HomeProfileOption(value: "flat", display: "Flat"),
|
||||
HomeProfileOption(value: "other", display: "Other"),
|
||||
]
|
||||
|
||||
static let exteriorTypes: [HomeProfileOption] = [
|
||||
HomeProfileOption(value: "brick", display: "Brick"),
|
||||
HomeProfileOption(value: "vinyl_siding", display: "Vinyl Siding"),
|
||||
HomeProfileOption(value: "wood_siding", display: "Wood Siding"),
|
||||
HomeProfileOption(value: "stucco", display: "Stucco"),
|
||||
HomeProfileOption(value: "stone", display: "Stone"),
|
||||
HomeProfileOption(value: "fiber_cement", display: "Fiber Cement"),
|
||||
HomeProfileOption(value: "other", display: "Other"),
|
||||
]
|
||||
|
||||
static let flooringTypes: [HomeProfileOption] = [
|
||||
HomeProfileOption(value: "hardwood", display: "Hardwood"),
|
||||
HomeProfileOption(value: "laminate", display: "Laminate"),
|
||||
HomeProfileOption(value: "tile", display: "Tile"),
|
||||
HomeProfileOption(value: "carpet", display: "Carpet"),
|
||||
HomeProfileOption(value: "vinyl", display: "Vinyl"),
|
||||
HomeProfileOption(value: "concrete", display: "Concrete"),
|
||||
HomeProfileOption(value: "other", display: "Other"),
|
||||
]
|
||||
|
||||
static let landscapingTypes: [HomeProfileOption] = [
|
||||
HomeProfileOption(value: "lawn", display: "Lawn"),
|
||||
HomeProfileOption(value: "desert", display: "Desert"),
|
||||
HomeProfileOption(value: "xeriscape", display: "Xeriscape"),
|
||||
HomeProfileOption(value: "garden", display: "Garden"),
|
||||
HomeProfileOption(value: "mixed", display: "Mixed"),
|
||||
HomeProfileOption(value: "none", display: "None"),
|
||||
HomeProfileOption(value: "other", display: "Other"),
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
OnboardingHomeProfileContent(
|
||||
onContinue: {},
|
||||
onSkip: {}
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ struct OnboardingJoinResidenceContent: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
.a11yDecorative()
|
||||
|
||||
// Decorative blobs
|
||||
GeometryReader { geo in
|
||||
@@ -57,6 +58,7 @@ struct OnboardingJoinResidenceContent: View {
|
||||
.offset(x: geo.size.width * 0.65, y: geo.size.height * 0.6)
|
||||
.blur(radius: 20)
|
||||
}
|
||||
.a11yDecorative()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
@@ -110,6 +112,7 @@ struct OnboardingJoinResidenceContent: View {
|
||||
Text("Join a Residence")
|
||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.a11yHeader()
|
||||
|
||||
Text("Enter the 6-character code shared with you to join an existing home.")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
@@ -137,6 +140,7 @@ struct OnboardingJoinResidenceContent: View {
|
||||
.textInputAutocapitalization(.characters)
|
||||
.autocorrectionDisabled()
|
||||
.focused($isCodeFieldFocused)
|
||||
.accessibilityHint("Enter 6-character share code")
|
||||
.onChange(of: shareCode) { _, newValue in
|
||||
// Limit to 6 characters
|
||||
if newValue.count > 6 {
|
||||
|
||||
@@ -17,6 +17,7 @@ struct OnboardingLocationContent: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
.a11yDecorative()
|
||||
|
||||
// Decorative blobs
|
||||
GeometryReader { geo in
|
||||
@@ -54,6 +55,7 @@ struct OnboardingLocationContent: View {
|
||||
.offset(x: geo.size.width * 0.65, y: geo.size.height * 0.75)
|
||||
.blur(radius: 15)
|
||||
}
|
||||
.a11yDecorative()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
@@ -125,6 +127,7 @@ struct OnboardingLocationContent: View {
|
||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
.a11yHeader()
|
||||
|
||||
Text("Enter your ZIP code so we can suggest\nmaintenance tasks for your climate region.")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
@@ -163,6 +166,7 @@ struct OnboardingLocationContent: View {
|
||||
.keyboardType(.numberPad)
|
||||
.focused($isTextFieldFocused)
|
||||
.multilineTextAlignment(.center)
|
||||
.accessibilityHint("Enter your ZIP code")
|
||||
.onChange(of: zipCode) { _, newValue in
|
||||
// Only allow digits, max 5
|
||||
let filtered = String(newValue.filter(\.isNumber).prefix(5))
|
||||
|
||||
@@ -24,6 +24,7 @@ struct OnboardingNameResidenceContent: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
.a11yDecorative()
|
||||
|
||||
// Decorative blobs
|
||||
GeometryReader { geo in
|
||||
@@ -61,6 +62,7 @@ struct OnboardingNameResidenceContent: View {
|
||||
.offset(x: geo.size.width * 0.55, y: geo.size.height * 0.6)
|
||||
.blur(radius: 20)
|
||||
}
|
||||
.a11yDecorative()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
@@ -124,6 +126,7 @@ struct OnboardingNameResidenceContent: View {
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceTitle)
|
||||
.a11yHeader()
|
||||
|
||||
Text("Don't worry, nothing's written in stone here.\nYou can always change it later in the app.")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
@@ -166,6 +169,7 @@ struct OnboardingNameResidenceContent: View {
|
||||
.focused($isTextFieldFocused)
|
||||
.submitLabel(.continue)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.residenceNameField)
|
||||
.accessibilityHint("Enter the name of your property")
|
||||
.onSubmit {
|
||||
if isValid {
|
||||
onContinue()
|
||||
|
||||
@@ -36,14 +36,22 @@ class OnboardingState: ObservableObject {
|
||||
/// The ID of the residence created during onboarding (used for task creation)
|
||||
@Published var createdResidenceId: Int32? = nil
|
||||
|
||||
/// ZIP code entered during the location step (used for residence creation and regional templates)
|
||||
@Published var pendingPostalCode: String = ""
|
||||
// MARK: - Home Profile State (collected during onboarding)
|
||||
|
||||
/// Regional task templates loaded from API based on ZIP code
|
||||
@Published var regionalTemplates: [TaskTemplate] = []
|
||||
|
||||
/// Whether regional templates are currently loading
|
||||
@Published var isLoadingTemplates: Bool = false
|
||||
@Published var pendingHeatingType: String? = nil
|
||||
@Published var pendingCoolingType: String? = nil
|
||||
@Published var pendingWaterHeaterType: String? = nil
|
||||
@Published var pendingRoofType: String? = nil
|
||||
@Published var pendingHasPool: Bool = false
|
||||
@Published var pendingHasSprinklerSystem: Bool = false
|
||||
@Published var pendingHasSeptic: Bool = false
|
||||
@Published var pendingHasFireplace: Bool = false
|
||||
@Published var pendingHasGarage: Bool = false
|
||||
@Published var pendingHasBasement: Bool = false
|
||||
@Published var pendingHasAttic: Bool = false
|
||||
@Published var pendingExteriorType: String? = nil
|
||||
@Published var pendingFlooringPrimary: String? = nil
|
||||
@Published var pendingLandscapingType: String? = nil
|
||||
|
||||
/// The user's selected intent (start fresh or join existing).
|
||||
/// Reads/writes the persisted @AppStorage value and notifies SwiftUI of the change.
|
||||
@@ -63,20 +71,6 @@ class OnboardingState: ObservableObject {
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Load regional task templates from the backend for the given ZIP code
|
||||
func loadRegionalTemplates(zip: String) {
|
||||
pendingPostalCode = zip
|
||||
isLoadingTemplates = true
|
||||
Task {
|
||||
defer { self.isLoadingTemplates = false }
|
||||
let result = try await APILayer.shared.getRegionalTemplates(state: nil, zip: zip)
|
||||
if let success = result as? ApiResultSuccess<NSArray>,
|
||||
let templates = success.data as? [TaskTemplate] {
|
||||
self.regionalTemplates = templates
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the onboarding flow
|
||||
func startOnboarding() {
|
||||
isOnboardingActive = true
|
||||
@@ -85,7 +79,7 @@ class OnboardingState: ObservableObject {
|
||||
}
|
||||
|
||||
/// Move to the next step in the flow
|
||||
/// Order: Welcome → Features → Name → Account → Verify → Location → Tasks → Upsell
|
||||
/// Order: Welcome → Features → Name → Account → Verify → Home Profile → Tasks → Upsell
|
||||
func nextStep() {
|
||||
switch currentStep {
|
||||
case .welcome:
|
||||
@@ -104,14 +98,17 @@ class OnboardingState: ObservableObject {
|
||||
if userIntent == .joinExisting {
|
||||
currentStep = .joinResidence
|
||||
} else {
|
||||
currentStep = .residenceLocation
|
||||
currentStep = .homeProfile
|
||||
}
|
||||
case .joinResidence:
|
||||
currentStep = .subscriptionUpsell
|
||||
completeOnboarding()
|
||||
case .residenceLocation:
|
||||
// Skip past this step if we somehow land here
|
||||
currentStep = .homeProfile
|
||||
case .homeProfile:
|
||||
currentStep = .firstTask
|
||||
case .firstTask:
|
||||
currentStep = .subscriptionUpsell
|
||||
completeOnboarding()
|
||||
case .subscriptionUpsell:
|
||||
completeOnboarding()
|
||||
}
|
||||
@@ -124,14 +121,18 @@ class OnboardingState: ObservableObject {
|
||||
|
||||
/// Complete the onboarding flow.
|
||||
/// Setting `hasCompletedOnboarding` also syncs with Kotlin DataManager via `didSet`.
|
||||
/// Guards against completing without authentication to prevent broken state.
|
||||
func completeOnboarding() {
|
||||
guard AuthenticationManager.shared.isAuthenticated else {
|
||||
// Don't complete onboarding without auth
|
||||
return
|
||||
}
|
||||
hasCompletedOnboarding = true
|
||||
isOnboardingActive = false
|
||||
pendingResidenceName = ""
|
||||
pendingPostalCode = ""
|
||||
regionalTemplates = []
|
||||
createdResidenceId = nil
|
||||
userIntent = .unknown
|
||||
resetHomeProfile()
|
||||
}
|
||||
|
||||
/// Reset onboarding state (useful for testing or re-onboarding).
|
||||
@@ -140,11 +141,28 @@ class OnboardingState: ObservableObject {
|
||||
hasCompletedOnboarding = false
|
||||
isOnboardingActive = false
|
||||
pendingResidenceName = ""
|
||||
pendingPostalCode = ""
|
||||
regionalTemplates = []
|
||||
createdResidenceId = nil
|
||||
userIntent = .unknown
|
||||
currentStep = .welcome
|
||||
resetHomeProfile()
|
||||
}
|
||||
|
||||
/// Reset all home profile fields
|
||||
private func resetHomeProfile() {
|
||||
pendingHeatingType = nil
|
||||
pendingCoolingType = nil
|
||||
pendingWaterHeaterType = nil
|
||||
pendingRoofType = nil
|
||||
pendingHasPool = false
|
||||
pendingHasSprinklerSystem = false
|
||||
pendingHasSeptic = false
|
||||
pendingHasFireplace = false
|
||||
pendingHasGarage = false
|
||||
pendingHasBasement = false
|
||||
pendingHasAttic = false
|
||||
pendingExteriorType = nil
|
||||
pendingFlooringPrimary = nil
|
||||
pendingLandscapingType = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,8 +175,9 @@ enum OnboardingStep: Int, CaseIterable {
|
||||
case verifyEmail = 4
|
||||
case joinResidence = 5 // Only for users joining with a code
|
||||
case residenceLocation = 6 // ZIP code entry for regional templates
|
||||
case firstTask = 7
|
||||
case subscriptionUpsell = 8
|
||||
case homeProfile = 7 // Home systems & features (optional)
|
||||
case firstTask = 8
|
||||
case subscriptionUpsell = 9
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
@@ -176,6 +195,8 @@ enum OnboardingStep: Int, CaseIterable {
|
||||
return "Join Residence"
|
||||
case .residenceLocation:
|
||||
return "Your Location"
|
||||
case .homeProfile:
|
||||
return "Home Profile"
|
||||
case .firstTask:
|
||||
return "First Task"
|
||||
case .subscriptionUpsell:
|
||||
|
||||
@@ -54,6 +54,7 @@ struct OnboardingSubscriptionContent: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
.a11yDecorative()
|
||||
|
||||
// Decorative blobs
|
||||
GeometryReader { geo in
|
||||
@@ -91,6 +92,7 @@ struct OnboardingSubscriptionContent: View {
|
||||
.offset(x: geo.size.width * 0.65, y: geo.size.height * 0.7)
|
||||
.blur(radius: 20)
|
||||
}
|
||||
.a11yDecorative()
|
||||
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: OrganicSpacing.comfortable) {
|
||||
@@ -176,6 +178,7 @@ struct OnboardingSubscriptionContent: View {
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.accessibilityLabel("Rated 4.9 stars by 10K+ homeowners")
|
||||
}
|
||||
.padding(.top, OrganicSpacing.comfortable)
|
||||
|
||||
@@ -272,6 +275,7 @@ struct OnboardingSubscriptionContent: View {
|
||||
.naturalShadow(.medium)
|
||||
}
|
||||
.disabled(isLoading)
|
||||
.a11yButton("Start free trial")
|
||||
|
||||
// Continue without
|
||||
Button(action: {
|
||||
@@ -281,6 +285,7 @@ struct OnboardingSubscriptionContent: View {
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.a11yButton("Continue with free plan")
|
||||
|
||||
// Legal text
|
||||
VStack(spacing: 4) {
|
||||
@@ -509,6 +514,8 @@ private struct OrganicPricingPlanCard: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.animation(.easeInOut(duration: 0.2), value: isSelected)
|
||||
.accessibilityLabel("\(plan.title) plan, \(displayPrice ?? plan.price)\(plan.period)\(plan.savings.map { ", \($0)" } ?? "")")
|
||||
.accessibilityValue(isSelected ? "Selected" : "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,6 +564,7 @@ private struct OrganicSubscriptionBenefitRow: View {
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.naturalShadow(.subtle)
|
||||
.a11yDecorative()
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(benefit.title)
|
||||
@@ -574,6 +582,7 @@ private struct OrganicSubscriptionBenefitRow: View {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundColor(benefit.gradient[0])
|
||||
.a11yDecorative()
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 6)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import Foundation
|
||||
import ComposeApp
|
||||
|
||||
/// Backs the First-Task onboarding screen. Owns the network calls for
|
||||
/// personalised suggestions and the full template catalog, plus the bulk
|
||||
/// task-create submission. No hardcoded suggestion rules or fallback
|
||||
/// catalog — when the API fails the screen shows error+retry+skip.
|
||||
///
|
||||
/// Mirrors the Android `OnboardingViewModel.suggestionsState` /
|
||||
/// `templatesGroupedState` / `createTasksState` flows in a purely Swift
|
||||
/// shape: calling `APILayer.shared.*` directly is more idiomatic here than
|
||||
/// observing Kotlin StateFlows, and matches the pattern used by
|
||||
/// `TaskViewModel.swift`.
|
||||
@MainActor
|
||||
final class OnboardingTasksViewModel: ObservableObject {
|
||||
|
||||
// MARK: - Suggestions (For You tab)
|
||||
@Published private(set) var suggestions: [TaskSuggestionResponse] = []
|
||||
@Published private(set) var profileCompleteness: Double = 0
|
||||
@Published private(set) var isLoadingSuggestions = false
|
||||
@Published private(set) var suggestionsError: String?
|
||||
/// True once `loadSuggestions` has returned any terminal state.
|
||||
/// Used by the view to distinguish "haven't tried yet" from "tried and
|
||||
/// returned empty".
|
||||
@Published private(set) var suggestionsAttempted = false
|
||||
|
||||
// MARK: - Grouped catalog (Browse All tab)
|
||||
@Published private(set) var grouped: TaskTemplatesGroupedResponse?
|
||||
@Published private(set) var isLoadingGrouped = false
|
||||
@Published private(set) var groupedError: String?
|
||||
|
||||
// MARK: - Submission
|
||||
@Published private(set) var isSubmitting = false
|
||||
@Published private(set) var submitError: String?
|
||||
|
||||
// MARK: - Loads
|
||||
|
||||
func loadSuggestions(residenceId: Int32) async {
|
||||
if isLoadingSuggestions { return }
|
||||
isLoadingSuggestions = true
|
||||
suggestionsError = nil
|
||||
|
||||
do {
|
||||
let result = try await APILayer.shared.getTaskSuggestions(residenceId: residenceId)
|
||||
if let success = result as? ApiResultSuccess<TaskSuggestionsResponse>,
|
||||
let data = success.data {
|
||||
suggestions = data.suggestions
|
||||
profileCompleteness = data.profileCompleteness
|
||||
AnalyticsManager.shared.track(.onboardingSuggestionsLoaded(
|
||||
count: data.suggestions.count,
|
||||
profileCompleteness: data.profileCompleteness
|
||||
))
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
suggestionsError = ErrorMessageParser.parse(error.message)
|
||||
} else {
|
||||
suggestionsError = "Could not load suggestions."
|
||||
}
|
||||
} catch {
|
||||
suggestionsError = ErrorMessageParser.parse(error.localizedDescription)
|
||||
}
|
||||
|
||||
isLoadingSuggestions = false
|
||||
suggestionsAttempted = true
|
||||
}
|
||||
|
||||
func loadGrouped(forceRefresh: Bool = false) async {
|
||||
if isLoadingGrouped { return }
|
||||
isLoadingGrouped = true
|
||||
groupedError = nil
|
||||
|
||||
do {
|
||||
let result = try await APILayer.shared.getTaskTemplatesGrouped(forceRefresh: forceRefresh)
|
||||
if let success = result as? ApiResultSuccess<TaskTemplatesGroupedResponse>,
|
||||
let data = success.data {
|
||||
grouped = data
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
groupedError = ErrorMessageParser.parse(error.message)
|
||||
} else {
|
||||
groupedError = "Could not load templates."
|
||||
}
|
||||
} catch {
|
||||
groupedError = ErrorMessageParser.parse(error.localizedDescription)
|
||||
}
|
||||
|
||||
isLoadingGrouped = false
|
||||
}
|
||||
|
||||
// MARK: - Submit
|
||||
|
||||
/// Posts the picked tasks in a single transaction via the bulk endpoint.
|
||||
/// Returns true on any successful server response (including empty
|
||||
/// selections, which short-circuit without a network call). False is
|
||||
/// terminal — the caller should show the stored `submitError`.
|
||||
func submit(residenceId: Int32, requests: [TaskCreateRequest]) async -> Bool {
|
||||
if requests.isEmpty {
|
||||
AnalyticsManager.shared.track(.onboardingTaskStepSkipped(reason: "user_skip"))
|
||||
return true
|
||||
}
|
||||
|
||||
isSubmitting = true
|
||||
submitError = nil
|
||||
|
||||
let request = BulkCreateTasksRequest(residenceId: residenceId, tasks: requests)
|
||||
defer { isSubmitting = false }
|
||||
|
||||
do {
|
||||
let result = try await APILayer.shared.bulkCreateTasks(request: request)
|
||||
if result is ApiResultSuccess<BulkCreateTasksResponse> {
|
||||
AnalyticsManager.shared.track(.onboardingTasksCreated(count: requests.count))
|
||||
return true
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
submitError = ErrorMessageParser.parse(error.message)
|
||||
return false
|
||||
} else {
|
||||
submitError = "Could not create tasks."
|
||||
return false
|
||||
}
|
||||
} catch {
|
||||
submitError = ErrorMessageParser.parse(error.localizedDescription)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ struct OnboardingValuePropsContent: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
.a11yDecorative()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Feature cards in a tab view
|
||||
@@ -105,6 +106,7 @@ struct OnboardingValuePropsContent: View {
|
||||
.naturalShadow(.medium)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.valuePropsNextButton)
|
||||
.accessibilityHint("Double tap to continue to the next step")
|
||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||
.padding(.bottom, OrganicSpacing.airy)
|
||||
}
|
||||
@@ -153,6 +155,7 @@ struct OrganicFeatureCard: View {
|
||||
.frame(width: 180, height: 180)
|
||||
.scaleEffect(appeared ? 1 : 0.8)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.a11yDecorative()
|
||||
|
||||
// Icon circle
|
||||
ZStack {
|
||||
@@ -181,6 +184,7 @@ struct OrganicFeatureCard: View {
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
.a11yHeader()
|
||||
|
||||
Text(feature.subtitle)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
|
||||
@@ -14,6 +14,7 @@ struct OnboardingVerifyEmailContent: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
.a11yDecorative()
|
||||
|
||||
// Decorative blobs
|
||||
GeometryReader { geo in
|
||||
@@ -51,6 +52,7 @@ struct OnboardingVerifyEmailContent: View {
|
||||
.offset(x: geo.size.width * 0.6, y: geo.size.height * 0.65)
|
||||
.blur(radius: 15)
|
||||
}
|
||||
.a11yDecorative()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
@@ -105,6 +107,7 @@ struct OnboardingVerifyEmailContent: View {
|
||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyEmailTitle)
|
||||
.a11yHeader()
|
||||
|
||||
Text("We sent a 6-digit code to your email address. Enter it below to verify your account.")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
@@ -133,6 +136,7 @@ struct OnboardingVerifyEmailContent: View {
|
||||
.textContentType(.oneTimeCode)
|
||||
.focused($isCodeFieldFocused)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verificationCodeField)
|
||||
.accessibilityHint("Enter 6-digit verification code")
|
||||
.keyboardDismissToolbar()
|
||||
.onChange(of: viewModel.code) { _, newValue in
|
||||
// Filter to digits only and truncate to 6 in one pass to prevent re-triggering
|
||||
|
||||
@@ -14,6 +14,7 @@ struct OnboardingWelcomeView: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
.a11yDecorative()
|
||||
|
||||
// Decorative blobs
|
||||
GeometryReader { geo in
|
||||
@@ -51,6 +52,7 @@ struct OnboardingWelcomeView: View {
|
||||
.offset(x: geo.size.width * 0.6, y: geo.size.height * 0.65)
|
||||
.blur(radius: 25)
|
||||
}
|
||||
.a11yDecorative()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
@@ -99,6 +101,7 @@ struct OnboardingWelcomeView: View {
|
||||
.font(.system(size: 32, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.welcomeTitle)
|
||||
.a11yHeader()
|
||||
|
||||
Text("Your home maintenance companion")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
@@ -136,6 +139,7 @@ struct OnboardingWelcomeView: View {
|
||||
.naturalShadow(.medium)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.startFreshButton)
|
||||
.accessibilityHint("Double tap to start setting up your property")
|
||||
|
||||
// Secondary CTA - Join Existing
|
||||
Button(action: onJoinExisting) {
|
||||
@@ -156,6 +160,7 @@ struct OnboardingWelcomeView: View {
|
||||
)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.joinExistingButton)
|
||||
.accessibilityHint("Double tap to join an existing property with a share code")
|
||||
|
||||
// Returning user login
|
||||
Button(action: {
|
||||
@@ -166,6 +171,7 @@ struct OnboardingWelcomeView: View {
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.loginButton)
|
||||
.accessibilityHint("Double tap to log in to your existing account")
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||
@@ -179,6 +185,7 @@ struct OnboardingWelcomeView: View {
|
||||
}
|
||||
.opacity(0.5)
|
||||
.padding(.bottom, 20)
|
||||
.a11yDecorative()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ struct ForgotPasswordView: View {
|
||||
Text("Forgot Password?")
|
||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.a11yHeader()
|
||||
|
||||
Text("Enter your email address and we'll send you a verification code")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
@@ -83,6 +84,7 @@ struct ForgotPasswordView: View {
|
||||
viewModel.clearError()
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.PasswordReset.emailField)
|
||||
.accessibilityHint("Enter your account email address")
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||
@@ -160,6 +162,7 @@ struct ForgotPasswordView: View {
|
||||
}
|
||||
.disabled(viewModel.email.isEmpty || viewModel.isLoading)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.PasswordReset.sendCodeButton)
|
||||
.accessibilityHint("Double tap to send a verification code to your email")
|
||||
|
||||
// Back to Login
|
||||
Button(action: { dismiss() }) {
|
||||
|
||||
@@ -68,6 +68,7 @@ struct ResetPasswordView: View {
|
||||
Text("Set New Password")
|
||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.a11yHeader()
|
||||
|
||||
Text("Create a strong password to secure your account")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
|
||||
@@ -41,6 +41,7 @@ struct VerifyResetCodeView: View {
|
||||
Text("Check Your Email")
|
||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.a11yHeader()
|
||||
|
||||
Text("We sent a 6-digit code to")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
@@ -89,6 +90,7 @@ struct VerifyResetCodeView: View {
|
||||
.focused($isCodeFocused)
|
||||
.keyboardDismissToolbar()
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.PasswordReset.codeField)
|
||||
.accessibilityHint("Enter 6-digit verification code from your email")
|
||||
.padding(20)
|
||||
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user