Add onboarding UI tests and improve app data management
- Add Suite0_OnboardingTests with fresh install and login test flows - Add accessibility identifiers to onboarding views for UI testing - Remove deprecated DataCache in favor of unified DataManager - Update API layer to support public upgrade-triggers endpoint - Improve onboarding first task view with better date handling - Update various views with accessibility identifiers for testing - Fix subscription feature comparison view layout - Update document detail view improvements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -172,6 +172,65 @@ struct AccessibilityIdentifiers {
|
||||
static let downloadButton = "DocumentDetail.DownloadButton"
|
||||
}
|
||||
|
||||
// MARK: - Onboarding
|
||||
struct Onboarding {
|
||||
// Welcome Screen
|
||||
static let welcomeTitle = "Onboarding.WelcomeTitle"
|
||||
static let startFreshButton = "Onboarding.StartFreshButton"
|
||||
static let joinExistingButton = "Onboarding.JoinExistingButton"
|
||||
static let loginButton = "Onboarding.LoginButton"
|
||||
|
||||
// Value Props Screen
|
||||
static let valuePropsTitle = "Onboarding.ValuePropsTitle"
|
||||
static let valuePropsNextButton = "Onboarding.ValuePropsNextButton"
|
||||
|
||||
// Name Residence Screen
|
||||
static let nameResidenceTitle = "Onboarding.NameResidenceTitle"
|
||||
static let residenceNameField = "Onboarding.ResidenceNameField"
|
||||
static let nameResidenceContinueButton = "Onboarding.NameResidenceContinueButton"
|
||||
|
||||
// Create Account Screen
|
||||
static let createAccountTitle = "Onboarding.CreateAccountTitle"
|
||||
static let appleSignInButton = "Onboarding.AppleSignInButton"
|
||||
static let emailSignUpExpandButton = "Onboarding.EmailSignUpExpandButton"
|
||||
static let usernameField = "Onboarding.UsernameField"
|
||||
static let emailField = "Onboarding.EmailField"
|
||||
static let passwordField = "Onboarding.PasswordField"
|
||||
static let confirmPasswordField = "Onboarding.ConfirmPasswordField"
|
||||
static let createAccountButton = "Onboarding.CreateAccountButton"
|
||||
static let loginLinkButton = "Onboarding.LoginLinkButton"
|
||||
|
||||
// Verify Email Screen
|
||||
static let verifyEmailTitle = "Onboarding.VerifyEmailTitle"
|
||||
static let verificationCodeField = "Onboarding.VerificationCodeField"
|
||||
static let verifyButton = "Onboarding.VerifyButton"
|
||||
|
||||
// Join Residence Screen
|
||||
static let joinResidenceTitle = "Onboarding.JoinResidenceTitle"
|
||||
static let shareCodeField = "Onboarding.ShareCodeField"
|
||||
static let joinResidenceButton = "Onboarding.JoinResidenceButton"
|
||||
|
||||
// First Task Screen
|
||||
static let firstTaskTitle = "Onboarding.FirstTaskTitle"
|
||||
static let taskSelectionCounter = "Onboarding.TaskSelectionCounter"
|
||||
static let addPopularTasksButton = "Onboarding.AddPopularTasksButton"
|
||||
static let addTasksContinueButton = "Onboarding.AddTasksContinueButton"
|
||||
static let taskCategorySection = "Onboarding.TaskCategorySection"
|
||||
static let taskTemplateRow = "Onboarding.TaskTemplateRow"
|
||||
|
||||
// Subscription Screen
|
||||
static let subscriptionTitle = "Onboarding.SubscriptionTitle"
|
||||
static let yearlyPlanCard = "Onboarding.YearlyPlanCard"
|
||||
static let monthlyPlanCard = "Onboarding.MonthlyPlanCard"
|
||||
static let startTrialButton = "Onboarding.StartTrialButton"
|
||||
static let continueWithFreeButton = "Onboarding.ContinueWithFreeButton"
|
||||
|
||||
// Navigation
|
||||
static let backButton = "Onboarding.BackButton"
|
||||
static let skipButton = "Onboarding.SkipButton"
|
||||
static let progressIndicator = "Onboarding.ProgressIndicator"
|
||||
}
|
||||
|
||||
// MARK: - Profile
|
||||
struct Profile {
|
||||
static let logoutButton = "Profile.LogoutButton"
|
||||
|
||||
151
iosApp/CaseraUITests/Suite0_OnboardingTests.swift
Normal file
151
iosApp/CaseraUITests/Suite0_OnboardingTests.swift
Normal file
@@ -0,0 +1,151 @@
|
||||
import XCTest
|
||||
|
||||
/// Onboarding flow tests
|
||||
///
|
||||
/// SETUP REQUIREMENTS:
|
||||
/// This test suite requires the app to be UNINSTALLED before running.
|
||||
/// Add a Pre-action script to the CaseraUITests scheme (Edit Scheme → Test → Pre-actions):
|
||||
/// /usr/bin/xcrun simctl uninstall booted com.tt.casera.CaseraDev
|
||||
/// exit 0
|
||||
///
|
||||
/// There is ONE fresh-install test that runs the complete onboarding flow.
|
||||
/// Additional tests for returning users (login screen) can run without fresh install.
|
||||
final class Suite0_OnboardingTests: XCTestCase {
|
||||
var app: XCUIApplication!
|
||||
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launch()
|
||||
sleep(2)
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
app.terminate()
|
||||
app = nil
|
||||
}
|
||||
|
||||
func test_onboarding() {
|
||||
let app = XCUIApplication()
|
||||
app.activate()
|
||||
|
||||
sleep(3)
|
||||
|
||||
let springboardApp = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
||||
springboardApp/*@START_MENU_TOKEN@*/.buttons["Allow"]/*[[".otherElements.buttons[\"Allow\"]",".buttons[\"Allow\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
sleep(1)
|
||||
|
||||
app/*@START_MENU_TOKEN@*/.buttons["Onboarding.StartFreshButton"]/*[[".buttons",".containing(.staticText, identifier: \"Start Fresh\")",".containing(.image, identifier: \"icon\")",".otherElements",".buttons[\"Start Fresh\"]",".buttons[\"Onboarding.StartFreshButton\"]"],[[[-1,5],[-1,4],[-1,3,2],[-1,0,1]],[[-1,2],[-1,1]],[[-1,5],[-1,4]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
|
||||
sleep(1)
|
||||
app.cells/*@START_MENU_TOKEN@*/.firstMatch/*[[".containing(.other, identifier: nil).firstMatch",".firstMatch"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.swipeLeft()
|
||||
|
||||
sleep(1)
|
||||
app/*@START_MENU_TOKEN@*/.staticTexts["Delayed maintenance turns $100 fixes into $10,000 disasters. Stay ahead of problems before they drain your wallet."]/*[[".otherElements.staticTexts[\"Delayed maintenance turns $100 fixes into $10,000 disasters. Stay ahead of problems before they drain your wallet.\"]",".staticTexts[\"Delayed maintenance turns $100 fixes into $10,000 disasters. Stay ahead of problems before they drain your wallet.\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.swipeLeft()
|
||||
|
||||
sleep(1)
|
||||
app/*@START_MENU_TOKEN@*/.staticTexts["Snap a photo of your receipts and warranties. When something breaks, you'll know exactly what's covered—instantly."]/*[[".otherElements.staticTexts[\"Snap a photo of your receipts and warranties. When something breaks, you'll know exactly what's covered—instantly.\"]",".staticTexts[\"Snap a photo of your receipts and warranties. When something breaks, you'll know exactly what's covered—instantly.\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.swipeLeft()
|
||||
sleep(1)
|
||||
|
||||
app/*@START_MENU_TOKEN@*/.staticTexts["I'm Ready!"]/*[[".buttons[\"I'm Ready!\"].staticTexts",".buttons.staticTexts[\"I'm Ready!\"]",".staticTexts[\"I'm Ready!\"]"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
|
||||
sleep(1)
|
||||
app/*@START_MENU_TOKEN@*/.textFields["Onboarding.ResidenceNameField"]/*[[".otherElements",".textFields[\"Xcuites\"]",".textFields[\"The Smith Residence\"]",".textFields[\"Onboarding.ResidenceNameField\"]",".textFields"],[[[-1,3],[-1,2],[-1,1],[-1,4],[-1,0,1]],[[-1,3],[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.typeText("xcuitest")
|
||||
app/*@START_MENU_TOKEN@*/.staticTexts["That's Perfect!"]/*[[".buttons[\"Onboarding.NameResidenceContinueButton\"].staticTexts",".buttons.staticTexts[\"That's Perfect!\"]",".staticTexts[\"That's Perfect!\"]"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
app/*@START_MENU_TOKEN@*/.staticTexts["Create Account with Email"]/*[[".buttons",".staticTexts",".staticTexts[\"Create Account with Email\"]"],[[[-1,2],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
|
||||
sleep(1)
|
||||
let scrollViewsQuery = app.scrollViews
|
||||
let element = scrollViewsQuery/*@START_MENU_TOKEN@*/.firstMatch/*[[".containing(.other, identifier: \"Vertical scroll bar, 2 pages\").firstMatch",".containing(.other, identifier: nil).firstMatch",".firstMatch"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/
|
||||
element.tap()
|
||||
app/*@START_MENU_TOKEN@*/.textFields["Username"]/*[[".otherElements.textFields[\"Username\"]",".textFields[\"Username\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
app/*@START_MENU_TOKEN@*/.textFields["Username"]/*[[".otherElements",".textFields[\"xcuitest\"]",".textFields[\"Username\"]"],[[[-1,2],[-1,1],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.typeText("xcuitest")
|
||||
scrollViewsQuery/*@START_MENU_TOKEN@*/.containing(.other, identifier: nil).firstMatch/*[[".element(boundBy: 0)",".containing(.other, identifier: \"Vertical scroll bar, 2 pages\").firstMatch",".containing(.other, identifier: nil).firstMatch"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.tap()
|
||||
|
||||
let element2 = app/*@START_MENU_TOKEN@*/.textFields["Email"]/*[[".otherElements.textFields[\"Email\"]",".textFields[\"Email\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch
|
||||
element2.tap()
|
||||
element2.tap()
|
||||
app/*@START_MENU_TOKEN@*/.textFields["Email"]/*[[".otherElements",".textFields[\"xcuitest@treymail.com\"]",".textFields[\"Email\"]"],[[[-1,2],[-1,1],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.typeText("xcuitest@treymail.com")
|
||||
|
||||
let element3 = app/*@START_MENU_TOKEN@*/.secureTextFields["Password"]/*[[".otherElements.secureTextFields[\"Password\"]",".secureTextFields[\"Password\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch
|
||||
element3.tap()
|
||||
element3.tap()
|
||||
app/*@START_MENU_TOKEN@*/.secureTextFields["Password"]/*[[".otherElements",".secureTextFields[\"••••••••\"]",".secureTextFields[\"Password\"]"],[[[-1,2],[-1,1],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.typeText("12345678")
|
||||
|
||||
let element4 = app/*@START_MENU_TOKEN@*/.secureTextFields["Confirm Password"]/*[[".otherElements.secureTextFields[\"Confirm Password\"]",".secureTextFields[\"Confirm Password\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch
|
||||
element4.tap()
|
||||
element4.tap()
|
||||
element4.typeText("12345678")
|
||||
element.swipeUp()
|
||||
app/*@START_MENU_TOKEN@*/.buttons["Onboarding.CreateAccountButton"]/*[[".otherElements",".buttons[\"Create Account\"]",".buttons[\"Onboarding.CreateAccountButton\"]"],[[[-1,2],[-1,1],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
app/*@START_MENU_TOKEN@*/.textFields["Onboarding.VerificationCodeField"]/*[[".otherElements",".textFields[\"Enter 6-digit code\"]",".textFields[\"Onboarding.VerificationCodeField\"]",".textFields"],[[[-1,2],[-1,1],[-1,3],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
|
||||
|
||||
sleep(1)
|
||||
let element5 = app/*@START_MENU_TOKEN@*/.textFields["Onboarding.VerificationCodeField"]/*[[".otherElements",".textFields[\"Enter 6-digit code\"]",".textFields[\"Onboarding.VerificationCodeField\"]",".textFields"],[[[-1,2],[-1,1],[-1,3],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch
|
||||
element5.tap()
|
||||
element5.tap()
|
||||
app/*@START_MENU_TOKEN@*/.textFields["Onboarding.VerificationCodeField"]/*[[".otherElements",".textFields[\"123456\"]",".textFields[\"Enter 6-digit code\"]",".textFields[\"Onboarding.VerificationCodeField\"]",".textFields"],[[[-1,3],[-1,2],[-1,1],[-1,4],[-1,0,1]],[[-1,3],[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.typeText("123456")
|
||||
sleep(1)
|
||||
|
||||
app/*@START_MENU_TOKEN@*/.images["chevron.up"]/*[[".buttons",".images[\"Go Up\"]",".images[\"chevron.up\"]"],[[[-1,2],[-1,1],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
sleep(1)
|
||||
app/*@START_MENU_TOKEN@*/.buttons["HVAC & Climate"]/*[[".buttons",".containing(.staticText, identifier: \"HVAC & Climate\")",".containing(.image, identifier: \"thermometer.medium\")",".otherElements.buttons[\"HVAC & Climate\"]",".buttons[\"HVAC & Climate\"]"],[[[-1,4],[-1,3],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
app/*@START_MENU_TOKEN@*/.staticTexts["Add Most Popular"]/*[[".buttons[\"Add Most Popular\"].staticTexts",".buttons.staticTexts[\"Add Most Popular\"]",".staticTexts[\"Add Most Popular\"]"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
app/*@START_MENU_TOKEN@*/.buttons["Add 5 Tasks & Continue"]/*[[".buttons",".containing(.image, identifier: \"arrow.right\")",".containing(.staticText, identifier: \"Add 5 Tasks & Continue\")",".otherElements.buttons[\"Add 5 Tasks & Continue\"]",".buttons[\"Add 5 Tasks & Continue\"]"],[[[-1,4],[-1,3],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
|
||||
sleep(1)
|
||||
app/*@START_MENU_TOKEN@*/.staticTexts["All your warranties, receipts, and manuals in one searchable place"]/*[[".otherElements.staticTexts[\"All your warranties, receipts, and manuals in one searchable place\"]",".staticTexts[\"All your warranties, receipts, and manuals in one searchable place\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
app/*@START_MENU_TOKEN@*/.buttons["Continue with Free"]/*[[".otherElements.buttons[\"Continue with Free\"]",".buttons[\"Continue with Free\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
|
||||
sleep(2)
|
||||
let residencesHeader = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Your Properties' OR label CONTAINS[c] 'My Properties' OR label CONTAINS[c] 'Residences'")).firstMatch
|
||||
XCTAssertTrue(residencesHeader.waitForExistence(timeout: 5), "Residences list screen must be visible")
|
||||
|
||||
let xcuitestResidence = app.staticTexts["xcuitest"].waitForExistence(timeout: 10)
|
||||
XCTAssertTrue(xcuitestResidence, "Residence should appear in list")
|
||||
|
||||
app/*@START_MENU_TOKEN@*/.images["checkmark.circle.fill"]/*[[".buttons[\"checkmark.circle.fill\"].images",".buttons",".images[\"selected\"]",".images[\"checkmark.circle.fill\"]"],[[[-1,3],[-1,2],[-1,1,1],[-1,0]],[[-1,3],[-1,2]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
|
||||
let taskOne = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", "HVAC")).firstMatch
|
||||
XCTAssertTrue(taskOne.waitForExistence(timeout: 10), "HVAC task should appear in list")
|
||||
|
||||
let taskTwo = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", "Leaks")).firstMatch
|
||||
XCTAssertTrue(taskTwo.waitForExistence(timeout: 10), "Leaks task should appear in list")
|
||||
|
||||
let taskThree = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", "Coils")).firstMatch
|
||||
XCTAssertTrue(taskThree.waitForExistence(timeout: 10), "Coils task should appear in list")
|
||||
|
||||
|
||||
// Try profile tab logout
|
||||
let profileTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Profile'")).firstMatch
|
||||
if profileTab.exists && profileTab.isHittable {
|
||||
profileTab.tap()
|
||||
|
||||
let logoutButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout' OR label CONTAINS[c] 'Log Out'")).firstMatch
|
||||
if logoutButton.waitForExistence(timeout: 3) && logoutButton.isHittable {
|
||||
logoutButton.tap()
|
||||
|
||||
// Handle confirmation alert
|
||||
let alertLogout = app.alerts.buttons["Log Out"]
|
||||
if alertLogout.waitForExistence(timeout: 2) {
|
||||
alertLogout.tap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try verification screen logout
|
||||
let verifyLogout = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout'")).firstMatch
|
||||
if verifyLogout.exists && verifyLogout.isHittable {
|
||||
verifyLogout.tap()
|
||||
}
|
||||
|
||||
// Wait for login screen
|
||||
_ = app.staticTexts["Welcome Back"].waitForExistence(timeout: 5)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ final class Suite1_RegistrationTests: XCTestCase {
|
||||
|
||||
// STRICT: Must be on login screen before each test
|
||||
XCTAssertTrue(loginScreen.waitForExistence(timeout: 10), "PRECONDITION FAILED: Must start on login screen")
|
||||
|
||||
app.swipeUp()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
@@ -78,25 +80,26 @@ final class Suite1_RegistrationTests: XCTestCase {
|
||||
/// Navigate to registration screen with strict verification
|
||||
/// Note: Registration is presented as a sheet, so login screen elements still exist underneath
|
||||
private func navigateToRegistration() {
|
||||
// PRECONDITION: Must be on login screen
|
||||
let welcomeText = app.staticTexts["Welcome Back"]
|
||||
XCTAssertTrue(welcomeText.exists, "PRECONDITION: Must be on login screen to navigate to registration")
|
||||
|
||||
let signUpButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign Up'")).firstMatch
|
||||
XCTAssertTrue(signUpButton.waitForExistence(timeout: 5), "Sign Up button must exist on login screen")
|
||||
XCTAssertTrue(signUpButton.isHittable, "Sign Up button must be tappable")
|
||||
|
||||
dismissKeyboard()
|
||||
signUpButton.tap()
|
||||
|
||||
// STRICT: Verify registration screen appeared (shown as sheet)
|
||||
// Note: Login screen still exists underneath the sheet, so we verify registration elements instead
|
||||
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
|
||||
XCTAssertTrue(usernameField.waitForExistence(timeout: 5), "Registration username field must appear")
|
||||
XCTAssertTrue(waitForElementToBeHittable(usernameField, timeout: 5), "Registration username field must be tappable")
|
||||
|
||||
// STRICT: The Sign Up button should no longer be hittable (covered by sheet)
|
||||
XCTAssertFalse(signUpButton.isHittable, "Login Sign Up button should be covered by registration sheet")
|
||||
app.swipeUp()
|
||||
// PRECONDITION: Must be on login screen
|
||||
let welcomeText = app.staticTexts["Welcome Back"]
|
||||
XCTAssertTrue(welcomeText.exists, "PRECONDITION: Must be on login screen to navigate to registration")
|
||||
|
||||
let signUpButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign Up'")).firstMatch
|
||||
XCTAssertTrue(signUpButton.waitForExistence(timeout: 5), "Sign Up button must exist on login screen")
|
||||
XCTAssertTrue(signUpButton.isHittable, "Sign Up button must be tappable")
|
||||
|
||||
dismissKeyboard()
|
||||
signUpButton.tap()
|
||||
|
||||
// STRICT: Verify registration screen appeared (shown as sheet)
|
||||
// Note: Login screen still exists underneath the sheet, so we verify registration elements instead
|
||||
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
|
||||
XCTAssertTrue(usernameField.waitForExistence(timeout: 5), "Registration username field must appear")
|
||||
XCTAssertTrue(waitForElementToBeHittable(usernameField, timeout: 5), "Registration username field must be tappable")
|
||||
|
||||
// STRICT: The Sign Up button should no longer be hittable (covered by sheet)
|
||||
XCTAssertFalse(signUpButton.isHittable, "Login Sign Up button should be covered by registration sheet")
|
||||
}
|
||||
|
||||
/// Dismisses iOS Strong Password suggestion overlay
|
||||
|
||||
@@ -172,10 +172,6 @@ final class Suite3_ResidenceTests: XCTestCase {
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
// Scroll down to see more fields
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
// Fill address fields - MUST exist for residence
|
||||
let streetField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Street'")).firstMatch
|
||||
XCTAssertTrue(streetField.exists, "Street field should exist in residence form")
|
||||
@@ -192,11 +188,15 @@ final class Suite3_ResidenceTests: XCTestCase {
|
||||
stateField.tap()
|
||||
stateField.typeText("TS")
|
||||
|
||||
let postalField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Postal' OR placeholderValue CONTAINS[c] 'Zip'")).firstMatch
|
||||
let postalField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Postal' OR placeholderValue CONTAINS[c] 'Postal'")).firstMatch
|
||||
XCTAssertTrue(postalField.exists, "Postal code field should exist in residence form")
|
||||
postalField.tap()
|
||||
postalField.typeText("12345")
|
||||
|
||||
// Scroll down to see more fields
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
// Save
|
||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
||||
XCTAssertTrue(saveButton.exists, "Save button should exist")
|
||||
|
||||
@@ -358,14 +358,10 @@ final class Suite4_ComprehensiveResidenceTests: XCTestCase {
|
||||
// Edit name
|
||||
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
|
||||
if nameField.exists {
|
||||
nameField.tap()
|
||||
// Clear existing text
|
||||
nameField.tap()
|
||||
sleep(1)
|
||||
nameField.tap()
|
||||
sleep(1)
|
||||
app.menuItems["Select All"].tap()
|
||||
sleep(1)
|
||||
let element = app/*@START_MENU_TOKEN@*/.textFields["ResidenceForm.NameField"]/*[[".otherElements",".textFields[\"Original Name 1764809003\"]",".textFields[\"Property Name\"]",".textFields[\"ResidenceForm.NameField\"]"],[[[-1,3],[-1,2],[-1,1],[-1,0,1]],[[-1,3],[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch
|
||||
element.tap()
|
||||
element.tap()
|
||||
app/*@START_MENU_TOKEN@*/.menuItems["Select All"]/*[[".menuItems.containing(.staticText, identifier: \"Select All\")",".collectionViews.menuItems[\"Select All\"]",".menuItems[\"Select All\"]"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
nameField.typeText(newName)
|
||||
|
||||
// Save
|
||||
|
||||
@@ -519,10 +519,6 @@ final class Suite7_ContractorTests: XCTestCase {
|
||||
phoneField.typeText(newPhone)
|
||||
}
|
||||
|
||||
// Scroll to more fields
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
// Update email
|
||||
let emailField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Email'")).firstMatch
|
||||
if emailField.exists {
|
||||
@@ -558,10 +554,6 @@ final class Suite7_ContractorTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to save button
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
// Save (when editing, button should say "Save")
|
||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
||||
XCTAssertTrue(saveButton.exists, "Save button should exist when editing contractor")
|
||||
|
||||
@@ -43,6 +43,13 @@ struct UITestHelpers {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if user is on verify screen after previous test
|
||||
let logoutButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout'")).firstMatch
|
||||
if logoutButton.exists {
|
||||
logoutButton.tap()
|
||||
sleep(2)
|
||||
}
|
||||
|
||||
// Verify we're back on login screen
|
||||
XCTAssertTrue(welcomeText.waitForExistence(timeout: 5), "Failed to log out - Welcome Back screen should appear after logout")
|
||||
|
||||
@@ -15,7 +15,9 @@ struct TaskWidgetProvider: TimelineProvider {
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (TaskWidgetEntry) -> ()) {
|
||||
let tasks = DataCache.shared.allTasks.value as? [CustomTask] ?? []
|
||||
// Note: Widgets run in a separate process and can't access shared app state directly.
|
||||
// TODO: Implement App Groups or shared container for widget data access.
|
||||
let tasks: [CustomTask] = []
|
||||
let entry = TaskWidgetEntry(
|
||||
date: Date(),
|
||||
tasks: Array(tasks.prefix(5))
|
||||
@@ -24,7 +26,9 @@ struct TaskWidgetProvider: TimelineProvider {
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<TaskWidgetEntry>) -> ()) {
|
||||
let tasks = DataCache.shared.allTasks.value as? [CustomTask] ?? []
|
||||
// Note: Widgets run in a separate process and can't access shared app state directly.
|
||||
// TODO: Implement App Groups or shared container for widget data access.
|
||||
let tasks: [CustomTask] = []
|
||||
let entry = TaskWidgetEntry(
|
||||
date: Date(),
|
||||
tasks: Array(tasks.prefix(5))
|
||||
|
||||
@@ -12,6 +12,7 @@ struct ContractorFormSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var viewModel = ContractorViewModel()
|
||||
@StateObject private var residenceViewModel = ResidenceViewModel()
|
||||
@ObservedObject private var dataManager = DataManagerObservable.shared
|
||||
|
||||
let contractor: Contractor?
|
||||
let onSave: () -> Void
|
||||
@@ -41,7 +42,7 @@ struct ContractorFormSheet: View {
|
||||
@FocusState private var focusedField: ContractorFormField?
|
||||
|
||||
private var specialties: [ContractorSpecialty] {
|
||||
return DataCache.shared.contractorSpecialties.value as? [ContractorSpecialty] ?? []
|
||||
return dataManager.contractorSpecialties
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
|
||||
@@ -4,6 +4,7 @@ import ComposeApp
|
||||
struct ContractorsListView: View {
|
||||
@StateObject private var viewModel = ContractorViewModel()
|
||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||
@ObservedObject private var dataManager = DataManagerObservable.shared
|
||||
@State private var searchText = ""
|
||||
@State private var showingAddSheet = false
|
||||
@State private var selectedSpecialty: String? = nil
|
||||
@@ -11,8 +12,8 @@ struct ContractorsListView: View {
|
||||
@State private var showSpecialtyFilter = false
|
||||
@State private var showingUpgradePrompt = false
|
||||
|
||||
// Lookups from DataCache
|
||||
@State private var contractorSpecialties: [ContractorSpecialty] = []
|
||||
// Lookups from DataManagerObservable
|
||||
private var contractorSpecialties: [ContractorSpecialty] { dataManager.contractorSpecialties }
|
||||
|
||||
var specialties: [String] {
|
||||
contractorSpecialties.map { $0.name }
|
||||
@@ -171,9 +172,9 @@ struct ContractorsListView: View {
|
||||
}
|
||||
.onAppear {
|
||||
loadContractors()
|
||||
loadContractorSpecialties()
|
||||
}
|
||||
// No need for onChange on searchText - filtering is client-side
|
||||
// Contractor specialties are loaded from DataManagerObservable
|
||||
}
|
||||
|
||||
private func loadContractors(forceRefresh: Bool = false) {
|
||||
@@ -181,23 +182,6 @@ struct ContractorsListView: View {
|
||||
viewModel.loadContractors(forceRefresh: forceRefresh)
|
||||
}
|
||||
|
||||
private func loadContractorSpecialties() {
|
||||
Task {
|
||||
// Small delay to ensure DataCache is populated
|
||||
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
|
||||
|
||||
await MainActor.run {
|
||||
if let specialties = DataCache.shared.contractorSpecialties.value as? [ContractorSpecialty] {
|
||||
self.contractorSpecialties = specialties
|
||||
print("✅ ContractorsList: Loaded \(specialties.count) contractor specialties")
|
||||
} else {
|
||||
print("❌ ContractorsList: Failed to load contractor specialties from DataCache")
|
||||
self.contractorSpecialties = []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleFavorite(_ id: Int32) {
|
||||
viewModel.toggleFavorite(id: id) { success in
|
||||
if success {
|
||||
|
||||
@@ -94,6 +94,10 @@ class DataManagerObservable: ObservableObject {
|
||||
await MainActor.run {
|
||||
self.authToken = token
|
||||
self.isAuthenticated = token != nil
|
||||
// Clear widget cache on logout
|
||||
if token == nil {
|
||||
WidgetDataManager.shared.clearCache()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,6 +168,10 @@ class DataManagerObservable: ObservableObject {
|
||||
for await tasks in DataManager.shared.allTasks {
|
||||
await MainActor.run {
|
||||
self.allTasks = tasks
|
||||
// Save to widget shared container
|
||||
if let tasks = tasks {
|
||||
WidgetDataManager.shared.saveTasks(from: tasks)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,26 +357,52 @@ class DataManagerObservable: ObservableObject {
|
||||
// MARK: - Map Conversion Helpers
|
||||
|
||||
/// Convert Kotlin Map<Int, V> to Swift [Int32: V]
|
||||
/// Uses ObjectIdentifier-based iteration to avoid Swift bridging issues with KotlinInt keys
|
||||
private func convertIntMap<V>(_ kotlinMap: Any?) -> [Int32: V] {
|
||||
guard let map = kotlinMap as? [KotlinInt: V] else {
|
||||
guard let kotlinMap = kotlinMap else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
var result: [Int32: V] = [:]
|
||||
for (key, value) in map {
|
||||
result[key.int32Value] = value
|
||||
|
||||
// Cast to NSDictionary to avoid Swift's strict type bridging
|
||||
// which can crash when iterating [KotlinInt: V] dictionaries
|
||||
let nsDict = kotlinMap as! NSDictionary
|
||||
|
||||
for key in nsDict.allKeys {
|
||||
guard let value = nsDict[key], let typedValue = value as? V else { continue }
|
||||
|
||||
// Extract the int value from whatever key type we have
|
||||
if let kotlinKey = key as? KotlinInt {
|
||||
result[kotlinKey.int32Value] = typedValue
|
||||
} else if let nsNumberKey = key as? NSNumber {
|
||||
result[nsNumberKey.int32Value] = typedValue
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Convert Kotlin Map<Int, List<V>> to Swift [Int32: [V]]
|
||||
private func convertIntArrayMap<V>(_ kotlinMap: Any?) -> [Int32: [V]] {
|
||||
guard let map = kotlinMap as? [KotlinInt: [V]] else {
|
||||
guard let kotlinMap = kotlinMap else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
var result: [Int32: [V]] = [:]
|
||||
for (key, value) in map {
|
||||
result[key.int32Value] = value
|
||||
|
||||
let nsDict = kotlinMap as! NSDictionary
|
||||
|
||||
for key in nsDict.allKeys {
|
||||
guard let value = nsDict[key], let typedValue = value as? [V] else { continue }
|
||||
|
||||
if let kotlinKey = key as? KotlinInt {
|
||||
result[kotlinKey.int32Value] = typedValue
|
||||
} else if let nsNumberKey = key as? NSNumber {
|
||||
result[nsNumberKey.int32Value] = typedValue
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,11 @@ struct DocumentDetailView: View {
|
||||
@State private var showImageViewer = false
|
||||
@State private var selectedImageIndex = 0
|
||||
@State private var deleteSucceeded = false
|
||||
@State private var isDownloading = false
|
||||
@State private var downloadProgress: Double = 0
|
||||
@State private var downloadError: String?
|
||||
@State private var downloadedFileURL: URL?
|
||||
@State private var showShareSheet = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -99,6 +104,87 @@ struct DocumentDetailView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
if let fileURL = downloadedFileURL {
|
||||
ShareSheet(activityItems: [fileURL])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Download File
|
||||
|
||||
private func downloadFile(document: Document) {
|
||||
guard let fileUrl = document.fileUrl else {
|
||||
downloadError = "No file URL available"
|
||||
return
|
||||
}
|
||||
|
||||
guard let token = TokenStorage.shared.getToken() else {
|
||||
downloadError = "Not authenticated"
|
||||
return
|
||||
}
|
||||
|
||||
isDownloading = true
|
||||
downloadError = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
// Build full URL
|
||||
let baseURL = ApiClient.shared.getMediaBaseUrl()
|
||||
let fullURLString = baseURL + fileUrl
|
||||
|
||||
guard let url = URL(string: fullURLString) else {
|
||||
await MainActor.run {
|
||||
downloadError = "Invalid URL"
|
||||
isDownloading = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Create authenticated request
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
// Download the file
|
||||
let (tempURL, response) = try await URLSession.shared.download(for: request)
|
||||
|
||||
// Check response status
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
guard (200...299).contains(httpResponse.statusCode) else {
|
||||
await MainActor.run {
|
||||
downloadError = "Download failed: HTTP \(httpResponse.statusCode)"
|
||||
isDownloading = false
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Determine filename
|
||||
let filename = document.title.replacingOccurrences(of: " ", with: "_") + "." + (document.fileType ?? "file")
|
||||
|
||||
// Move to a permanent location
|
||||
let documentsPath = FileManager.default.temporaryDirectory
|
||||
let destinationURL = documentsPath.appendingPathComponent(filename)
|
||||
|
||||
// Remove existing file if present
|
||||
try? FileManager.default.removeItem(at: destinationURL)
|
||||
|
||||
// Move downloaded file
|
||||
try FileManager.default.moveItem(at: tempURL, to: destinationURL)
|
||||
|
||||
await MainActor.run {
|
||||
downloadedFileURL = destinationURL
|
||||
isDownloading = false
|
||||
showShareSheet = true
|
||||
}
|
||||
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
downloadError = "Download failed: \(error.localizedDescription)"
|
||||
isDownloading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -290,18 +376,32 @@ struct DocumentDetailView: View {
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
// TODO: Download file
|
||||
downloadFile(document: document)
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
Text(L10n.Documents.downloadFile)
|
||||
if isDownloading {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.scaleEffect(0.8)
|
||||
Text("Downloading...")
|
||||
} else {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
Text(L10n.Documents.downloadFile)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.blue)
|
||||
.background(isDownloading ? Color.appPrimary.opacity(0.7) : Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.disabled(isDownloading)
|
||||
|
||||
if let error = downloadError {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appError)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
@@ -424,3 +524,19 @@ struct DocumentDetailView: View {
|
||||
return formatter.string(fromByteCount: Int64(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Share Sheet
|
||||
|
||||
struct ShareSheet: UIViewControllerRepresentable {
|
||||
let activityItems: [Any]
|
||||
var applicationActivities: [UIActivity]? = nil
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(
|
||||
activityItems: activityItems,
|
||||
applicationActivities: applicationActivities
|
||||
)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
|
||||
@@ -172,6 +172,65 @@ struct AccessibilityIdentifiers {
|
||||
static let downloadButton = "DocumentDetail.DownloadButton"
|
||||
}
|
||||
|
||||
// MARK: - Onboarding
|
||||
struct Onboarding {
|
||||
// Welcome Screen
|
||||
static let welcomeTitle = "Onboarding.WelcomeTitle"
|
||||
static let startFreshButton = "Onboarding.StartFreshButton"
|
||||
static let joinExistingButton = "Onboarding.JoinExistingButton"
|
||||
static let loginButton = "Onboarding.LoginButton"
|
||||
|
||||
// Value Props Screen
|
||||
static let valuePropsTitle = "Onboarding.ValuePropsTitle"
|
||||
static let valuePropsNextButton = "Onboarding.ValuePropsNextButton"
|
||||
|
||||
// Name Residence Screen
|
||||
static let nameResidenceTitle = "Onboarding.NameResidenceTitle"
|
||||
static let residenceNameField = "Onboarding.ResidenceNameField"
|
||||
static let nameResidenceContinueButton = "Onboarding.NameResidenceContinueButton"
|
||||
|
||||
// Create Account Screen
|
||||
static let createAccountTitle = "Onboarding.CreateAccountTitle"
|
||||
static let appleSignInButton = "Onboarding.AppleSignInButton"
|
||||
static let emailSignUpExpandButton = "Onboarding.EmailSignUpExpandButton"
|
||||
static let usernameField = "Onboarding.UsernameField"
|
||||
static let emailField = "Onboarding.EmailField"
|
||||
static let passwordField = "Onboarding.PasswordField"
|
||||
static let confirmPasswordField = "Onboarding.ConfirmPasswordField"
|
||||
static let createAccountButton = "Onboarding.CreateAccountButton"
|
||||
static let loginLinkButton = "Onboarding.LoginLinkButton"
|
||||
|
||||
// Verify Email Screen
|
||||
static let verifyEmailTitle = "Onboarding.VerifyEmailTitle"
|
||||
static let verificationCodeField = "Onboarding.VerificationCodeField"
|
||||
static let verifyButton = "Onboarding.VerifyButton"
|
||||
|
||||
// Join Residence Screen
|
||||
static let joinResidenceTitle = "Onboarding.JoinResidenceTitle"
|
||||
static let shareCodeField = "Onboarding.ShareCodeField"
|
||||
static let joinResidenceButton = "Onboarding.JoinResidenceButton"
|
||||
|
||||
// First Task Screen
|
||||
static let firstTaskTitle = "Onboarding.FirstTaskTitle"
|
||||
static let taskSelectionCounter = "Onboarding.TaskSelectionCounter"
|
||||
static let addPopularTasksButton = "Onboarding.AddPopularTasksButton"
|
||||
static let addTasksContinueButton = "Onboarding.AddTasksContinueButton"
|
||||
static let taskCategorySection = "Onboarding.TaskCategorySection"
|
||||
static let taskTemplateRow = "Onboarding.TaskTemplateRow"
|
||||
|
||||
// Subscription Screen
|
||||
static let subscriptionTitle = "Onboarding.SubscriptionTitle"
|
||||
static let yearlyPlanCard = "Onboarding.YearlyPlanCard"
|
||||
static let monthlyPlanCard = "Onboarding.MonthlyPlanCard"
|
||||
static let startTrialButton = "Onboarding.StartTrialButton"
|
||||
static let continueWithFreeButton = "Onboarding.ContinueWithFreeButton"
|
||||
|
||||
// Navigation
|
||||
static let backButton = "Onboarding.BackButton"
|
||||
static let skipButton = "Onboarding.SkipButton"
|
||||
static let progressIndicator = "Onboarding.ProgressIndicator"
|
||||
}
|
||||
|
||||
// MARK: - Profile
|
||||
struct Profile {
|
||||
static let logoutButton = "Profile.LogoutButton"
|
||||
|
||||
@@ -16890,6 +16890,9 @@
|
||||
"Done" : {
|
||||
"comment" : "A button that dismisses an image viewer sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Downloading..." : {
|
||||
|
||||
},
|
||||
"Edit" : {
|
||||
"comment" : "A label for an edit action.",
|
||||
@@ -29458,10 +29461,6 @@
|
||||
},
|
||||
"Unarchive Task" : {
|
||||
|
||||
},
|
||||
"Upgrade to Pro" : {
|
||||
"comment" : "A button label that says \"Upgrade to Pro\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Upgrade to Pro for unlimited access" : {
|
||||
"comment" : "A description of the benefit of upgrading to the Pro plan.",
|
||||
|
||||
@@ -78,19 +78,6 @@ class LoginViewModel: ObservableObject {
|
||||
_ = try? await APILayer.shared.initializeLookups()
|
||||
}
|
||||
|
||||
// Prefetch all data for caching
|
||||
Task {
|
||||
do {
|
||||
print("Starting data prefetch...")
|
||||
let prefetchManager = DataPrefetchManager.Companion().getInstance()
|
||||
_ = try await prefetchManager.prefetchAllData()
|
||||
print("Data prefetch completed successfully")
|
||||
} catch {
|
||||
print("Data prefetch failed: \(error.localizedDescription)")
|
||||
// Don't block login on prefetch failure
|
||||
}
|
||||
}
|
||||
|
||||
// Call login success callback
|
||||
self.onLoginSuccess?(self.isVerified)
|
||||
} else if let error = result as? ApiResultError {
|
||||
|
||||
@@ -92,9 +92,14 @@ struct OnboardingCoordinator: View {
|
||||
isPrimary: KotlinBoolean(bool: true)
|
||||
)
|
||||
|
||||
residenceViewModel.createResidence(request: request) { success in
|
||||
print("🏠 ONBOARDING: Residence creation result: \(success ? "SUCCESS" : "FAILED")")
|
||||
residenceViewModel.createResidence(request: request) { (residence: ResidenceResponse?) in
|
||||
self.isCreatingResidence = false
|
||||
if let residence = residence {
|
||||
print("🏠 ONBOARDING: Residence created successfully with ID: \(residence.id)")
|
||||
self.onboardingState.createdResidenceId = residence.id
|
||||
} else {
|
||||
print("🏠 ONBOARDING: Residence creation FAILED")
|
||||
}
|
||||
// Navigate regardless of success - user can create residence later if needed
|
||||
self.goForward(to: step)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ struct OnboardingCreateAccountContent: View {
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.createAccountTitle)
|
||||
|
||||
Text("Your data will be synced across devices")
|
||||
.font(.subheadline)
|
||||
@@ -121,6 +122,7 @@ struct OnboardingCreateAccountContent: View {
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.cornerRadius(AppRadius.md)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.emailSignUpExpandButton)
|
||||
} else {
|
||||
// Expanded form
|
||||
VStack(spacing: AppSpacing.md) {
|
||||
@@ -188,6 +190,7 @@ struct OnboardingCreateAccountContent: View {
|
||||
.cornerRadius(AppRadius.md)
|
||||
.shadow(color: isFormValid ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.createAccountButton)
|
||||
.disabled(!isFormValid || viewModel.isLoading)
|
||||
}
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
|
||||
@@ -7,6 +7,8 @@ struct OnboardingFirstTaskContent: View {
|
||||
var onTaskAdded: () -> Void
|
||||
|
||||
@StateObject private var viewModel = TaskViewModel()
|
||||
@ObservedObject private var dataManager = DataManagerObservable.shared
|
||||
@ObservedObject private var onboardingState = OnboardingState.shared
|
||||
@State private var selectedTasks: Set<UUID> = []
|
||||
@State private var isCreatingTasks = false
|
||||
@State private var showCustomTaskSheet = false
|
||||
@@ -318,10 +320,9 @@ struct OnboardingFirstTaskContent: View {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the first residence from cache (just created during onboarding)
|
||||
guard let residences = DataCache.shared.residences.value as? [ResidenceResponse],
|
||||
let residence = residences.first else {
|
||||
print("🏠 ONBOARDING: No residence found in cache, skipping task creation")
|
||||
// Get the residence ID from OnboardingState (set during residence creation)
|
||||
guard let residenceId = onboardingState.createdResidenceId else {
|
||||
print("🏠 ONBOARDING: No residence ID found in OnboardingState, skipping task creation")
|
||||
onTaskAdded()
|
||||
return
|
||||
}
|
||||
@@ -337,27 +338,25 @@ struct OnboardingFirstTaskContent: View {
|
||||
dateFormatter.dateFormat = "yyyy-MM-dd"
|
||||
let todayString = dateFormatter.string(from: Date())
|
||||
|
||||
print("🏠 ONBOARDING: Creating \(totalCount) tasks for residence \(residence.id)")
|
||||
print("🏠 ONBOARDING: Creating \(totalCount) tasks for residence \(residenceId)")
|
||||
|
||||
for template in selectedTemplates {
|
||||
// Look up category ID from DataCache
|
||||
// Look up category ID from DataManager
|
||||
let categoryId: Int32? = {
|
||||
guard let categories = DataCache.shared.taskCategories.value as? [ComposeApp.TaskCategory] else { return nil }
|
||||
let categoryName = template.category.lowercased()
|
||||
return categories.first { $0.name.lowercased() == categoryName }?.id
|
||||
return dataManager.taskCategories.first { $0.name.lowercased() == categoryName }?.id
|
||||
}()
|
||||
|
||||
// Look up frequency ID from DataCache
|
||||
// Look up frequency ID from DataManager
|
||||
let frequencyId: Int32? = {
|
||||
guard let frequencies = DataCache.shared.taskFrequencies.value as? [TaskFrequency] else { return nil }
|
||||
let frequencyName = template.frequency.lowercased()
|
||||
return frequencies.first { $0.name.lowercased() == frequencyName }?.id
|
||||
return dataManager.taskFrequencies.first { $0.name.lowercased() == frequencyName }?.id
|
||||
}()
|
||||
|
||||
print("🏠 ONBOARDING: Creating task '\(template.title)' - categoryId=\(String(describing: categoryId)), frequencyId=\(String(describing: frequencyId))")
|
||||
|
||||
let request = TaskCreateRequest(
|
||||
residenceId: residence.id,
|
||||
residenceId: residenceId,
|
||||
title: template.title,
|
||||
description: nil,
|
||||
categoryId: categoryId.map { KotlinInt(int: $0) },
|
||||
|
||||
@@ -68,6 +68,7 @@ struct OnboardingNameResidenceContent: View {
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceTitle)
|
||||
|
||||
Text("Don't worry, nothing's written in stone here.\nYou can always change it later in the app.")
|
||||
.font(.subheadline)
|
||||
@@ -96,6 +97,7 @@ struct OnboardingNameResidenceContent: View {
|
||||
.textInputAutocapitalization(.words)
|
||||
.focused($isTextFieldFocused)
|
||||
.submitLabel(.continue)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.residenceNameField)
|
||||
.onSubmit {
|
||||
if isValid {
|
||||
onContinue()
|
||||
@@ -182,6 +184,7 @@ struct OnboardingNameResidenceContent: View {
|
||||
.cornerRadius(AppRadius.lg)
|
||||
.shadow(color: isValid ? Color.appPrimary.opacity(0.4) : .clear, radius: 15, y: 8)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceContinueButton)
|
||||
.disabled(!isValid)
|
||||
.padding(.horizontal, AppSpacing.xl)
|
||||
.padding(.bottom, AppSpacing.xxxl)
|
||||
|
||||
@@ -18,6 +18,9 @@ class OnboardingState: ObservableObject {
|
||||
/// The name of the residence being created during onboarding
|
||||
@AppStorage("onboardingResidenceName") var pendingResidenceName: String = ""
|
||||
|
||||
/// The ID of the residence created during onboarding (used for task creation)
|
||||
@Published var createdResidenceId: Int32? = nil
|
||||
|
||||
/// The user's selected intent (start fresh or join existing) - persisted
|
||||
@AppStorage("onboardingUserIntent") private var userIntentRaw: String = OnboardingIntent.unknown.rawValue
|
||||
|
||||
@@ -86,6 +89,7 @@ class OnboardingState: ObservableObject {
|
||||
hasCompletedOnboarding = true
|
||||
isOnboardingActive = false
|
||||
pendingResidenceName = ""
|
||||
createdResidenceId = nil
|
||||
userIntent = .unknown
|
||||
}
|
||||
|
||||
@@ -94,6 +98,7 @@ class OnboardingState: ObservableObject {
|
||||
hasCompletedOnboarding = false
|
||||
isOnboardingActive = false
|
||||
pendingResidenceName = ""
|
||||
createdResidenceId = nil
|
||||
userIntent = .unknown
|
||||
currentStep = .welcome
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ struct OnboardingVerifyEmailContent: View {
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyEmailTitle)
|
||||
|
||||
Text("We sent a 6-digit code to your email address. Enter it below to verify your account.")
|
||||
.font(.subheadline)
|
||||
@@ -50,6 +51,7 @@ struct OnboardingVerifyEmailContent: View {
|
||||
.keyboardType(.numberPad)
|
||||
.textContentType(.oneTimeCode)
|
||||
.focused($isCodeFieldFocused)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verificationCodeField)
|
||||
.onChange(of: viewModel.code) { _, newValue in
|
||||
// Limit to 6 digits
|
||||
if newValue.count > 6 {
|
||||
@@ -124,6 +126,7 @@ struct OnboardingVerifyEmailContent: View {
|
||||
.cornerRadius(AppRadius.md)
|
||||
.shadow(color: viewModel.code.count == 6 ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyButton)
|
||||
.disabled(viewModel.code.count != 6 || viewModel.isLoading)
|
||||
.padding(.horizontal, AppSpacing.xl)
|
||||
.padding(.bottom, AppSpacing.xxxl)
|
||||
|
||||
@@ -28,6 +28,7 @@ struct OnboardingWelcomeView: View {
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.welcomeTitle)
|
||||
|
||||
Text("Your home maintenance companion")
|
||||
.font(.title3)
|
||||
@@ -64,6 +65,7 @@ struct OnboardingWelcomeView: View {
|
||||
.cornerRadius(AppRadius.md)
|
||||
.shadow(color: Color.appPrimary.opacity(0.3), radius: 10, y: 5)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.startFreshButton)
|
||||
|
||||
// Secondary CTA - Join Existing
|
||||
Button(action: onJoinExisting) {
|
||||
@@ -80,6 +82,7 @@ struct OnboardingWelcomeView: View {
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.cornerRadius(AppRadius.md)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.joinExistingButton)
|
||||
|
||||
// Returning user login
|
||||
Button(action: {
|
||||
@@ -89,6 +92,7 @@ struct OnboardingWelcomeView: View {
|
||||
.font(.subheadline)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.loginButton)
|
||||
.padding(.top, AppSpacing.sm)
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.xl)
|
||||
|
||||
@@ -15,13 +15,8 @@ class RegisterViewModel: ObservableObject {
|
||||
@Published var errorMessage: String?
|
||||
@Published var isRegistered: Bool = false
|
||||
|
||||
// MARK: - Private Properties
|
||||
private let tokenStorage: TokenStorageProtocol
|
||||
|
||||
// MARK: - Initialization
|
||||
init(tokenStorage: TokenStorageProtocol? = nil) {
|
||||
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
|
||||
}
|
||||
init() {}
|
||||
|
||||
// MARK: - Public Methods
|
||||
func register() {
|
||||
@@ -54,16 +49,15 @@ class RegisterViewModel: ObservableObject {
|
||||
let request = RegisterRequest(username: username, email: email, password: password, firstName: nil, lastName: nil)
|
||||
let result = try await APILayer.shared.register(request: request)
|
||||
|
||||
if let success = result as? ApiResultSuccess<AuthResponse>, let response = success.data {
|
||||
let token = response.token
|
||||
self.tokenStorage.saveToken(token: token)
|
||||
if let success = result as? ApiResultSuccess<AuthResponse>, let _ = success.data {
|
||||
// APILayer.register() now handles:
|
||||
// - Setting auth token in DataManager
|
||||
// - Storing token in TokenManager
|
||||
// - Initializing lookups
|
||||
|
||||
// Update AuthenticationManager - user is authenticated but NOT verified
|
||||
AuthenticationManager.shared.login(verified: false)
|
||||
|
||||
// Initialize lookups via APILayer after successful registration
|
||||
_ = try? await APILayer.shared.initializeLookups()
|
||||
|
||||
self.isRegistered = true
|
||||
self.isLoading = false
|
||||
} else if let error = result as? ApiResultError {
|
||||
|
||||
@@ -3,9 +3,10 @@ import ComposeApp
|
||||
|
||||
struct ResidenceDetailView: View {
|
||||
let residenceId: Int32
|
||||
|
||||
|
||||
@StateObject private var viewModel = ResidenceViewModel()
|
||||
@StateObject private var taskViewModel = TaskViewModel()
|
||||
@ObservedObject private var dataManager = DataManagerObservable.shared
|
||||
|
||||
// Use TaskViewModel's state instead of local state
|
||||
private var tasksResponse: TaskColumnsResponse? { taskViewModel.tasksResponse }
|
||||
@@ -15,7 +16,7 @@ struct ResidenceDetailView: View {
|
||||
@State private var contractors: [ContractorSummary] = []
|
||||
@State private var isLoadingContractors = false
|
||||
@State private var contractorsError: String?
|
||||
|
||||
|
||||
@State private var showAddTask = false
|
||||
@State private var showEditResidence = false
|
||||
@State private var showEditTask = false
|
||||
@@ -37,7 +38,7 @@ struct ResidenceDetailView: View {
|
||||
|
||||
// Check if current user is the owner of the residence
|
||||
private func isCurrentUserOwner(of residence: ResidenceResponse) -> Bool {
|
||||
guard let currentUser = ComposeApp.DataCache.shared.currentUser.value else {
|
||||
guard let currentUser = dataManager.currentUser else {
|
||||
return false
|
||||
}
|
||||
return Int(residence.ownerId) == Int(currentUser.id)
|
||||
|
||||
@@ -134,28 +134,52 @@ class ResidenceViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
func createResidence(request: ResidenceCreateRequest, completion: @escaping (Bool) -> Void) {
|
||||
createResidence(request: request) { result in
|
||||
completion(result != nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a residence and returns the created residence on success
|
||||
func createResidence(request: ResidenceCreateRequest, completion: @escaping (ResidenceResponse?) -> Void) {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
print("🏠 ResidenceVM: Calling API...")
|
||||
let result = try await APILayer.shared.createResidence(request: request)
|
||||
print("🏠 ResidenceVM: Got result: \(String(describing: result))")
|
||||
|
||||
if result is ApiResultSuccess<ResidenceResponse> {
|
||||
self.isLoading = false
|
||||
// DataManager is updated by APILayer (including refreshMyResidences),
|
||||
// which updates DataManagerObservable, which updates our @Published
|
||||
// myResidences via Combine subscription
|
||||
completion(true)
|
||||
} else if let error = result as? ApiResultError {
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
completion(false)
|
||||
await MainActor.run {
|
||||
if let success = result as? ApiResultSuccess<ResidenceResponse> {
|
||||
print("🏠 ResidenceVM: Is ApiResultSuccess, data = \(String(describing: success.data))")
|
||||
if let residence = success.data {
|
||||
print("🏠 ResidenceVM: Got residence with id \(residence.id)")
|
||||
self.isLoading = false
|
||||
completion(residence)
|
||||
} else {
|
||||
print("🏠 ResidenceVM: success.data is nil")
|
||||
self.isLoading = false
|
||||
completion(nil)
|
||||
}
|
||||
} else if let error = result as? ApiResultError {
|
||||
print("🏠 ResidenceVM: Is ApiResultError: \(error.message ?? "nil")")
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
completion(nil)
|
||||
} else {
|
||||
print("🏠 ResidenceVM: Unknown result type: \(type(of: result))")
|
||||
self.isLoading = false
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
self.errorMessage = error.localizedDescription
|
||||
self.isLoading = false
|
||||
completion(false)
|
||||
print("🏠 ResidenceVM: Exception: \(error)")
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.localizedDescription
|
||||
self.isLoading = false
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@ struct ResidenceFormView: View {
|
||||
@Binding var isPresented: Bool
|
||||
var onSuccess: (() -> Void)?
|
||||
@StateObject private var viewModel = ResidenceViewModel()
|
||||
@ObservedObject private var dataManager = DataManagerObservable.shared
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
// Lookups from DataCache
|
||||
@State private var residenceTypes: [ResidenceType] = []
|
||||
// Lookups from DataManagerObservable
|
||||
private var residenceTypes: [ResidenceType] { dataManager.residenceTypes }
|
||||
|
||||
// Form fields
|
||||
@State private var name: String = ""
|
||||
@@ -196,21 +197,10 @@ struct ResidenceFormView: View {
|
||||
|
||||
private func loadResidenceTypes() {
|
||||
Task {
|
||||
// Get residence types from DataCache via APILayer
|
||||
let result = try? await APILayer.shared.getResidenceTypes(forceRefresh: false)
|
||||
if let success = result as? ApiResultSuccess<NSArray>,
|
||||
let types = success.data as? [ResidenceType] {
|
||||
await MainActor.run {
|
||||
self.residenceTypes = types
|
||||
}
|
||||
} else {
|
||||
// Fallback to DataCache directly
|
||||
await MainActor.run {
|
||||
if let cached = DataCache.shared.residenceTypes.value as? [ResidenceType] {
|
||||
self.residenceTypes = cached
|
||||
}
|
||||
}
|
||||
}
|
||||
// Trigger residence types refresh if needed
|
||||
// Residence types are now loaded from DataManagerObservable
|
||||
// Just trigger a refresh if needed
|
||||
_ = try? await APILayer.shared.getResidenceTypes(forceRefresh: false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,20 +26,22 @@ class AuthenticationManager: ObservableObject {
|
||||
|
||||
isAuthenticated = true
|
||||
|
||||
// Fetch current user to check verification status
|
||||
// Fetch current user and initialize lookups immediately for all authenticated users
|
||||
Task { @MainActor in
|
||||
do {
|
||||
// Initialize lookups right away for any authenticated user
|
||||
// This fetches /static_data/ and /upgrade-triggers/ at app start
|
||||
print("🚀 Initializing lookups at app start...")
|
||||
_ = try await APILayer.shared.initializeLookups()
|
||||
print("✅ Lookups initialized on app launch")
|
||||
|
||||
let result = try await APILayer.shared.getCurrentUser(forceRefresh: true)
|
||||
|
||||
if let success = result as? ApiResultSuccess<User> {
|
||||
self.isVerified = success.data?.verified ?? false
|
||||
|
||||
// Initialize lookups if verified
|
||||
// Verify subscription entitlements with backend for verified users
|
||||
if self.isVerified {
|
||||
_ = try await APILayer.shared.initializeLookups()
|
||||
print("✅ Lookups initialized on app launch for verified user")
|
||||
|
||||
// Verify subscription entitlements with backend
|
||||
await StoreKitManager.shared.verifyEntitlementsOnLaunch()
|
||||
}
|
||||
} else if result is ApiResultError {
|
||||
@@ -68,17 +70,11 @@ class AuthenticationManager: ObservableObject {
|
||||
func markVerified() {
|
||||
isVerified = true
|
||||
|
||||
// Initialize lookups after verification
|
||||
// Lookups are already initialized at app start or during login/register
|
||||
// Just verify subscription entitlements after user becomes verified
|
||||
Task {
|
||||
do {
|
||||
_ = try await APILayer.shared.initializeLookups()
|
||||
print("✅ Lookups initialized after email verification")
|
||||
|
||||
// Verify subscription entitlements with backend
|
||||
await StoreKitManager.shared.verifyEntitlementsOnLaunch()
|
||||
} catch {
|
||||
print("❌ Failed to initialize lookups after verification: \(error)")
|
||||
}
|
||||
await StoreKitManager.shared.verifyEntitlementsOnLaunch()
|
||||
print("✅ Subscription entitlements verified after email verification")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
import StoreKit
|
||||
|
||||
struct FeatureComparisonView: View {
|
||||
@Binding var isPresented: Bool
|
||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||
|
||||
@StateObject private var storeKit = StoreKitManager.shared
|
||||
@State private var showUpgradePrompt = false
|
||||
@State private var selectedProduct: Product?
|
||||
@State private var isProcessing = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showSuccessAlert = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
@@ -70,20 +77,65 @@ struct FeatureComparisonView: View {
|
||||
.cornerRadius(AppRadius.lg)
|
||||
.padding(.horizontal)
|
||||
|
||||
// Upgrade Button
|
||||
Button(action: {
|
||||
// TODO: Trigger upgrade flow
|
||||
isPresented = false
|
||||
}) {
|
||||
Text("Upgrade to Pro")
|
||||
.fontWeight(.semibold)
|
||||
.frame(maxWidth: .infinity)
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
// Subscription Products
|
||||
if storeKit.isLoading {
|
||||
ProgressView()
|
||||
.tint(Color.appPrimary)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppRadius.md)
|
||||
} else if !storeKit.products.isEmpty {
|
||||
VStack(spacing: AppSpacing.md) {
|
||||
ForEach(storeKit.products, id: \.id) { product in
|
||||
SubscriptionButton(
|
||||
product: product,
|
||||
isSelected: selectedProduct?.id == product.id,
|
||||
isProcessing: isProcessing,
|
||||
onSelect: {
|
||||
selectedProduct = product
|
||||
handlePurchase(product)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
} else {
|
||||
// Fallback if products fail to load
|
||||
Button(action: {
|
||||
Task { await storeKit.loadProducts() }
|
||||
}) {
|
||||
Text("Retry Loading Products")
|
||||
.fontWeight(.semibold)
|
||||
.frame(maxWidth: .infinity)
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppRadius.md)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// Error Message
|
||||
if let error = errorMessage {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(Color.appError)
|
||||
Text(error)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(Color.appError)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.appError.opacity(0.1))
|
||||
.cornerRadius(AppRadius.md)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// Restore Purchases
|
||||
Button(action: {
|
||||
handleRestore()
|
||||
}) {
|
||||
Text("Restore Purchases")
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, AppSpacing.xl)
|
||||
}
|
||||
}
|
||||
@@ -96,8 +148,121 @@ struct FeatureComparisonView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Subscription Active", isPresented: $showSuccessAlert) {
|
||||
Button("Done") {
|
||||
isPresented = false
|
||||
}
|
||||
} message: {
|
||||
Text("You now have full access to all Pro features!")
|
||||
}
|
||||
.task {
|
||||
await storeKit.loadProducts()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Purchase Handling
|
||||
|
||||
private func handlePurchase(_ product: Product) {
|
||||
isProcessing = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let transaction = try await storeKit.purchase(product)
|
||||
|
||||
await MainActor.run {
|
||||
isProcessing = false
|
||||
|
||||
if transaction != nil {
|
||||
showSuccessAlert = true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isProcessing = false
|
||||
errorMessage = "Purchase failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRestore() {
|
||||
isProcessing = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
await storeKit.restorePurchases()
|
||||
|
||||
await MainActor.run {
|
||||
isProcessing = false
|
||||
|
||||
if !storeKit.purchasedProductIDs.isEmpty {
|
||||
showSuccessAlert = true
|
||||
} else {
|
||||
errorMessage = "No purchases found to restore"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subscription Button
|
||||
|
||||
struct SubscriptionButton: View {
|
||||
let product: Product
|
||||
let isSelected: Bool
|
||||
let isProcessing: Bool
|
||||
let onSelect: () -> Void
|
||||
|
||||
var isAnnual: Bool {
|
||||
product.id.contains("annual")
|
||||
}
|
||||
|
||||
var savingsText: String? {
|
||||
if isAnnual {
|
||||
return "Save 17%"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: onSelect) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(product.displayName)
|
||||
.font(.headline)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
if let savings = savingsText {
|
||||
Text(savings)
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isProcessing && isSelected {
|
||||
ProgressView()
|
||||
.tint(Color.appTextOnPrimary)
|
||||
} else {
|
||||
Text(product.displayPrice)
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(isAnnual ? Color.appPrimary : Color.appSecondary)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppRadius.md)
|
||||
.stroke(isAnnual ? Color.appAccent : Color.clear, lineWidth: 2)
|
||||
)
|
||||
}
|
||||
.disabled(isProcessing)
|
||||
}
|
||||
}
|
||||
|
||||
struct ComparisonRow: View {
|
||||
|
||||
@@ -32,6 +32,12 @@ struct SummaryCard: View {
|
||||
Divider()
|
||||
|
||||
HStack(spacing: 20) {
|
||||
SummaryStatView(
|
||||
icon: "calendar",
|
||||
value: "\(summary.totalOverdue)",
|
||||
label: "Over Due"
|
||||
)
|
||||
|
||||
SummaryStatView(
|
||||
icon: "calendar",
|
||||
value: "\(summary.tasksDueNextWeek)",
|
||||
|
||||
@@ -13,6 +13,7 @@ struct TaskFormView: View {
|
||||
let existingTask: TaskResponse? // nil for add mode, populated for edit mode
|
||||
@Binding var isPresented: Bool
|
||||
@StateObject private var viewModel = TaskViewModel()
|
||||
@ObservedObject private var dataManager = DataManagerObservable.shared
|
||||
@FocusState private var focusedField: TaskFormField?
|
||||
|
||||
private var isEditMode: Bool {
|
||||
@@ -32,12 +33,12 @@ struct TaskFormView: View {
|
||||
selectedStatus != nil
|
||||
}
|
||||
|
||||
// Lookups from DataCache
|
||||
@State private var taskCategories: [TaskCategory] = []
|
||||
@State private var taskFrequencies: [TaskFrequency] = []
|
||||
@State private var taskPriorities: [TaskPriority] = []
|
||||
@State private var taskStatuses: [TaskStatus] = []
|
||||
@State private var isLoadingLookups: Bool = true
|
||||
// Lookups from DataManagerObservable
|
||||
private var taskCategories: [TaskCategory] { dataManager.taskCategories }
|
||||
private var taskFrequencies: [TaskFrequency] { dataManager.taskFrequencies }
|
||||
private var taskPriorities: [TaskPriority] { dataManager.taskPriorities }
|
||||
private var taskStatuses: [TaskStatus] { dataManager.taskStatuses }
|
||||
private var isLoadingLookups: Bool { !dataManager.lookupsInitialized }
|
||||
|
||||
// Form fields
|
||||
@State private var selectedResidence: ResidenceResponse?
|
||||
@@ -254,8 +255,16 @@ struct TaskFormView: View {
|
||||
.disabled(!canSave || viewModel.isLoading || isLoadingLookups)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await loadLookups()
|
||||
.onAppear {
|
||||
// Set defaults when lookups are available
|
||||
if dataManager.lookupsInitialized {
|
||||
setDefaults()
|
||||
}
|
||||
}
|
||||
.onChange(of: dataManager.lookupsInitialized) { initialized in
|
||||
if initialized {
|
||||
setDefaults()
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.taskCreated) { created in
|
||||
if created {
|
||||
@@ -280,37 +289,6 @@ struct TaskFormView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func loadLookups() async {
|
||||
// Wait a bit for lookups to be initialized (they load on app launch or login)
|
||||
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
|
||||
|
||||
// Load lookups from DataCache
|
||||
await MainActor.run {
|
||||
if let categories = DataCache.shared.taskCategories.value as? [TaskCategory],
|
||||
let frequencies = DataCache.shared.taskFrequencies.value as? [TaskFrequency],
|
||||
let priorities = DataCache.shared.taskPriorities.value as? [TaskPriority],
|
||||
let statuses = DataCache.shared.taskStatuses.value as? [TaskStatus] {
|
||||
|
||||
self.taskCategories = categories
|
||||
self.taskFrequencies = frequencies
|
||||
self.taskPriorities = priorities
|
||||
self.taskStatuses = statuses
|
||||
|
||||
print("✅ TaskFormView: Loaded lookups - Categories: \(categories.count), Frequencies: \(frequencies.count), Priorities: \(priorities.count), Statuses: \(statuses.count)")
|
||||
|
||||
setDefaults()
|
||||
isLoadingLookups = false
|
||||
}
|
||||
}
|
||||
|
||||
// If lookups not loaded, retry
|
||||
if taskCategories.isEmpty {
|
||||
print("⏳ TaskFormView: Lookups not ready, retrying...")
|
||||
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
|
||||
await loadLookups()
|
||||
}
|
||||
}
|
||||
|
||||
private func setDefaults() {
|
||||
// Set default values if not already set
|
||||
if selectedCategory == nil && !taskCategories.isEmpty {
|
||||
|
||||
@@ -18,6 +18,14 @@ struct iOSApp: App {
|
||||
|
||||
// Initialize TokenStorage once at app startup (legacy support)
|
||||
TokenStorage.shared.initialize(manager: TokenManager.Companion.shared.getInstance())
|
||||
|
||||
// Initialize lookups at app start (public endpoints, no auth required)
|
||||
// This fetches /static_data/ and /upgrade-triggers/ immediately
|
||||
Task {
|
||||
print("🚀 Initializing lookups at app start...")
|
||||
_ = try? await APILayer.shared.initializeLookups()
|
||||
print("✅ Lookups initialized")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
|
||||
Reference in New Issue
Block a user