Fix TokenStorage stale cache bug and add user-friendly error messages
- Fix TokenStorage.getToken() returning stale cached token after login/logout - Add comprehensive ErrorMessageParser with 80+ error code mappings - Add Suite9 and Suite10 UI test files for E2E integration testing - Fix accessibility identifiers in RegisterView and ResidenceFormView - Fix UITestHelpers logout to target alert button specifically - Update various UI components with proper accessibility identifiers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ package com.example.casera.network
|
||||
*/
|
||||
object ApiConfig {
|
||||
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
|
||||
val CURRENT_ENV = Environment.DEV
|
||||
val CURRENT_ENV = Environment.LOCAL
|
||||
|
||||
enum class Environment {
|
||||
LOCAL,
|
||||
|
||||
@@ -33,10 +33,9 @@ object TokenStorage {
|
||||
|
||||
fun getToken(): String? {
|
||||
ensureInitialized()
|
||||
// Return cached token if available, otherwise try to load from storage
|
||||
if (cachedToken == null) {
|
||||
cachedToken = tokenManager?.getToken()
|
||||
}
|
||||
// Always read from storage to avoid stale cache issues
|
||||
// (DataManager.setAuthToken updates tokenManager directly, bypassing our cachedToken)
|
||||
cachedToken = tokenManager?.getToken()
|
||||
return cachedToken
|
||||
}
|
||||
|
||||
|
||||
684
iosApp/CaseraUITests/Suite10_ComprehensiveE2ETests.swift
Normal file
684
iosApp/CaseraUITests/Suite10_ComprehensiveE2ETests.swift
Normal file
@@ -0,0 +1,684 @@
|
||||
import XCTest
|
||||
|
||||
/// Comprehensive End-to-End Test Suite
|
||||
/// Closely mirrors TestIntegration_ComprehensiveE2E from myCribAPI-go/internal/integration/integration_test.go
|
||||
///
|
||||
/// This test creates a complete scenario:
|
||||
/// 1. Registers a new user and verifies login
|
||||
/// 2. Creates multiple residences
|
||||
/// 3. Creates multiple tasks in different states
|
||||
/// 4. Verifies task categorization in kanban columns
|
||||
/// 5. Tests task state transitions (in-progress, complete, cancel, archive)
|
||||
///
|
||||
/// IMPORTANT: These are integration tests requiring network connectivity.
|
||||
/// Run against a test/dev server, NOT production.
|
||||
final class Suite10_ComprehensiveE2ETests: XCTestCase {
|
||||
var app: XCUIApplication!
|
||||
|
||||
// Test run identifier for unique data - use static so it's shared across test methods
|
||||
private static let testRunId = Int(Date().timeIntervalSince1970)
|
||||
|
||||
// Test user credentials - unique per test run
|
||||
private var testUsername: String { "e2e_comp_\(Self.testRunId)" }
|
||||
private var testEmail: String { "e2e_comp_\(Self.testRunId)@test.com" }
|
||||
private let testPassword = "TestPass123!"
|
||||
|
||||
/// Fixed verification code used by Go API when DEBUG=true
|
||||
private let verificationCode = "123456"
|
||||
|
||||
/// Track if user has been registered for this test run
|
||||
private static var userRegistered = false
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Register user on first test, then just ensure logged in for subsequent tests
|
||||
if !Self.userRegistered {
|
||||
registerTestUser()
|
||||
Self.userRegistered = true
|
||||
} else {
|
||||
UITestHelpers.ensureLoggedIn(app: app, username: testUsername, password: testPassword)
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a new test user for this test suite
|
||||
private func registerTestUser() {
|
||||
// Check if already logged in
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
if tabBar.exists {
|
||||
return // Already logged in
|
||||
}
|
||||
|
||||
// Check if on login screen, navigate to register
|
||||
let welcomeText = app.staticTexts["Welcome Back"]
|
||||
if welcomeText.waitForExistence(timeout: 5) {
|
||||
let signUpButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign Up'")).firstMatch
|
||||
if signUpButton.exists {
|
||||
signUpButton.tap()
|
||||
sleep(2)
|
||||
}
|
||||
}
|
||||
|
||||
// Fill registration form
|
||||
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
|
||||
if usernameField.waitForExistence(timeout: 5) {
|
||||
usernameField.tap()
|
||||
usernameField.typeText(testUsername)
|
||||
|
||||
let emailField = app.textFields[AccessibilityIdentifiers.Authentication.registerEmailField]
|
||||
emailField.tap()
|
||||
emailField.typeText(testEmail)
|
||||
|
||||
let passwordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerPasswordField]
|
||||
passwordField.tap()
|
||||
dismissStrongPasswordSuggestion()
|
||||
passwordField.typeText(testPassword)
|
||||
|
||||
let confirmPasswordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField]
|
||||
confirmPasswordField.tap()
|
||||
dismissStrongPasswordSuggestion()
|
||||
confirmPasswordField.typeText(testPassword)
|
||||
|
||||
dismissKeyboard()
|
||||
sleep(1)
|
||||
|
||||
// Submit registration
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
var registerButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
|
||||
if !registerButton.exists || !registerButton.isHittable {
|
||||
registerButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Create Account' OR label CONTAINS[c] 'Register'")).firstMatch
|
||||
}
|
||||
if registerButton.exists {
|
||||
registerButton.tap()
|
||||
sleep(3)
|
||||
}
|
||||
|
||||
// Handle email verification
|
||||
let verifyEmailTitle = app.staticTexts["Verify Your Email"]
|
||||
if verifyEmailTitle.waitForExistence(timeout: 10) {
|
||||
let codeField = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField]
|
||||
if codeField.waitForExistence(timeout: 5) {
|
||||
codeField.tap()
|
||||
codeField.typeText(verificationCode)
|
||||
sleep(5)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for login to complete
|
||||
_ = tabBar.waitForExistence(timeout: 15)
|
||||
}
|
||||
}
|
||||
|
||||
/// Dismiss strong password suggestion if shown
|
||||
private func dismissStrongPasswordSuggestion() {
|
||||
let chooseOwnPassword = app.buttons["Choose My Own Password"]
|
||||
if chooseOwnPassword.waitForExistence(timeout: 1) {
|
||||
chooseOwnPassword.tap()
|
||||
return
|
||||
}
|
||||
let notNow = app.buttons["Not Now"]
|
||||
if notNow.exists && notNow.isHittable {
|
||||
notNow.tap()
|
||||
}
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
app = nil
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func navigateToTab(_ tabName: String) {
|
||||
let tab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] '\(tabName)'")).firstMatch
|
||||
if tab.waitForExistence(timeout: 5) && !tab.isSelected {
|
||||
tab.tap()
|
||||
sleep(2)
|
||||
}
|
||||
}
|
||||
|
||||
/// Dismiss keyboard by tapping outside (doesn't submit forms)
|
||||
private func dismissKeyboard() {
|
||||
// Tap on a neutral area to dismiss keyboard without submitting
|
||||
let coordinate = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1))
|
||||
coordinate.tap()
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
}
|
||||
|
||||
/// Creates a residence with the given name
|
||||
/// Returns true if successful
|
||||
@discardableResult
|
||||
private func createResidence(name: String, streetAddress: String = "123 Test St", city: String = "Austin", state: String = "TX", postalCode: String = "78701") -> Bool {
|
||||
navigateToTab("Residences")
|
||||
sleep(2)
|
||||
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton]
|
||||
guard addButton.waitForExistence(timeout: 5) else {
|
||||
XCTFail("Add residence button not found")
|
||||
return false
|
||||
}
|
||||
addButton.tap()
|
||||
sleep(2)
|
||||
|
||||
// Fill name
|
||||
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
|
||||
guard nameField.waitForExistence(timeout: 5) else {
|
||||
XCTFail("Name field not found")
|
||||
return false
|
||||
}
|
||||
nameField.tap()
|
||||
nameField.typeText(name)
|
||||
|
||||
// Fill address
|
||||
fillTextField(placeholder: "Street", text: streetAddress)
|
||||
fillTextField(placeholder: "City", text: city)
|
||||
fillTextField(placeholder: "State", text: state)
|
||||
fillTextField(placeholder: "Postal", text: postalCode)
|
||||
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
// Save
|
||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
||||
guard saveButton.exists else {
|
||||
XCTFail("Save button not found")
|
||||
return false
|
||||
}
|
||||
saveButton.tap()
|
||||
sleep(3)
|
||||
|
||||
// Verify created
|
||||
let residenceCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(name)'")).firstMatch
|
||||
return residenceCard.waitForExistence(timeout: 10)
|
||||
}
|
||||
|
||||
/// Creates a task with the given title
|
||||
/// Returns true if successful
|
||||
@discardableResult
|
||||
private func createTask(title: String, description: String? = nil) -> Bool {
|
||||
navigateToTab("Tasks")
|
||||
sleep(2)
|
||||
|
||||
let addButton = findAddTaskButton()
|
||||
guard addButton.waitForExistence(timeout: 5) && addButton.isEnabled else {
|
||||
XCTFail("Add task button not found or disabled")
|
||||
return false
|
||||
}
|
||||
addButton.tap()
|
||||
sleep(2)
|
||||
|
||||
// Fill title
|
||||
let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch
|
||||
guard titleField.waitForExistence(timeout: 5) else {
|
||||
XCTFail("Title field not found")
|
||||
return false
|
||||
}
|
||||
titleField.tap()
|
||||
titleField.typeText(title)
|
||||
|
||||
// Fill description if provided
|
||||
if let desc = description {
|
||||
let descField = app.textViews.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Description'")).firstMatch
|
||||
if descField.exists {
|
||||
descField.tap()
|
||||
descField.typeText(desc)
|
||||
}
|
||||
}
|
||||
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
// Save
|
||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
||||
guard saveButton.exists else {
|
||||
XCTFail("Save button not found")
|
||||
return false
|
||||
}
|
||||
saveButton.tap()
|
||||
sleep(3)
|
||||
|
||||
// Verify created
|
||||
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(title)'")).firstMatch
|
||||
return taskCard.waitForExistence(timeout: 10)
|
||||
}
|
||||
|
||||
private func fillTextField(placeholder: String, text: String) {
|
||||
let field = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] '\(placeholder)'")).firstMatch
|
||||
if field.exists {
|
||||
field.tap()
|
||||
field.typeText(text)
|
||||
}
|
||||
}
|
||||
|
||||
private func findAddTaskButton() -> XCUIElement {
|
||||
// Strategy 1: Accessibility identifier
|
||||
let addButtonById = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||
if addButtonById.exists && addButtonById.isEnabled {
|
||||
return addButtonById
|
||||
}
|
||||
|
||||
// Strategy 2: Navigation bar plus button
|
||||
let navBarButtons = app.navigationBars.buttons
|
||||
for i in 0..<navBarButtons.count {
|
||||
let button = navBarButtons.element(boundBy: i)
|
||||
if (button.label == "plus" || button.label.contains("Add")) && button.isEnabled {
|
||||
return button
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Empty state button
|
||||
let emptyStateButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add Task'")).firstMatch
|
||||
if emptyStateButton.exists && emptyStateButton.isEnabled {
|
||||
return emptyStateButton
|
||||
}
|
||||
|
||||
return addButtonById
|
||||
}
|
||||
|
||||
// MARK: - Test 1: Create Multiple Residences
|
||||
// Phase 2 of TestIntegration_ComprehensiveE2E
|
||||
|
||||
func test01_createMultipleResidences() {
|
||||
let residenceNames = [
|
||||
"E2E Main House \(Self.testRunId)",
|
||||
"E2E Beach House \(Self.testRunId)",
|
||||
"E2E Mountain Cabin \(Self.testRunId)"
|
||||
]
|
||||
|
||||
for (index, name) in residenceNames.enumerated() {
|
||||
let streetAddress = "\(100 * (index + 1)) Test St"
|
||||
let success = createResidence(name: name, streetAddress: streetAddress)
|
||||
XCTAssertTrue(success, "Should create residence: \(name)")
|
||||
}
|
||||
|
||||
// Verify all residences exist
|
||||
navigateToTab("Residences")
|
||||
sleep(2)
|
||||
|
||||
for name in residenceNames {
|
||||
let residenceCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(name)'")).firstMatch
|
||||
XCTAssertTrue(residenceCard.waitForExistence(timeout: 5), "Residence '\(name)' should exist in list")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 2: Create Tasks with Various States
|
||||
// Phase 3 of TestIntegration_ComprehensiveE2E
|
||||
|
||||
func test02_createTasksWithVariousStates() {
|
||||
// Ensure at least one residence exists
|
||||
navigateToTab("Residences")
|
||||
sleep(2)
|
||||
|
||||
let emptyState = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No properties'")).firstMatch
|
||||
if emptyState.exists {
|
||||
createResidence(name: "Task Test Residence \(Self.testRunId)")
|
||||
}
|
||||
|
||||
// Create tasks with different purposes
|
||||
let tasks = [
|
||||
("E2E Active Task \(Self.testRunId)", "Task that remains active"),
|
||||
("E2E Progress Task \(Self.testRunId)", "Task to mark in-progress"),
|
||||
("E2E Complete Task \(Self.testRunId)", "Task to complete"),
|
||||
("E2E Cancel Task \(Self.testRunId)", "Task to cancel")
|
||||
]
|
||||
|
||||
for (title, description) in tasks {
|
||||
let success = createTask(title: title, description: description)
|
||||
XCTAssertTrue(success, "Should create task: \(title)")
|
||||
}
|
||||
|
||||
// Verify all tasks exist
|
||||
navigateToTab("Tasks")
|
||||
sleep(2)
|
||||
|
||||
for (title, _) in tasks {
|
||||
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(title)'")).firstMatch
|
||||
XCTAssertTrue(taskCard.waitForExistence(timeout: 5), "Task '\(title)' should exist")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 3: Task State Transitions
|
||||
// Mirrors task operations from TestIntegration_TaskFlow
|
||||
|
||||
func test03_taskStateTransitions() {
|
||||
navigateToTab("Tasks")
|
||||
sleep(2)
|
||||
|
||||
// Find a task to transition (create one if needed)
|
||||
let testTaskTitle = "E2E State Test \(Self.testRunId)"
|
||||
|
||||
var taskExists = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(testTaskTitle)'")).firstMatch.exists
|
||||
if !taskExists {
|
||||
// Check if any residence exists first
|
||||
navigateToTab("Residences")
|
||||
sleep(2)
|
||||
|
||||
let emptyResidences = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No properties'")).firstMatch
|
||||
if emptyResidences.exists {
|
||||
createResidence(name: "State Test Residence \(Self.testRunId)")
|
||||
}
|
||||
|
||||
createTask(title: testTaskTitle, description: "Testing state transitions")
|
||||
navigateToTab("Tasks")
|
||||
sleep(2)
|
||||
}
|
||||
|
||||
// Find and tap the task
|
||||
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(testTaskTitle)'")).firstMatch
|
||||
if taskCard.waitForExistence(timeout: 5) {
|
||||
taskCard.tap()
|
||||
sleep(2)
|
||||
|
||||
// Try to mark in progress
|
||||
let inProgressButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'In Progress' OR label CONTAINS[c] 'Start'")).firstMatch
|
||||
if inProgressButton.exists && inProgressButton.isEnabled {
|
||||
inProgressButton.tap()
|
||||
sleep(2)
|
||||
}
|
||||
|
||||
// Try to complete
|
||||
let completeButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Complete' OR label CONTAINS[c] 'Mark Complete'")).firstMatch
|
||||
if completeButton.exists && completeButton.isEnabled {
|
||||
completeButton.tap()
|
||||
sleep(2)
|
||||
|
||||
// Handle completion form if shown
|
||||
let submitButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Submit' OR label CONTAINS[c] 'Save'")).firstMatch
|
||||
if submitButton.waitForExistence(timeout: 2) {
|
||||
submitButton.tap()
|
||||
sleep(2)
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate back
|
||||
let backButton = app.navigationBars.buttons.element(boundBy: 0)
|
||||
if backButton.exists && backButton.isHittable {
|
||||
backButton.tap()
|
||||
sleep(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 4: Task Cancel Operation
|
||||
|
||||
func test04_taskCancelOperation() {
|
||||
navigateToTab("Tasks")
|
||||
sleep(2)
|
||||
|
||||
let testTaskTitle = "E2E Cancel Test \(Self.testRunId)"
|
||||
|
||||
// Create task if doesn't exist
|
||||
if !app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(testTaskTitle)'")).firstMatch.exists {
|
||||
navigateToTab("Residences")
|
||||
sleep(1)
|
||||
|
||||
let emptyResidences = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No properties'")).firstMatch
|
||||
if emptyResidences.exists {
|
||||
createResidence(name: "Cancel Test Residence \(Self.testRunId)")
|
||||
}
|
||||
|
||||
createTask(title: testTaskTitle, description: "Task to be cancelled")
|
||||
navigateToTab("Tasks")
|
||||
sleep(2)
|
||||
}
|
||||
|
||||
// Find and tap task
|
||||
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(testTaskTitle)'")).firstMatch
|
||||
if taskCard.waitForExistence(timeout: 5) {
|
||||
taskCard.tap()
|
||||
sleep(2)
|
||||
|
||||
// Look for cancel button
|
||||
let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel Task' OR label CONTAINS[c] 'Cancel'")).firstMatch
|
||||
if cancelButton.exists && cancelButton.isEnabled {
|
||||
cancelButton.tap()
|
||||
sleep(1)
|
||||
|
||||
// Confirm cancellation if alert shown
|
||||
let confirmButton = app.alerts.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel' OR label CONTAINS[c] 'Confirm' OR label CONTAINS[c] 'Yes'")).firstMatch
|
||||
if confirmButton.exists {
|
||||
confirmButton.tap()
|
||||
sleep(2)
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate back
|
||||
let backButton = app.navigationBars.buttons.element(boundBy: 0)
|
||||
if backButton.exists && backButton.isHittable {
|
||||
backButton.tap()
|
||||
sleep(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 5: Task Archive Operation
|
||||
|
||||
func test05_taskArchiveOperation() {
|
||||
navigateToTab("Tasks")
|
||||
sleep(2)
|
||||
|
||||
let testTaskTitle = "E2E Archive Test \(Self.testRunId)"
|
||||
|
||||
// Create task if doesn't exist
|
||||
if !app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(testTaskTitle)'")).firstMatch.exists {
|
||||
navigateToTab("Residences")
|
||||
sleep(1)
|
||||
|
||||
let emptyResidences = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No properties'")).firstMatch
|
||||
if emptyResidences.exists {
|
||||
createResidence(name: "Archive Test Residence \(Self.testRunId)")
|
||||
}
|
||||
|
||||
createTask(title: testTaskTitle, description: "Task to be archived")
|
||||
navigateToTab("Tasks")
|
||||
sleep(2)
|
||||
}
|
||||
|
||||
// Find and tap task
|
||||
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(testTaskTitle)'")).firstMatch
|
||||
if taskCard.waitForExistence(timeout: 5) {
|
||||
taskCard.tap()
|
||||
sleep(2)
|
||||
|
||||
// Look for archive button
|
||||
let archiveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Archive'")).firstMatch
|
||||
if archiveButton.exists && archiveButton.isEnabled {
|
||||
archiveButton.tap()
|
||||
sleep(1)
|
||||
|
||||
// Confirm archive if alert shown
|
||||
let confirmButton = app.alerts.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Archive' OR label CONTAINS[c] 'Confirm' OR label CONTAINS[c] 'Yes'")).firstMatch
|
||||
if confirmButton.exists {
|
||||
confirmButton.tap()
|
||||
sleep(2)
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate back
|
||||
let backButton = app.navigationBars.buttons.element(boundBy: 0)
|
||||
if backButton.exists && backButton.isHittable {
|
||||
backButton.tap()
|
||||
sleep(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 6: Verify Kanban Column Structure
|
||||
// Phase 6 of TestIntegration_ComprehensiveE2E
|
||||
|
||||
func test06_verifyKanbanStructure() {
|
||||
navigateToTab("Tasks")
|
||||
sleep(3)
|
||||
|
||||
// Expected kanban column names (may vary by implementation)
|
||||
let expectedColumns = [
|
||||
"Overdue",
|
||||
"In Progress",
|
||||
"Due Soon",
|
||||
"Upcoming",
|
||||
"Completed",
|
||||
"Cancelled"
|
||||
]
|
||||
|
||||
var foundColumns: [String] = []
|
||||
|
||||
for column in expectedColumns {
|
||||
let columnHeader = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] '\(column)'")).firstMatch
|
||||
if columnHeader.exists {
|
||||
foundColumns.append(column)
|
||||
}
|
||||
}
|
||||
|
||||
// Should have at least some kanban columns OR be in list view
|
||||
let hasKanbanView = foundColumns.count >= 2
|
||||
let hasListView = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks' OR label CONTAINS[c] 'All Tasks'")).firstMatch.exists
|
||||
|
||||
XCTAssertTrue(hasKanbanView || hasListView, "Should display tasks in kanban or list view. Found columns: \(foundColumns)")
|
||||
}
|
||||
|
||||
// MARK: - Test 7: Residence Details Show Tasks
|
||||
// Verifies that residence detail screen shows associated tasks
|
||||
|
||||
func test07_residenceDetailsShowTasks() {
|
||||
navigateToTab("Residences")
|
||||
sleep(2)
|
||||
|
||||
// Find any residence
|
||||
let residenceCard = app.cells.firstMatch
|
||||
guard residenceCard.waitForExistence(timeout: 5) else {
|
||||
// No residences - create one with a task
|
||||
createResidence(name: "Detail Test Residence \(Self.testRunId)")
|
||||
createTask(title: "Detail Test Task \(Self.testRunId)")
|
||||
navigateToTab("Residences")
|
||||
sleep(2)
|
||||
|
||||
let newResidenceCard = app.cells.firstMatch
|
||||
guard newResidenceCard.waitForExistence(timeout: 5) else {
|
||||
XCTFail("Could not find any residence")
|
||||
return
|
||||
}
|
||||
newResidenceCard.tap()
|
||||
sleep(2)
|
||||
return
|
||||
}
|
||||
|
||||
residenceCard.tap()
|
||||
sleep(2)
|
||||
|
||||
// Look for tasks section in residence details
|
||||
let tasksSection = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks' OR label CONTAINS[c] 'Maintenance'")).firstMatch
|
||||
let taskCount = app.staticTexts.containing(NSPredicate(format: "label MATCHES '\\\\d+ tasks?' OR label MATCHES '\\\\d+ Tasks?'")).firstMatch
|
||||
|
||||
// Either tasks section header or task count should be visible
|
||||
let hasTasksInfo = tasksSection.exists || taskCount.exists
|
||||
|
||||
// Navigate back
|
||||
let backButton = app.navigationBars.buttons.element(boundBy: 0)
|
||||
if backButton.exists && backButton.isHittable {
|
||||
backButton.tap()
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
// Note: Not asserting because task section visibility depends on UI design
|
||||
}
|
||||
|
||||
// MARK: - Test 8: Contractor CRUD (Mirrors backend contractor tests)
|
||||
|
||||
func test08_contractorCRUD() {
|
||||
navigateToTab("Contractors")
|
||||
sleep(2)
|
||||
|
||||
let contractorName = "E2E Test Contractor \(Self.testRunId)"
|
||||
|
||||
// Check if Contractors tab exists
|
||||
let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
|
||||
guard contractorsTab.exists else {
|
||||
// Contractors may not be a main tab - skip this test
|
||||
return
|
||||
}
|
||||
|
||||
// Try to add contractor
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Contractor.addButton]
|
||||
guard addButton.waitForExistence(timeout: 5) else {
|
||||
// May need residence first
|
||||
return
|
||||
}
|
||||
|
||||
addButton.tap()
|
||||
sleep(2)
|
||||
|
||||
// Fill contractor form
|
||||
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
|
||||
if nameField.exists {
|
||||
nameField.tap()
|
||||
nameField.typeText(contractorName)
|
||||
|
||||
let companyField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Company'")).firstMatch
|
||||
if companyField.exists {
|
||||
companyField.tap()
|
||||
companyField.typeText("Test Company Inc")
|
||||
}
|
||||
|
||||
let phoneField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Phone'")).firstMatch
|
||||
if phoneField.exists {
|
||||
phoneField.tap()
|
||||
phoneField.typeText("555-123-4567")
|
||||
}
|
||||
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
||||
if saveButton.exists {
|
||||
saveButton.tap()
|
||||
sleep(3)
|
||||
|
||||
// Verify contractor was created
|
||||
let contractorCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(contractorName)'")).firstMatch
|
||||
XCTAssertTrue(contractorCard.waitForExistence(timeout: 10), "Contractor '\(contractorName)' should be created")
|
||||
}
|
||||
} else {
|
||||
// Cancel if form didn't load properly
|
||||
let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch
|
||||
if cancelButton.exists {
|
||||
cancelButton.tap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 9: Full Flow Summary
|
||||
|
||||
func test09_fullFlowSummary() {
|
||||
// This test verifies the overall app state after running previous tests
|
||||
|
||||
// Check Residences tab
|
||||
navigateToTab("Residences")
|
||||
sleep(2)
|
||||
|
||||
let residencesList = app.cells
|
||||
let residenceCount = residencesList.count
|
||||
|
||||
// Check Tasks tab
|
||||
navigateToTab("Tasks")
|
||||
sleep(2)
|
||||
|
||||
let tasksScreen = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
||||
XCTAssertTrue(tasksScreen.exists, "Tasks screen should be accessible")
|
||||
|
||||
// Check Profile tab
|
||||
navigateToTab("Profile")
|
||||
sleep(2)
|
||||
|
||||
let logoutButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout' OR label CONTAINS[c] 'Log Out'")).firstMatch
|
||||
XCTAssertTrue(logoutButton.exists, "User should be logged in with logout option available")
|
||||
|
||||
print("=== E2E Test Summary ===")
|
||||
print("Residences found: \(residenceCount)")
|
||||
print("Tasks screen accessible: true")
|
||||
print("User logged in: true")
|
||||
print("========================")
|
||||
}
|
||||
}
|
||||
526
iosApp/CaseraUITests/Suite9_IntegrationE2ETests.swift
Normal file
526
iosApp/CaseraUITests/Suite9_IntegrationE2ETests.swift
Normal file
@@ -0,0 +1,526 @@
|
||||
import XCTest
|
||||
|
||||
/// Comprehensive End-to-End Integration Tests
|
||||
/// Mirrors the backend integration tests in myCribAPI-go/internal/integration/integration_test.go
|
||||
///
|
||||
/// This test suite covers:
|
||||
/// 1. Full authentication flow (register, login, logout)
|
||||
/// 2. Residence CRUD operations
|
||||
/// 3. Task lifecycle (create, update, mark-in-progress, complete, archive, cancel)
|
||||
/// 4. Residence sharing between users
|
||||
/// 5. Cross-user access control
|
||||
///
|
||||
/// IMPORTANT: These tests create real data and require network connectivity.
|
||||
/// Run with a test server or dev environment (not production).
|
||||
final class Suite9_IntegrationE2ETests: XCTestCase {
|
||||
var app: XCUIApplication!
|
||||
|
||||
// Test user credentials - unique per test run
|
||||
private let timestamp = Int(Date().timeIntervalSince1970)
|
||||
|
||||
private var userAUsername: String { "e2e_usera_\(timestamp)" }
|
||||
private var userAEmail: String { "e2e_usera_\(timestamp)@test.com" }
|
||||
private var userAPassword: String { "TestPass123!" }
|
||||
|
||||
private var userBUsername: String { "e2e_userb_\(timestamp)" }
|
||||
private var userBEmail: String { "e2e_userb_\(timestamp)@test.com" }
|
||||
private var userBPassword: String { "TestPass456!" }
|
||||
|
||||
/// Fixed verification code used by Go API when DEBUG=true
|
||||
private let verificationCode = "123456"
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launch()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
app = nil
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func ensureLoggedOut() {
|
||||
UITestHelpers.ensureLoggedOut(app: app)
|
||||
}
|
||||
|
||||
private func login(username: String, password: String) {
|
||||
UITestHelpers.login(app: app, username: username, password: password)
|
||||
}
|
||||
|
||||
/// Navigate to a specific tab
|
||||
private func navigateToTab(_ tabName: String) {
|
||||
let tab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] '\(tabName)'")).firstMatch
|
||||
if tab.waitForExistence(timeout: 5) && !tab.isSelected {
|
||||
tab.tap()
|
||||
sleep(2)
|
||||
}
|
||||
}
|
||||
|
||||
/// Dismiss keyboard by tapping outside (doesn't submit forms)
|
||||
private func dismissKeyboard() {
|
||||
let coordinate = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1))
|
||||
coordinate.tap()
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
}
|
||||
|
||||
/// Dismiss strong password suggestion if shown
|
||||
private func dismissStrongPasswordSuggestion() {
|
||||
let chooseOwnPassword = app.buttons["Choose My Own Password"]
|
||||
if chooseOwnPassword.waitForExistence(timeout: 1) {
|
||||
chooseOwnPassword.tap()
|
||||
return
|
||||
}
|
||||
let notNow = app.buttons["Not Now"]
|
||||
if notNow.exists && notNow.isHittable {
|
||||
notNow.tap()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 1: Complete Authentication Flow
|
||||
// Mirrors TestIntegration_AuthenticationFlow
|
||||
|
||||
func test01_authenticationFlow() {
|
||||
// Phase 1: Start on login screen
|
||||
let welcomeText = app.staticTexts["Welcome Back"]
|
||||
if !welcomeText.waitForExistence(timeout: 5) {
|
||||
ensureLoggedOut()
|
||||
}
|
||||
XCTAssertTrue(welcomeText.waitForExistence(timeout: 10), "Should start on login screen")
|
||||
|
||||
// Phase 2: Navigate to registration
|
||||
let signUpButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign Up'")).firstMatch
|
||||
XCTAssertTrue(signUpButton.waitForExistence(timeout: 5), "Sign Up button should exist")
|
||||
signUpButton.tap()
|
||||
sleep(2)
|
||||
|
||||
// Phase 3: Fill registration form using proper accessibility identifiers
|
||||
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
|
||||
XCTAssertTrue(usernameField.waitForExistence(timeout: 5), "Username field should exist")
|
||||
usernameField.tap()
|
||||
usernameField.typeText(userAUsername)
|
||||
|
||||
let emailField = app.textFields[AccessibilityIdentifiers.Authentication.registerEmailField]
|
||||
XCTAssertTrue(emailField.waitForExistence(timeout: 3), "Email field should exist")
|
||||
emailField.tap()
|
||||
emailField.typeText(userAEmail)
|
||||
|
||||
// Password field - check both SecureField and TextField
|
||||
var passwordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerPasswordField]
|
||||
if !passwordField.exists {
|
||||
passwordField = app.textFields[AccessibilityIdentifiers.Authentication.registerPasswordField]
|
||||
}
|
||||
XCTAssertTrue(passwordField.waitForExistence(timeout: 3), "Password field should exist")
|
||||
passwordField.tap()
|
||||
dismissStrongPasswordSuggestion()
|
||||
passwordField.typeText(userAPassword)
|
||||
|
||||
// Confirm password field
|
||||
var confirmPasswordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField]
|
||||
if !confirmPasswordField.exists {
|
||||
confirmPasswordField = app.textFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField]
|
||||
}
|
||||
XCTAssertTrue(confirmPasswordField.waitForExistence(timeout: 3), "Confirm password field should exist")
|
||||
confirmPasswordField.tap()
|
||||
dismissStrongPasswordSuggestion()
|
||||
confirmPasswordField.typeText(userAPassword)
|
||||
|
||||
dismissKeyboard()
|
||||
sleep(1)
|
||||
|
||||
// Phase 4: Submit registration
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
let registerButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
|
||||
XCTAssertTrue(registerButton.waitForExistence(timeout: 5), "Register button should exist")
|
||||
registerButton.tap()
|
||||
sleep(3)
|
||||
|
||||
// Phase 5: Handle email verification
|
||||
let verifyEmailTitle = app.staticTexts["Verify Your Email"]
|
||||
XCTAssertTrue(verifyEmailTitle.waitForExistence(timeout: 10), "Verification screen must appear after registration")
|
||||
|
||||
sleep(3)
|
||||
|
||||
// Enter verification code - auto-submits when 6 digits entered
|
||||
let codeField = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField]
|
||||
XCTAssertTrue(codeField.waitForExistence(timeout: 5), "Verification code field must exist")
|
||||
codeField.tap()
|
||||
codeField.typeText(verificationCode)
|
||||
sleep(5)
|
||||
|
||||
// Phase 6: Verify logged in
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
XCTAssertTrue(tabBar.waitForExistence(timeout: 15), "Should be logged in after registration")
|
||||
|
||||
// Phase 7: Logout
|
||||
UITestHelpers.logout(app: app)
|
||||
|
||||
// Phase 8: Login with created credentials
|
||||
XCTAssertTrue(welcomeText.waitForExistence(timeout: 10), "Should be on login screen after logout")
|
||||
login(username: userAUsername, password: userAPassword)
|
||||
|
||||
// Phase 9: Verify logged in
|
||||
XCTAssertTrue(tabBar.waitForExistence(timeout: 10), "Should be logged in after login")
|
||||
|
||||
// Phase 10: Final logout
|
||||
UITestHelpers.logout(app: app)
|
||||
XCTAssertTrue(welcomeText.waitForExistence(timeout: 10), "Should be logged out")
|
||||
}
|
||||
|
||||
// MARK: - Test 2: Residence CRUD Flow
|
||||
// Mirrors TestIntegration_ResidenceFlow
|
||||
|
||||
func test02_residenceCRUDFlow() {
|
||||
// Ensure logged in as test user
|
||||
UITestHelpers.ensureLoggedIn(app: app, username: userAUsername, password: userAPassword)
|
||||
navigateToTab("Residences")
|
||||
sleep(2)
|
||||
|
||||
let residenceName = "E2E Test Home \(timestamp)"
|
||||
|
||||
// Phase 1: Create residence
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton]
|
||||
XCTAssertTrue(addButton.waitForExistence(timeout: 5), "Add residence button should exist")
|
||||
addButton.tap()
|
||||
sleep(2)
|
||||
|
||||
// Fill form - just tap and type, don't dismiss keyboard between fields
|
||||
let nameField = app.textFields[AccessibilityIdentifiers.Residence.nameField]
|
||||
XCTAssertTrue(nameField.waitForExistence(timeout: 5), "Name field should exist")
|
||||
nameField.tap()
|
||||
sleep(1)
|
||||
nameField.typeText(residenceName)
|
||||
|
||||
// Use return key to move to next field or dismiss, then scroll
|
||||
app.keyboards.buttons["return"].tap()
|
||||
sleep(1)
|
||||
|
||||
// Scroll to show more fields
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
// Fill street field
|
||||
let streetField = app.textFields[AccessibilityIdentifiers.Residence.streetAddressField]
|
||||
if streetField.waitForExistence(timeout: 3) && streetField.isHittable {
|
||||
streetField.tap()
|
||||
sleep(1)
|
||||
streetField.typeText("123 E2E Test St")
|
||||
app.keyboards.buttons["return"].tap()
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
// Fill city field
|
||||
let cityField = app.textFields[AccessibilityIdentifiers.Residence.cityField]
|
||||
if cityField.waitForExistence(timeout: 3) && cityField.isHittable {
|
||||
cityField.tap()
|
||||
sleep(1)
|
||||
cityField.typeText("Austin")
|
||||
app.keyboards.buttons["return"].tap()
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
// Fill state field
|
||||
let stateField = app.textFields[AccessibilityIdentifiers.Residence.stateProvinceField]
|
||||
if stateField.waitForExistence(timeout: 3) && stateField.isHittable {
|
||||
stateField.tap()
|
||||
sleep(1)
|
||||
stateField.typeText("TX")
|
||||
app.keyboards.buttons["return"].tap()
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
// Fill postal code field
|
||||
let postalField = app.textFields[AccessibilityIdentifiers.Residence.postalCodeField]
|
||||
if postalField.waitForExistence(timeout: 3) && postalField.isHittable {
|
||||
postalField.tap()
|
||||
sleep(1)
|
||||
postalField.typeText("78701")
|
||||
}
|
||||
|
||||
// Dismiss keyboard and scroll to save button
|
||||
dismissKeyboard()
|
||||
sleep(1)
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
// Save the residence
|
||||
let saveButton = app.buttons[AccessibilityIdentifiers.Residence.saveButton]
|
||||
if saveButton.waitForExistence(timeout: 5) && saveButton.isHittable {
|
||||
saveButton.tap()
|
||||
} else {
|
||||
// Try finding by label as fallback
|
||||
let saveByLabel = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
||||
XCTAssertTrue(saveByLabel.waitForExistence(timeout: 5), "Save button should exist")
|
||||
saveByLabel.tap()
|
||||
}
|
||||
sleep(3)
|
||||
|
||||
// Phase 2: Verify residence was created
|
||||
navigateToTab("Residences")
|
||||
sleep(2)
|
||||
let residenceCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(residenceName)'")).firstMatch
|
||||
XCTAssertTrue(residenceCard.waitForExistence(timeout: 10), "Residence '\(residenceName)' should appear in list")
|
||||
}
|
||||
|
||||
// MARK: - Test 3: Task Lifecycle Flow
|
||||
// Mirrors TestIntegration_TaskFlow
|
||||
|
||||
func test03_taskLifecycleFlow() {
|
||||
// Ensure logged in
|
||||
UITestHelpers.ensureLoggedIn(app: app, username: userAUsername, password: userAPassword)
|
||||
|
||||
// Ensure residence exists first - create one if empty
|
||||
navigateToTab("Residences")
|
||||
sleep(2)
|
||||
|
||||
let residenceCards = app.cells
|
||||
if residenceCards.count == 0 {
|
||||
// No residences, create one first
|
||||
createMinimalResidence(name: "Task Test Home \(timestamp)")
|
||||
sleep(2)
|
||||
}
|
||||
|
||||
// Navigate to Tasks
|
||||
navigateToTab("Tasks")
|
||||
sleep(3)
|
||||
|
||||
let taskTitle = "E2E Task Lifecycle \(timestamp)"
|
||||
|
||||
// Phase 1: Create task - use firstMatch to avoid multiple element issue
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||
guard addButton.waitForExistence(timeout: 5) else {
|
||||
XCTFail("Add task button should exist")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if button is enabled
|
||||
guard addButton.isEnabled else {
|
||||
XCTFail("Add task button should be enabled (requires at least one residence)")
|
||||
return
|
||||
}
|
||||
|
||||
addButton.tap()
|
||||
sleep(2)
|
||||
|
||||
// Fill task form
|
||||
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch
|
||||
XCTAssertTrue(titleField.waitForExistence(timeout: 5), "Task title field should exist")
|
||||
titleField.tap()
|
||||
sleep(1)
|
||||
titleField.typeText(taskTitle)
|
||||
|
||||
dismissKeyboard()
|
||||
sleep(1)
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
// Save the task
|
||||
let saveTaskButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch
|
||||
if saveTaskButton.waitForExistence(timeout: 5) && saveTaskButton.isHittable {
|
||||
saveTaskButton.tap()
|
||||
} else {
|
||||
let saveByLabel = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save' OR label CONTAINS[c] 'Add Task' OR label CONTAINS[c] 'Create'")).firstMatch
|
||||
XCTAssertTrue(saveByLabel.exists, "Save/Create button should exist")
|
||||
saveByLabel.tap()
|
||||
}
|
||||
sleep(3)
|
||||
|
||||
// Phase 2: Verify task was created
|
||||
navigateToTab("Tasks")
|
||||
sleep(2)
|
||||
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(taskTitle)'")).firstMatch
|
||||
XCTAssertTrue(taskCard.waitForExistence(timeout: 10), "Task '\(taskTitle)' should appear in task list")
|
||||
}
|
||||
|
||||
// MARK: - Test 4: Kanban Column Distribution
|
||||
// Mirrors TestIntegration_TasksByResidenceKanban
|
||||
|
||||
func test04_kanbanColumnDistribution() {
|
||||
UITestHelpers.ensureLoggedIn(app: app, username: userAUsername, password: userAPassword)
|
||||
navigateToTab("Tasks")
|
||||
sleep(3)
|
||||
|
||||
// Verify tasks screen is showing
|
||||
let tasksTitle = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
||||
let kanbanExists = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Overdue' OR label CONTAINS[c] 'Upcoming' OR label CONTAINS[c] 'In Progress'")).firstMatch.exists
|
||||
|
||||
XCTAssertTrue(kanbanExists || tasksTitle.exists, "Tasks screen should be visible")
|
||||
}
|
||||
|
||||
// MARK: - Test 5: Cross-User Access Control
|
||||
// Mirrors TestIntegration_CrossUserAccessDenied
|
||||
|
||||
func test05_crossUserAccessControl() {
|
||||
UITestHelpers.ensureLoggedIn(app: app, username: userAUsername, password: userAPassword)
|
||||
|
||||
// Verify user can access their residences tab
|
||||
navigateToTab("Residences")
|
||||
sleep(2)
|
||||
|
||||
let residencesVisible = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch.isSelected
|
||||
XCTAssertTrue(residencesVisible, "User should be able to access Residences tab")
|
||||
|
||||
// Verify user can access their tasks tab
|
||||
navigateToTab("Tasks")
|
||||
sleep(2)
|
||||
|
||||
let tasksAccessible = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch.isSelected
|
||||
XCTAssertTrue(tasksAccessible, "User should be able to access Tasks tab")
|
||||
}
|
||||
|
||||
// MARK: - Test 6: Lookup Data Endpoints
|
||||
// Mirrors TestIntegration_LookupEndpoints
|
||||
|
||||
func test06_lookupDataAvailable() {
|
||||
UITestHelpers.ensureLoggedIn(app: app, username: userAUsername, password: userAPassword)
|
||||
|
||||
// Navigate to add residence to check residence types are loaded
|
||||
navigateToTab("Residences")
|
||||
sleep(2)
|
||||
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton]
|
||||
if addButton.waitForExistence(timeout: 5) {
|
||||
addButton.tap()
|
||||
sleep(2)
|
||||
|
||||
// Check property type picker exists (indicates lookups loaded)
|
||||
let propertyTypePicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Property Type' OR label CONTAINS[c] 'Type'")).firstMatch
|
||||
let pickerExists = propertyTypePicker.exists
|
||||
|
||||
// Cancel form
|
||||
let cancelButton = app.buttons[AccessibilityIdentifiers.Residence.formCancelButton]
|
||||
if cancelButton.exists {
|
||||
cancelButton.tap()
|
||||
} else {
|
||||
let cancelByLabel = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch
|
||||
if cancelByLabel.exists {
|
||||
cancelByLabel.tap()
|
||||
}
|
||||
}
|
||||
|
||||
XCTAssertTrue(pickerExists, "Property type picker should exist (lookups loaded)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 7: Residence Sharing Flow
|
||||
// Mirrors TestIntegration_ResidenceSharingFlow
|
||||
|
||||
func test07_residenceSharingUIElements() {
|
||||
UITestHelpers.ensureLoggedIn(app: app, username: userAUsername, password: userAPassword)
|
||||
navigateToTab("Residences")
|
||||
sleep(2)
|
||||
|
||||
// Find any residence to check sharing UI
|
||||
let residenceCard = app.cells.firstMatch
|
||||
if residenceCard.waitForExistence(timeout: 5) {
|
||||
residenceCard.tap()
|
||||
sleep(2)
|
||||
|
||||
// Look for share button in residence details
|
||||
let shareButton = app.buttons[AccessibilityIdentifiers.Residence.shareButton]
|
||||
let manageUsersButton = app.buttons[AccessibilityIdentifiers.Residence.manageUsersButton]
|
||||
|
||||
// Note: Share functionality may not be visible depending on user permissions
|
||||
// This test just verifies we can navigate to residence details
|
||||
|
||||
// Navigate back
|
||||
let backButton = app.navigationBars.buttons.element(boundBy: 0)
|
||||
if backButton.exists && backButton.isHittable {
|
||||
backButton.tap()
|
||||
sleep(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper: Create Minimal Residence
|
||||
|
||||
private func createMinimalResidence(name: String) {
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton]
|
||||
guard addButton.waitForExistence(timeout: 5) else { return }
|
||||
|
||||
addButton.tap()
|
||||
sleep(2)
|
||||
|
||||
// Fill name field
|
||||
let nameField = app.textFields[AccessibilityIdentifiers.Residence.nameField]
|
||||
if nameField.waitForExistence(timeout: 5) {
|
||||
nameField.tap()
|
||||
sleep(1)
|
||||
nameField.typeText(name)
|
||||
app.keyboards.buttons["return"].tap()
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
// Scroll to show address fields
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
// Fill street field
|
||||
let streetField = app.textFields[AccessibilityIdentifiers.Residence.streetAddressField]
|
||||
if streetField.waitForExistence(timeout: 3) && streetField.isHittable {
|
||||
streetField.tap()
|
||||
sleep(1)
|
||||
streetField.typeText("123 Test St")
|
||||
app.keyboards.buttons["return"].tap()
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
// Fill city field
|
||||
let cityField = app.textFields[AccessibilityIdentifiers.Residence.cityField]
|
||||
if cityField.waitForExistence(timeout: 3) && cityField.isHittable {
|
||||
cityField.tap()
|
||||
sleep(1)
|
||||
cityField.typeText("Austin")
|
||||
app.keyboards.buttons["return"].tap()
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
// Fill state field
|
||||
let stateField = app.textFields[AccessibilityIdentifiers.Residence.stateProvinceField]
|
||||
if stateField.waitForExistence(timeout: 3) && stateField.isHittable {
|
||||
stateField.tap()
|
||||
sleep(1)
|
||||
stateField.typeText("TX")
|
||||
app.keyboards.buttons["return"].tap()
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
// Fill postal code field
|
||||
let postalField = app.textFields[AccessibilityIdentifiers.Residence.postalCodeField]
|
||||
if postalField.waitForExistence(timeout: 3) && postalField.isHittable {
|
||||
postalField.tap()
|
||||
sleep(1)
|
||||
postalField.typeText("78701")
|
||||
}
|
||||
|
||||
dismissKeyboard()
|
||||
sleep(1)
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
// Save
|
||||
let saveButton = app.buttons[AccessibilityIdentifiers.Residence.saveButton]
|
||||
if saveButton.waitForExistence(timeout: 5) && saveButton.isHittable {
|
||||
saveButton.tap()
|
||||
} else {
|
||||
let saveByLabel = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
||||
if saveByLabel.exists {
|
||||
saveByLabel.tap()
|
||||
}
|
||||
}
|
||||
sleep(3)
|
||||
}
|
||||
|
||||
// MARK: - Helper: Find Add Task Button
|
||||
|
||||
private func findAddTaskButton() -> XCUIElement {
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton]
|
||||
if addButton.exists {
|
||||
return addButton
|
||||
}
|
||||
return app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add Task' OR label CONTAINS[c] 'New Task'")).firstMatch
|
||||
}
|
||||
}
|
||||
@@ -17,39 +17,41 @@ struct UITestHelpers {
|
||||
return
|
||||
}
|
||||
|
||||
// User is logged in, need to log them out
|
||||
let profileTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Profile'")).firstMatch
|
||||
if profileTab.exists {
|
||||
profileTab.tap()
|
||||
// Check if we have a tab bar (logged in state)
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
guard tabBar.exists else { return }
|
||||
|
||||
// Navigate to Residences tab first
|
||||
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
||||
if residencesTab.exists {
|
||||
residencesTab.tap()
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
// Tap settings button
|
||||
let settingsButton = app.buttons[AccessibilityIdentifiers.Navigation.settingsButton]
|
||||
if settingsButton.waitForExistence(timeout: 3) && settingsButton.isHittable {
|
||||
settingsButton.tap()
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
// Find and tap logout button
|
||||
let logoutButton = app.buttons[AccessibilityIdentifiers.Profile.logoutButton]
|
||||
if logoutButton.waitForExistence(timeout: 3) {
|
||||
logoutButton.tap()
|
||||
sleep(1)
|
||||
|
||||
let logoutButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout' OR label CONTAINS[c] 'Log Out' OR label CONTAINS[c] 'Sign Out'")).firstMatch
|
||||
if logoutButton.waitForExistence(timeout: 5) {
|
||||
logoutButton.tap()
|
||||
sleep(1)
|
||||
|
||||
// Tap the "Log Out" button on the alert
|
||||
let alertLogoutButton = app.alerts.buttons["Log Out"]
|
||||
if alertLogoutButton.exists {
|
||||
alertLogoutButton.tap()
|
||||
sleep(2)
|
||||
} else {
|
||||
// Fallback to broader search if exact match not found
|
||||
let confirmButton = app.alerts.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout' OR label CONTAINS[c] 'Log Out' OR label CONTAINS[c] 'Confirm' OR label CONTAINS[c] 'Yes'")).firstMatch
|
||||
if confirmButton.exists {
|
||||
confirmButton.tap()
|
||||
sleep(2)
|
||||
}
|
||||
// Confirm logout in alert if present - specifically target the alert's button
|
||||
let alert = app.alerts.firstMatch
|
||||
if alert.waitForExistence(timeout: 2) {
|
||||
let confirmLogout = alert.buttons["Log Out"]
|
||||
if confirmLogout.exists {
|
||||
confirmLogout.tap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -61,25 +63,34 @@ struct UITestHelpers {
|
||||
/// - username: The username/email to use for login
|
||||
/// - password: The password to use for login
|
||||
static func login(app: XCUIApplication, username: String, password: String) {
|
||||
let usernameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'email'")).firstMatch
|
||||
// Find username field by accessibility identifier
|
||||
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||||
XCTAssertTrue(usernameField.waitForExistence(timeout: 5), "Username field should exist")
|
||||
usernameField.tap()
|
||||
usernameField.typeText(username)
|
||||
|
||||
let passwordField = app.secureTextFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'password'")).firstMatch
|
||||
XCTAssertTrue(passwordField.exists, "Password field should exist")
|
||||
// Find password field - it could be TextField (if visible) or SecureField
|
||||
var passwordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.passwordField]
|
||||
if !passwordField.exists {
|
||||
passwordField = app.textFields[AccessibilityIdentifiers.Authentication.passwordField]
|
||||
}
|
||||
XCTAssertTrue(passwordField.waitForExistence(timeout: 3), "Password field should exist")
|
||||
passwordField.tap()
|
||||
passwordField.typeText(password)
|
||||
|
||||
let signInButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign In'")).firstMatch
|
||||
XCTAssertTrue(signInButton.exists, "Sign In button should exist")
|
||||
signInButton.tap()
|
||||
// Find and tap login button
|
||||
let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
|
||||
XCTAssertTrue(loginButton.waitForExistence(timeout: 3), "Login button should exist")
|
||||
loginButton.tap()
|
||||
|
||||
// Wait for login to complete
|
||||
sleep(3)
|
||||
}
|
||||
|
||||
/// Ensures the user is logged out before running a test
|
||||
/// - Parameter app: The XCUIApplication instance
|
||||
static func ensureLoggedOut(app: XCUIApplication) {
|
||||
sleep(3)
|
||||
sleep(2)
|
||||
logout(app: app)
|
||||
}
|
||||
|
||||
@@ -88,18 +99,21 @@ struct UITestHelpers {
|
||||
/// - Parameter username: Optional username (defaults to "testuser")
|
||||
/// - Parameter password: Optional password (defaults to "TestPass123!")
|
||||
static func ensureLoggedIn(app: XCUIApplication, username: String = "testuser", password: String = "TestPass123!") {
|
||||
sleep(3)
|
||||
sleep(2)
|
||||
|
||||
// Need to login
|
||||
let usernameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'email'")).firstMatch
|
||||
// Check if already logged in (tab bar visible)
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
if tabBar.exists {
|
||||
return // Already logged in
|
||||
}
|
||||
|
||||
// Check if on login screen
|
||||
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||||
if usernameField.waitForExistence(timeout: 5) {
|
||||
login(app: app, username: username, password: password)
|
||||
|
||||
// Wait for main screen to appear
|
||||
let mainTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences' OR label CONTAINS[c] 'Tasks'")).firstMatch
|
||||
_ = mainTab.waitForExistence(timeout: 10)
|
||||
} else {
|
||||
return
|
||||
_ = tabBar.waitForExistence(timeout: 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,9 +470,12 @@ struct ContractorDetailView: View {
|
||||
if let residenceId = residenceId {
|
||||
DetailSection(title: L10n.Contractors.associatedPropertySection) {
|
||||
HStack(spacing: AppSpacing.sm) {
|
||||
Image(systemName: "house.fill")
|
||||
Image("house_outline")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 18, height: 18)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.frame(width: 20)
|
||||
|
||||
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
|
||||
Text(L10n.Contractors.propertyLabel)
|
||||
|
||||
@@ -82,9 +82,12 @@ struct ContractorFormSheet: View {
|
||||
Section {
|
||||
Button(action: { showingResidencePicker = true }) {
|
||||
HStack {
|
||||
Image(systemName: "house")
|
||||
Image("house_outline")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 20, height: 20)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.frame(width: 24)
|
||||
Text(selectedResidenceName ?? L10n.Contractors.personalNoResidence)
|
||||
.foregroundColor(selectedResidenceName == nil ? Color.appTextSecondary.opacity(0.7) : Color.appTextPrimary)
|
||||
Spacer()
|
||||
|
||||
@@ -277,6 +277,7 @@ struct OrganicStatPill: View {
|
||||
.foregroundColor(color)
|
||||
} else {
|
||||
Image(icon)
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 16, height: 16)
|
||||
@@ -380,6 +381,7 @@ extension View {
|
||||
// MARK: - Organic Spacing
|
||||
|
||||
struct OrganicSpacing {
|
||||
static let compact: CGFloat = 8
|
||||
static let cozy: CGFloat = 20
|
||||
static let comfortable: CGFloat = 24
|
||||
static let spacious: CGFloat = 32
|
||||
@@ -429,7 +431,7 @@ struct FloatingLeaf: View {
|
||||
|
||||
// Organic Stat Pills
|
||||
HStack(spacing: 12) {
|
||||
OrganicStatPill(icon: "house.fill", value: "3", label: "Properties")
|
||||
OrganicStatPill(icon: "house_outline", value: "3", label: "Properties", isSystemIcon: false)
|
||||
OrganicStatPill(icon: "checklist", value: "12", label: "Tasks", color: .orange)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,31 +3,157 @@ import Foundation
|
||||
/// Utility for parsing and cleaning error messages from API responses and network errors
|
||||
enum ErrorMessageParser {
|
||||
|
||||
// Network/connection error patterns to detect
|
||||
private static let networkErrorPatterns: [(pattern: String, message: String)] = [
|
||||
("Could not connect to the server", "Unable to connect to the server. Please check your internet connection."),
|
||||
("NSURLErrorDomain", "Unable to connect to the server. Please check your internet connection."),
|
||||
("The Internet connection appears to be offline", "No internet connection. Please check your network settings."),
|
||||
("A server with the specified hostname could not be found", "Unable to connect to the server. Please check your internet connection."),
|
||||
("The request timed out", "Request timed out. Please try again."),
|
||||
("The network connection was lost", "Connection was interrupted. Please try again."),
|
||||
("An SSL error has occurred", "Secure connection failed. Please try again."),
|
||||
("CFNetwork", "Unable to connect to the server. Please check your internet connection."),
|
||||
("kCFStreamError", "Unable to connect to the server. Please check your internet connection."),
|
||||
("Code=-1004", "Unable to connect to the server. Please check your internet connection."),
|
||||
("Code=-1009", "No internet connection. Please check your network settings."),
|
||||
("Code=-1001", "Request timed out. Please try again."),
|
||||
("Code=-1003", "Unable to connect to the server. Please check your internet connection."),
|
||||
("Code=-1005", "Connection was interrupted. Please try again."),
|
||||
("Code=-1200", "Secure connection failed. Please try again."),
|
||||
("UnresolvedAddressException", "Unable to connect to the server. Please check your internet connection."),
|
||||
("ConnectException", "Unable to connect to the server. Please check your internet connection."),
|
||||
("SocketTimeoutException", "Request timed out. Please try again."),
|
||||
("Connection refused", "Unable to connect to the server. The server may be down."),
|
||||
("Connection reset", "Connection was interrupted. Please try again.")
|
||||
// MARK: - API Error Code Mappings
|
||||
|
||||
/// Maps backend error codes to user-friendly messages
|
||||
private static let errorCodeMappings: [String: String] = [
|
||||
// Authentication errors
|
||||
"error.invalid_credentials": "Invalid username or password. Please try again.",
|
||||
"error.invalid_token": "Your session has expired. Please log in again.",
|
||||
"error.not_authenticated": "Please log in to continue.",
|
||||
"error.account_inactive": "Your account is inactive. Please contact support.",
|
||||
"error.username_taken": "This username is already taken. Please choose another.",
|
||||
"error.email_taken": "This email is already registered. Try logging in instead.",
|
||||
"error.email_already_taken": "This email is already in use.",
|
||||
"error.registration_failed": "Registration failed. Please try again.",
|
||||
"error.failed_to_get_user": "Unable to load your profile. Please try again.",
|
||||
"error.failed_to_update_profile": "Unable to update your profile. Please try again.",
|
||||
|
||||
// Email verification errors
|
||||
"error.invalid_verification_code": "Invalid verification code. Please check and try again.",
|
||||
"error.verification_code_expired": "Your verification code has expired. Please request a new one.",
|
||||
"error.email_already_verified": "Your email is already verified.",
|
||||
"error.verification_failed": "Verification failed. Please try again.",
|
||||
"error.failed_to_resend_verification": "Unable to send verification code. Please try again.",
|
||||
|
||||
// Password reset errors
|
||||
"error.rate_limit_exceeded": "Too many attempts. Please wait a few minutes and try again.",
|
||||
"error.too_many_attempts": "Too many attempts. Please request a new code.",
|
||||
"error.invalid_reset_token": "This reset link has expired. Please request a new one.",
|
||||
"error.password_reset_failed": "Password reset failed. Please try again.",
|
||||
|
||||
// Social sign-in errors
|
||||
"error.apple_signin_not_configured": "Apple Sign In is not available. Please use email login.",
|
||||
"error.apple_signin_failed": "Apple Sign In failed. Please try again.",
|
||||
"error.invalid_apple_token": "Apple Sign In failed. Please try again.",
|
||||
"error.google_signin_not_configured": "Google Sign In is not available. Please use email login.",
|
||||
"error.google_signin_failed": "Google Sign In failed. Please try again.",
|
||||
"error.invalid_google_token": "Google Sign In failed. Please try again.",
|
||||
|
||||
// Resource not found errors
|
||||
"error.task_not_found": "Task not found. It may have been deleted.",
|
||||
"error.residence_not_found": "Property not found. It may have been deleted.",
|
||||
"error.contractor_not_found": "Contractor not found. It may have been deleted.",
|
||||
"error.document_not_found": "Document not found. It may have been deleted.",
|
||||
"error.completion_not_found": "Task completion not found.",
|
||||
"error.user_not_found": "User not found.",
|
||||
"error.notification_not_found": "Notification not found.",
|
||||
"error.template_not_found": "Task template not found.",
|
||||
"error.upgrade_trigger_not_found": "Feature not available.",
|
||||
|
||||
// Access denied errors
|
||||
"error.task_access_denied": "You don't have permission to view this task.",
|
||||
"error.residence_access_denied": "You don't have permission to view this property.",
|
||||
"error.contractor_access_denied": "You don't have permission to view this contractor.",
|
||||
"error.document_access_denied": "You don't have permission to view this document.",
|
||||
"error.not_residence_owner": "Only the property owner can do this.",
|
||||
"error.cannot_remove_owner": "The property owner cannot be removed.",
|
||||
"error.access_denied": "You don't have permission for this action.",
|
||||
|
||||
// Sharing errors
|
||||
"error.share_code_invalid": "Invalid share code. Please check and try again.",
|
||||
"error.share_code_expired": "This share code has expired. Please request a new one.",
|
||||
"error.user_already_member": "This user is already a member of this property.",
|
||||
|
||||
// Subscription/limit errors
|
||||
"error.properties_limit_reached": "You've reached your property limit. Upgrade to add more.",
|
||||
"error.properties_limit_exceeded": "You've reached your property limit. Upgrade to add more.",
|
||||
"error.tasks_limit_exceeded": "You've reached your task limit. Upgrade to add more.",
|
||||
"error.contractors_limit_exceeded": "You've reached your contractor limit. Upgrade to add more.",
|
||||
"error.documents_limit_exceeded": "You've reached your document limit. Upgrade to add more.",
|
||||
|
||||
// Task state errors
|
||||
"error.task_already_cancelled": "This task has already been cancelled.",
|
||||
"error.task_already_archived": "This task has already been archived.",
|
||||
|
||||
// Form/upload errors
|
||||
"error.failed_to_parse_form": "Unable to process the form. Please try again.",
|
||||
"error.task_id_required": "Task ID is required.",
|
||||
"error.residence_id_required": "Property ID is required.",
|
||||
"error.title_required": "Title is required.",
|
||||
"error.failed_to_upload_image": "Unable to upload image. Please try again.",
|
||||
"error.failed_to_upload_file": "Unable to upload file. Please try again.",
|
||||
"error.no_file_provided": "Please select a file to upload.",
|
||||
|
||||
// Invalid ID errors
|
||||
"error.invalid_task_id": "Invalid task.",
|
||||
"error.invalid_task_id_value": "Invalid task.",
|
||||
"error.invalid_residence_id": "Invalid property.",
|
||||
"error.invalid_residence_id_value": "Invalid property.",
|
||||
"error.invalid_contractor_id": "Invalid contractor.",
|
||||
"error.invalid_document_id": "Invalid document.",
|
||||
"error.invalid_completion_id": "Invalid task completion.",
|
||||
"error.invalid_user_id": "Invalid user.",
|
||||
"error.invalid_notification_id": "Invalid notification.",
|
||||
"error.invalid_device_id": "Invalid device.",
|
||||
"error.invalid_platform": "Invalid platform.",
|
||||
"error.invalid_id": "Invalid ID.",
|
||||
|
||||
// Data fetch errors
|
||||
"error.failed_to_fetch_residence_types": "Unable to load property types. Please try again.",
|
||||
"error.failed_to_fetch_task_categories": "Unable to load task categories. Please try again.",
|
||||
"error.failed_to_fetch_task_priorities": "Unable to load task priorities. Please try again.",
|
||||
"error.failed_to_fetch_task_frequencies": "Unable to load task frequencies. Please try again.",
|
||||
"error.failed_to_fetch_task_statuses": "Unable to load task statuses. Please try again.",
|
||||
"error.failed_to_fetch_contractor_specialties": "Unable to load contractor specialties. Please try again.",
|
||||
"error.failed_to_fetch_templates": "Unable to load task templates. Please try again.",
|
||||
"error.failed_to_search_templates": "Unable to search templates. Please try again.",
|
||||
|
||||
// Subscription purchase errors
|
||||
"error.receipt_data_required": "Purchase verification failed. Please try again.",
|
||||
"error.purchase_token_required": "Purchase verification failed. Please try again.",
|
||||
|
||||
// Media errors
|
||||
"error.file_not_found": "File not found.",
|
||||
"error.image_not_found": "Image not found.",
|
||||
|
||||
// Generic errors
|
||||
"error.invalid_request": "Invalid request. Please try again.",
|
||||
"error.invalid_request_body": "Invalid request. Please check your input.",
|
||||
"error.internal": "Something went wrong. Please try again.",
|
||||
"error.query_required": "Search query is required.",
|
||||
"error.query_too_short": "Search query is too short."
|
||||
]
|
||||
|
||||
// Indicators that a message is technical/developer-facing
|
||||
// MARK: - Network Error Patterns
|
||||
|
||||
/// Network/connection error patterns to detect
|
||||
private static let networkErrorPatterns: [(pattern: String, message: String)] = [
|
||||
("Could not connect to the server", "Unable to connect. Please check your internet connection."),
|
||||
("NSURLErrorDomain", "Unable to connect. Please check your internet connection."),
|
||||
("The Internet connection appears to be offline", "No internet connection. Please check your network."),
|
||||
("A server with the specified hostname could not be found", "Unable to connect. Please check your internet connection."),
|
||||
("The request timed out", "Request timed out. Please try again."),
|
||||
("The network connection was lost", "Connection lost. Please try again."),
|
||||
("An SSL error has occurred", "Secure connection failed. Please try again."),
|
||||
("CFNetwork", "Unable to connect. Please check your internet connection."),
|
||||
("kCFStreamError", "Unable to connect. Please check your internet connection."),
|
||||
("Code=-1004", "Unable to connect. Please check your internet connection."),
|
||||
("Code=-1009", "No internet connection. Please check your network."),
|
||||
("Code=-1001", "Request timed out. Please try again."),
|
||||
("Code=-1003", "Unable to connect. Please check your internet connection."),
|
||||
("Code=-1005", "Connection lost. Please try again."),
|
||||
("Code=-1200", "Secure connection failed. Please try again."),
|
||||
("UnresolvedAddressException", "Unable to connect. Please check your internet connection."),
|
||||
("ConnectException", "Unable to connect. Please check your internet connection."),
|
||||
("SocketTimeoutException", "Request timed out. Please try again."),
|
||||
("Connection refused", "Unable to connect. The server may be temporarily unavailable."),
|
||||
("Connection reset", "Connection lost. Please try again.")
|
||||
]
|
||||
|
||||
// MARK: - Technical Error Indicators
|
||||
|
||||
/// Indicators that a message is technical/developer-facing
|
||||
private static let technicalIndicators = [
|
||||
"Exception",
|
||||
"Error Domain=",
|
||||
@@ -50,14 +176,25 @@ enum ErrorMessageParser {
|
||||
"_kCF"
|
||||
]
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Parses error messages to extract user-friendly text
|
||||
/// Handles network errors, JSON error responses, and raw error messages
|
||||
/// Handles API error codes, network errors, JSON responses, and raw error messages
|
||||
/// - Parameter rawMessage: The raw error message from the API or exception
|
||||
/// - Returns: A user-friendly error message
|
||||
static func parse(_ rawMessage: String) -> String {
|
||||
let trimmed = rawMessage.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// Check for network/connection errors first (these are technical messages from exceptions)
|
||||
// Check for known API error codes first (e.g., "error.invalid_token")
|
||||
if trimmed.hasPrefix("error.") {
|
||||
if let friendlyMessage = errorCodeMappings[trimmed] {
|
||||
return friendlyMessage
|
||||
}
|
||||
// Unknown error code - generate a generic message from the code
|
||||
return generateMessageFromCode(trimmed)
|
||||
}
|
||||
|
||||
// Check for network/connection errors
|
||||
for (pattern, friendlyMessage) in networkErrorPatterns {
|
||||
if trimmed.localizedCaseInsensitiveContains(pattern) {
|
||||
return friendlyMessage
|
||||
@@ -85,17 +222,17 @@ enum ErrorMessageParser {
|
||||
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
// Try to find common error fields
|
||||
if let errorMsg = json["error"] as? String {
|
||||
return errorMsg
|
||||
// Recursively parse the error message (it might be an error code)
|
||||
return parse(errorMsg)
|
||||
}
|
||||
if let message = json["message"] as? String {
|
||||
return message
|
||||
return parse(message)
|
||||
}
|
||||
if let detail = json["detail"] as? String {
|
||||
return detail
|
||||
return parse(detail)
|
||||
}
|
||||
|
||||
// Check if this looks like a data object (has id, title, etc)
|
||||
// rather than an error response
|
||||
if json["id"] != nil && (json["title"] != nil || json["name"] != nil) {
|
||||
return "Request failed. Please check your input and try again."
|
||||
}
|
||||
@@ -108,6 +245,22 @@ enum ErrorMessageParser {
|
||||
return "An error occurred. Please try again."
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Generates a user-friendly message from an unknown error code
|
||||
private static func generateMessageFromCode(_ code: String) -> String {
|
||||
// Remove "error." prefix and convert underscores to spaces
|
||||
var message = code.replacingOccurrences(of: "error.", with: "")
|
||||
message = message.replacingOccurrences(of: "_", with: " ")
|
||||
|
||||
// Capitalize first letter
|
||||
if let firstChar = message.first {
|
||||
message = firstChar.uppercased() + message.dropFirst()
|
||||
}
|
||||
|
||||
return message + ". Please try again."
|
||||
}
|
||||
|
||||
/// Checks if the message looks like a technical/developer error (stack trace, exception, etc)
|
||||
private static func isTechnicalError(_ message: String) -> Bool {
|
||||
return technicalIndicators.contains { message.localizedCaseInsensitiveContains($0) }
|
||||
|
||||
@@ -43,7 +43,10 @@ enum L10n {
|
||||
static var registerPassword: String { String(localized: "auth_register_password") }
|
||||
static var registerConfirmPassword: String { String(localized: "auth_register_confirm_password") }
|
||||
static var registerButton: String { String(localized: "auth_register_button") }
|
||||
static var creatingAccount: String { String(localized: "auth_creating_account") }
|
||||
static var haveAccount: String { String(localized: "auth_have_account") }
|
||||
static var alreadyHaveAccount: String { String(localized: "auth_already_have_account") }
|
||||
static var signIn: String { String(localized: "auth_sign_in") }
|
||||
static var passwordsDontMatch: String { String(localized: "auth_passwords_dont_match") }
|
||||
static var joinCasera: String { String(localized: "auth_join_casera") }
|
||||
static var startManaging: String { String(localized: "auth_start_managing") }
|
||||
|
||||
@@ -123,16 +123,10 @@
|
||||
"$%@" : {
|
||||
"comment" : "A label displaying the cost of a task. The argument is the cost of the task.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"0" : {
|
||||
|
||||
},
|
||||
"000000" : {
|
||||
"comment" : "A placeholder text for a 6-digit code field.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"0.0" : {
|
||||
|
||||
},
|
||||
"0.00" : {
|
||||
|
||||
@@ -142,6 +136,9 @@
|
||||
},
|
||||
"7-day free trial, then %@" : {
|
||||
|
||||
},
|
||||
"ABC123" : {
|
||||
|
||||
},
|
||||
"Actions" : {
|
||||
"comment" : "A label for the actions menu in the task card.",
|
||||
@@ -164,10 +161,6 @@
|
||||
"comment" : "A description below the image in the \"No properties yet\" view, encouraging the user to add their first property.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Add your first property to start\nmanaging your home with ease" : {
|
||||
"comment" : "A description below an animated house illustration, encouraging users to add their first property.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Already have an account?" : {
|
||||
|
||||
},
|
||||
@@ -327,9 +320,6 @@
|
||||
},
|
||||
"Are you sure you want to remove %@ from this residence?" : {
|
||||
|
||||
},
|
||||
"At least 8 characters" : {
|
||||
|
||||
},
|
||||
"auth_account_info" : {
|
||||
"extractionState" : "manual",
|
||||
@@ -395,6 +385,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth_already_have_account" : {
|
||||
"comment" : "Text on the \"Already have an account?\" link in the auth register screen. Navigates to the auth login screen.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"auth_creating_account" : {
|
||||
|
||||
},
|
||||
"auth_dont_have_account" : {
|
||||
"extractionState" : "manual",
|
||||
@@ -3061,6 +3058,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth_sign_in" : {
|
||||
"comment" : "Button text that allows a user to sign in.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"auth_sign_in_subtitle" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -5243,13 +5244,7 @@
|
||||
"comment" : "The title for the view that shows a user's photo submissions.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Confirm Password" : {
|
||||
|
||||
},
|
||||
"Contains letters" : {
|
||||
|
||||
},
|
||||
"Contains numbers" : {
|
||||
"CONFIRM PASSWORD" : {
|
||||
|
||||
},
|
||||
"Continue with Free" : {
|
||||
@@ -5257,10 +5252,6 @@
|
||||
},
|
||||
"Contractor Imported" : {
|
||||
|
||||
},
|
||||
"Contractors" : {
|
||||
"comment" : "A tab label for the contractors section.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"contractors_add_button" : {
|
||||
"extractionState" : "manual",
|
||||
@@ -8926,8 +8917,8 @@
|
||||
"comment" : "A question displayed below a button in the \"Verify Code\" view, instructing the user to request a new code if they haven't received one.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Documents" : {
|
||||
"comment" : "A label displayed above the list of documents and warranties.",
|
||||
"Docs" : {
|
||||
"comment" : "A label displayed above the documents tab in the main tab view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"documents_active" : {
|
||||
@@ -16947,9 +16938,8 @@
|
||||
"comment" : "A label for an \"Edit Task\" button.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Email" : {
|
||||
"comment" : "A label displayed above the email text field in the \"Forgot Password?\" view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
"EMAIL" : {
|
||||
|
||||
},
|
||||
"Email Address" : {
|
||||
"comment" : "A label for the user to input their email address.",
|
||||
@@ -17400,6 +17390,9 @@
|
||||
"Joining residence..." : {
|
||||
"comment" : "A message displayed while waiting for the app to join a residence.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Joining..." : {
|
||||
|
||||
},
|
||||
"Let's get you started with some tasks.\nThe more you pick, the more we'll help you remember!" : {
|
||||
|
||||
@@ -17428,7 +17421,7 @@
|
||||
"Need inspiration?" : {
|
||||
|
||||
},
|
||||
"New Password" : {
|
||||
"NEW PASSWORD" : {
|
||||
|
||||
},
|
||||
"No active code" : {
|
||||
@@ -17480,10 +17473,7 @@
|
||||
"comment" : "A label indicating that a user is an owner of a residence.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Password Requirements" : {
|
||||
|
||||
},
|
||||
"Passwords match" : {
|
||||
"PASSWORD REQUIREMENTS" : {
|
||||
|
||||
},
|
||||
"Photo" : {
|
||||
@@ -21545,6 +21535,10 @@
|
||||
"comment" : "A fallback text that appears when the associated residence ID is not found in the user's residences. The placeholder number is replaced with the actual residence ID.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Pros" : {
|
||||
"comment" : "A tab label for the \"Pros\" section in the main tab view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Quick Start" : {
|
||||
|
||||
},
|
||||
@@ -21569,7 +21563,7 @@
|
||||
|
||||
},
|
||||
"Residences" : {
|
||||
"comment" : "A tab label for the \"Residences\" section in the main tab view.",
|
||||
"comment" : "A label for the \"Residences\" tab in the main tab view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"residences_add_contractors_prompt" : {
|
||||
@@ -24744,6 +24738,9 @@
|
||||
"Send Reset Code" : {
|
||||
"comment" : "A button label that says \"Send Reset Code\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Sending..." : {
|
||||
|
||||
},
|
||||
"Set Custom Time" : {
|
||||
"comment" : "A button that allows a user to set a custom notification time.",
|
||||
@@ -24889,9 +24886,6 @@
|
||||
"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)" : {
|
||||
|
||||
},
|
||||
"Signing in with Apple..." : {
|
||||
|
||||
@@ -24928,6 +24922,10 @@
|
||||
},
|
||||
"Take your home management\nto the next level" : {
|
||||
|
||||
},
|
||||
"Tap the + icon in the top right\nto add your first property" : {
|
||||
"comment" : "A description of an action a user can take to add a property.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Task Templates" : {
|
||||
"comment" : "The title of the view that lists all predefined task templates.",
|
||||
@@ -29995,16 +29993,15 @@
|
||||
"comment" : "A description of the benefit of upgrading to the Pro plan.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Users with access to this residence. Use the share button to invite others." : {
|
||||
"Use the share button to invite others" : {
|
||||
|
||||
},
|
||||
"Using system default time" : {
|
||||
"comment" : "A description of how a user can set a custom notification time.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Verification Code" : {
|
||||
"comment" : "A label displayed above the text field for entering a verification code.",
|
||||
"isCommentAutoGenerated" : true
|
||||
"VERIFICATION CODE" : {
|
||||
|
||||
},
|
||||
"Verify" : {
|
||||
"comment" : "A button label that says \"Verify\".",
|
||||
|
||||
@@ -14,7 +14,7 @@ struct MainTabView: View {
|
||||
}
|
||||
.id(refreshID)
|
||||
.tabItem {
|
||||
Label("Home", image: "tab_view_house")
|
||||
Label("Residences", image: "tab_view_house")
|
||||
}
|
||||
.tag(0)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.residencesTab)
|
||||
|
||||
@@ -142,8 +142,11 @@ struct OnboardingNameResidenceContent: View {
|
||||
)
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
Image(systemName: "house.fill")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
Image("house_outline")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 18, height: 18)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appAccent],
|
||||
|
||||
@@ -72,7 +72,8 @@ struct RegisterView: View {
|
||||
placeholder: L10n.Auth.registerUsername,
|
||||
text: $viewModel.username,
|
||||
icon: "person.fill",
|
||||
isFocused: focusedField == .username
|
||||
isFocused: focusedField == .username,
|
||||
accessibilityId: AccessibilityIdentifiers.Authentication.registerUsernameField
|
||||
)
|
||||
.focused($focusedField, equals: .username)
|
||||
.textInputAutocapitalization(.never)
|
||||
@@ -80,7 +81,6 @@ struct RegisterView: View {
|
||||
.textContentType(.username)
|
||||
.submitLabel(.next)
|
||||
.onSubmit { focusedField = .email }
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerUsernameField)
|
||||
|
||||
// Email Field
|
||||
OrganicTextField(
|
||||
@@ -88,7 +88,8 @@ struct RegisterView: View {
|
||||
placeholder: L10n.Auth.registerEmail,
|
||||
text: $viewModel.email,
|
||||
icon: "envelope.fill",
|
||||
isFocused: focusedField == .email
|
||||
isFocused: focusedField == .email,
|
||||
accessibilityId: AccessibilityIdentifiers.Authentication.registerEmailField
|
||||
)
|
||||
.focused($focusedField, equals: .email)
|
||||
.textInputAutocapitalization(.never)
|
||||
@@ -97,7 +98,6 @@ struct RegisterView: View {
|
||||
.textContentType(.emailAddress)
|
||||
.submitLabel(.next)
|
||||
.onSubmit { focusedField = .password }
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerEmailField)
|
||||
|
||||
OrganicDivider()
|
||||
.padding(.vertical, 4)
|
||||
@@ -108,13 +108,13 @@ struct RegisterView: View {
|
||||
placeholder: L10n.Auth.registerPassword,
|
||||
text: $viewModel.password,
|
||||
isVisible: $isPasswordVisible,
|
||||
isFocused: focusedField == .password
|
||||
isFocused: focusedField == .password,
|
||||
accessibilityId: AccessibilityIdentifiers.Authentication.registerPasswordField
|
||||
)
|
||||
.focused($focusedField, equals: .password)
|
||||
.textContentType(.newPassword)
|
||||
.submitLabel(.next)
|
||||
.onSubmit { focusedField = .confirmPassword }
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerPasswordField)
|
||||
|
||||
// Confirm Password Field
|
||||
OrganicSecureField(
|
||||
@@ -122,13 +122,13 @@ struct RegisterView: View {
|
||||
placeholder: L10n.Auth.registerConfirmPassword,
|
||||
text: $viewModel.confirmPassword,
|
||||
isVisible: $isConfirmPasswordVisible,
|
||||
isFocused: focusedField == .confirmPassword
|
||||
isFocused: focusedField == .confirmPassword,
|
||||
accessibilityId: AccessibilityIdentifiers.Authentication.registerConfirmPasswordField
|
||||
)
|
||||
.focused($focusedField, equals: .confirmPassword)
|
||||
.textContentType(.newPassword)
|
||||
.submitLabel(.go)
|
||||
.onSubmit { viewModel.register() }
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerConfirmPasswordField)
|
||||
|
||||
Text(L10n.Auth.passwordSuggestion)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
@@ -245,6 +245,7 @@ private struct OrganicTextField: View {
|
||||
@Binding var text: String
|
||||
let icon: String
|
||||
var isFocused: Bool = false
|
||||
var accessibilityId: String? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -267,6 +268,7 @@ private struct OrganicTextField: View {
|
||||
|
||||
TextField(placeholder, text: $text)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.accessibilityIdentifier(accessibilityId ?? "")
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||
@@ -288,6 +290,7 @@ private struct OrganicSecureField: View {
|
||||
@Binding var text: String
|
||||
@Binding var isVisible: Bool
|
||||
var isFocused: Bool = false
|
||||
var accessibilityId: String? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -311,8 +314,10 @@ private struct OrganicSecureField: View {
|
||||
Group {
|
||||
if isVisible {
|
||||
TextField(placeholder, text: $text)
|
||||
.accessibilityIdentifier(accessibilityId ?? "")
|
||||
} else {
|
||||
SecureField(placeholder, text: $text)
|
||||
.accessibilityIdentifier(accessibilityId ?? "")
|
||||
}
|
||||
}
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
|
||||
@@ -362,8 +362,11 @@ private struct OrganicEmptyResidencesView: View {
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.frame(width: 100, height: 100)
|
||||
|
||||
Image(systemName: "house.lodge.fill")
|
||||
.font(.system(size: 44, weight: .medium))
|
||||
Image("house_outline")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 48, height: 48)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.offset(y: isAnimating ? -2 : 2)
|
||||
.animation(
|
||||
@@ -378,7 +381,7 @@ private struct OrganicEmptyResidencesView: View {
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text("Add your first property to start\nmanaging your home with ease")
|
||||
Text("Tap the + icon in the top right\nto add your first property")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -66,16 +66,16 @@ struct ResidenceFormView: View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: OrganicSpacing.comfortable) {
|
||||
// Property Details Section
|
||||
OrganicFormSection(title: L10n.Residences.propertyDetails, icon: "house.fill") {
|
||||
OrganicFormSection(title: L10n.Residences.propertyDetails, icon: "house_outline") {
|
||||
VStack(spacing: 16) {
|
||||
OrganicFormTextField(
|
||||
label: L10n.Residences.propertyName,
|
||||
placeholder: "My Home",
|
||||
text: $name,
|
||||
error: nameError.isEmpty ? nil : nameError
|
||||
error: nameError.isEmpty ? nil : nameError,
|
||||
accessibilityId: AccessibilityIdentifiers.Residence.nameField
|
||||
)
|
||||
.focused($focusedField, equals: .name)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.nameField)
|
||||
|
||||
OrganicFormPicker(
|
||||
label: L10n.Residences.propertyType,
|
||||
@@ -95,54 +95,54 @@ struct ResidenceFormView: View {
|
||||
OrganicFormTextField(
|
||||
label: L10n.Residences.streetAddress,
|
||||
placeholder: "123 Main St",
|
||||
text: $streetAddress
|
||||
text: $streetAddress,
|
||||
accessibilityId: AccessibilityIdentifiers.Residence.streetAddressField
|
||||
)
|
||||
.focused($focusedField, equals: .streetAddress)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.streetAddressField)
|
||||
|
||||
OrganicFormTextField(
|
||||
label: L10n.Residences.apartmentUnit,
|
||||
placeholder: "Apt 4B",
|
||||
text: $apartmentUnit
|
||||
text: $apartmentUnit,
|
||||
accessibilityId: AccessibilityIdentifiers.Residence.apartmentUnitField
|
||||
)
|
||||
.focused($focusedField, equals: .apartmentUnit)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.apartmentUnitField)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
OrganicFormTextField(
|
||||
label: L10n.Residences.city,
|
||||
placeholder: "City",
|
||||
text: $city
|
||||
text: $city,
|
||||
accessibilityId: AccessibilityIdentifiers.Residence.cityField
|
||||
)
|
||||
.focused($focusedField, equals: .city)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.cityField)
|
||||
|
||||
OrganicFormTextField(
|
||||
label: L10n.Residences.stateProvince,
|
||||
placeholder: "State",
|
||||
text: $stateProvince
|
||||
text: $stateProvince,
|
||||
accessibilityId: AccessibilityIdentifiers.Residence.stateProvinceField
|
||||
)
|
||||
.focused($focusedField, equals: .stateProvince)
|
||||
.frame(maxWidth: 120)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.stateProvinceField)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
OrganicFormTextField(
|
||||
label: L10n.Residences.postalCode,
|
||||
placeholder: "12345",
|
||||
text: $postalCode
|
||||
text: $postalCode,
|
||||
accessibilityId: AccessibilityIdentifiers.Residence.postalCodeField
|
||||
)
|
||||
.focused($focusedField, equals: .postalCode)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.postalCodeField)
|
||||
|
||||
OrganicFormTextField(
|
||||
label: L10n.Residences.country,
|
||||
placeholder: "USA",
|
||||
text: $country
|
||||
text: $country,
|
||||
accessibilityId: AccessibilityIdentifiers.Residence.countryField
|
||||
)
|
||||
.focused($focusedField, equals: .country)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.countryField)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,19 +155,19 @@ struct ResidenceFormView: View {
|
||||
label: L10n.Residences.bedrooms,
|
||||
placeholder: "0",
|
||||
text: $bedrooms,
|
||||
keyboardType: .numberPad
|
||||
keyboardType: .numberPad,
|
||||
accessibilityId: AccessibilityIdentifiers.Residence.bedroomsField
|
||||
)
|
||||
.focused($focusedField, equals: .bedrooms)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.bedroomsField)
|
||||
|
||||
OrganicFormTextField(
|
||||
label: L10n.Residences.bathrooms,
|
||||
placeholder: "0.0",
|
||||
text: $bathrooms,
|
||||
keyboardType: .decimalPad
|
||||
keyboardType: .decimalPad,
|
||||
accessibilityId: AccessibilityIdentifiers.Residence.bathroomsField
|
||||
)
|
||||
.focused($focusedField, equals: .bathrooms)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.bathroomsField)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
@@ -175,29 +175,29 @@ struct ResidenceFormView: View {
|
||||
label: L10n.Residences.squareFootage,
|
||||
placeholder: "sq ft",
|
||||
text: $squareFootage,
|
||||
keyboardType: .numberPad
|
||||
keyboardType: .numberPad,
|
||||
accessibilityId: AccessibilityIdentifiers.Residence.squareFootageField
|
||||
)
|
||||
.focused($focusedField, equals: .squareFootage)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.squareFootageField)
|
||||
|
||||
OrganicFormTextField(
|
||||
label: L10n.Residences.lotSize,
|
||||
placeholder: "acres",
|
||||
text: $lotSize,
|
||||
keyboardType: .decimalPad
|
||||
keyboardType: .decimalPad,
|
||||
accessibilityId: AccessibilityIdentifiers.Residence.lotSizeField
|
||||
)
|
||||
.focused($focusedField, equals: .lotSize)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.lotSizeField)
|
||||
}
|
||||
|
||||
OrganicFormTextField(
|
||||
label: L10n.Residences.yearBuilt,
|
||||
placeholder: "2020",
|
||||
text: $yearBuilt,
|
||||
keyboardType: .numberPad
|
||||
keyboardType: .numberPad,
|
||||
accessibilityId: AccessibilityIdentifiers.Residence.yearBuiltField
|
||||
)
|
||||
.focused($focusedField, equals: .yearBuilt)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.yearBuiltField)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,9 +516,18 @@ private struct OrganicFormSection<Content: View>: View {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.frame(width: 28, height: 28)
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
if icon == "house_outline" {
|
||||
Image("house_outline")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 14, height: 14)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
} else {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
Text(title.uppercased())
|
||||
@@ -566,6 +575,7 @@ private struct OrganicFormTextField: View {
|
||||
@Binding var text: String
|
||||
var error: String? = nil
|
||||
var keyboardType: UIKeyboardType = .default
|
||||
var accessibilityId: String? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
@@ -583,6 +593,7 @@ private struct OrganicFormTextField: View {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(error != nil ? Color.appError : Color.appTextSecondary.opacity(0.1), lineWidth: 1)
|
||||
)
|
||||
.accessibilityIdentifier(accessibilityId ?? "")
|
||||
|
||||
if let error = error {
|
||||
Text(error)
|
||||
|
||||
@@ -96,7 +96,7 @@ struct UpgradeFeatureView: View {
|
||||
PromoContentView(content: promoContent)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
OrganicUpgradeFeatureRow(icon: "house.fill", text: "Unlimited properties")
|
||||
OrganicUpgradeFeatureRow(icon: "house_outline", text: "Unlimited properties")
|
||||
OrganicUpgradeFeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks")
|
||||
OrganicUpgradeFeatureRow(icon: "person.2.fill", text: "Contractor management")
|
||||
OrganicUpgradeFeatureRow(icon: "doc.fill", text: "Document & warranty storage")
|
||||
@@ -258,9 +258,18 @@ private struct OrganicUpgradeFeatureRow: View {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.frame(width: 36, height: 36)
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
if icon == "house_outline" {
|
||||
Image("house_outline")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 18, height: 18)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
} else {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
Text(text)
|
||||
|
||||
@@ -202,7 +202,7 @@ struct UpgradePromptView: View {
|
||||
PromoContentView(content: promoContent)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
OrganicFeatureRow(icon: "house.fill", text: "Unlimited properties")
|
||||
OrganicFeatureRow(icon: "house_outline", text: "Unlimited properties")
|
||||
OrganicFeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks")
|
||||
OrganicFeatureRow(icon: "person.2.fill", text: "Contractor management")
|
||||
OrganicFeatureRow(icon: "doc.fill", text: "Document & warranty storage")
|
||||
@@ -379,9 +379,18 @@ private struct OrganicFeatureRow: View {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.frame(width: 36, height: 36)
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
if icon == "house_outline" {
|
||||
Image("house_outline")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 18, height: 18)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
} else {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
Text(text)
|
||||
@@ -473,38 +482,6 @@ private struct OrganicSubscriptionButton: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Card Background
|
||||
|
||||
private struct OrganicCardBackground: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.appBackgroundSecondary
|
||||
|
||||
GeometryReader { geo in
|
||||
OrganicBlobShape(variation: 1)
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color.appPrimary.opacity(colorScheme == .dark ? 0.08 : 0.05),
|
||||
Color.appPrimary.opacity(0.01)
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: geo.size.width * 0.5
|
||||
)
|
||||
)
|
||||
.frame(width: geo.size.width * 0.6, height: geo.size.height * 0.5)
|
||||
.offset(x: geo.size.width * 0.4, y: -geo.size.height * 0.1)
|
||||
.blur(radius: 20)
|
||||
}
|
||||
|
||||
GrainTexture(opacity: 0.015)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SubscriptionProductButton: View {
|
||||
let product: Product
|
||||
let isSelected: Bool
|
||||
|
||||
@@ -15,15 +15,11 @@ struct StatView: View {
|
||||
|
||||
if icon == "house_outline" {
|
||||
Image("house_outline")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
.background(content: {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||
.frame(width: 24, height: 24)
|
||||
})
|
||||
.naturalShadow(.subtle)
|
||||
.foregroundColor(color)
|
||||
} else {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
|
||||
@@ -8,9 +8,12 @@ struct EmptyResidencesView: View {
|
||||
.fill(Color.appPrimary.opacity(0.08))
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
Image(systemName: "house")
|
||||
.font(.system(size: 56, weight: .medium))
|
||||
.foregroundColor(Color.appPrimary.opacity(0.6))
|
||||
Image("house_outline")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 56, height: 56)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
|
||||
Text("No properties yet")
|
||||
|
||||
@@ -19,7 +19,7 @@ struct SummaryCard: View {
|
||||
// Main Stats Row
|
||||
HStack(spacing: 0) {
|
||||
OrganicStatItem(
|
||||
icon: "house.fill",
|
||||
icon: "house_outline",
|
||||
value: "\(summary.totalResidences)",
|
||||
label: "Properties",
|
||||
accentColor: Color.appPrimary
|
||||
@@ -95,9 +95,18 @@ private struct OrganicStatItem: View {
|
||||
.fill(accentColor.opacity(0.12))
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundColor(accentColor)
|
||||
if icon == "house_outline" {
|
||||
Image("house_outline")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 20, height: 20)
|
||||
.foregroundColor(accentColor)
|
||||
} else {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundColor(accentColor)
|
||||
}
|
||||
}
|
||||
|
||||
// Value
|
||||
|
||||
@@ -12,9 +12,18 @@ struct SummaryStatView: View {
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
if icon == "house_outline" {
|
||||
Image("house_outline")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 20, height: 20)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
} else {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
Text(value)
|
||||
|
||||
@@ -383,18 +383,20 @@ private struct OrganicEmptyTasksView: View {
|
||||
Text(L10n.Tasks.addButton)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
.foregroundColor(hasResidences ? Color.appTextOnPrimary : Color.appTextSecondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
hasResidences
|
||||
? AnyShapeStyle(LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
))
|
||||
: AnyShapeStyle(Color.appTextSecondary.opacity(0.3))
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.shadow(color: Color.appPrimary.opacity(0.3), radius: 12, y: 6)
|
||||
.shadow(color: hasResidences ? Color.appPrimary.opacity(0.3) : Color.clear, radius: 12, y: 6)
|
||||
}
|
||||
.disabled(!hasResidences)
|
||||
.padding(.horizontal, 48)
|
||||
|
||||
@@ -97,10 +97,19 @@ struct VerifyEmailView: View {
|
||||
)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.verificationCodeField)
|
||||
.onChange(of: viewModel.code) { _, newValue in
|
||||
if newValue.count > 6 {
|
||||
viewModel.code = String(newValue.prefix(6))
|
||||
// Filter to only digits and limit to 6
|
||||
let filtered = newValue.filter { $0.isNumber }
|
||||
if filtered.count > 6 {
|
||||
viewModel.code = String(filtered.prefix(6))
|
||||
} else if filtered != newValue {
|
||||
viewModel.code = filtered
|
||||
}
|
||||
|
||||
// Auto-submit when 6 digits entered
|
||||
if viewModel.code.count == 6 && !viewModel.isLoading {
|
||||
isFocused = false // Dismiss keyboard
|
||||
viewModel.verifyEmail()
|
||||
}
|
||||
viewModel.code = newValue.filter { $0.isNumber }
|
||||
}
|
||||
|
||||
Text(L10n.Auth.verifyCodeMustBe6)
|
||||
@@ -154,6 +163,7 @@ struct VerifyEmailView: View {
|
||||
)
|
||||
}
|
||||
.disabled(viewModel.code.count != 6 || viewModel.isLoading)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.verifyButton)
|
||||
|
||||
// Help Text
|
||||
Text(L10n.Auth.verifyHelpText)
|
||||
|
||||
Reference in New Issue
Block a user