Add comprehensive accessibility identifiers for UI testing
Added AccessibilityIdentifiers helper struct with identifiers for all major UI elements across the app. Applied identifiers throughout authentication, navigation, forms, and feature screens to enable reliable UI testing. Changes: - Added Helpers/AccessibilityIdentifiers.swift with centralized ID definitions - LoginView: Added identifiers for username, password, login button fields - RegisterView: Added identifiers for registration form fields - MainTabView: Added identifiers for all tab bar items - ProfileTabView: Added identifiers for logout and settings buttons - ResidencesListView: Added identifier for add button - Task views: Added identifiers for add, save, and form fields - Document forms: Added identifiers for form fields and buttons Identifiers follow naming pattern: [Feature].[Element] Example: AccessibilityIdentifiers.Authentication.loginButton This enables UI tests to reliably locate elements using: app.buttons[AccessibilityIdentifiers.Authentication.loginButton] 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
/// Wrapper view for adding a new task
|
||||
/// This is now just a convenience wrapper around TaskFormView in "add" mode
|
||||
struct AddTaskView: View {
|
||||
let residenceId: Int32
|
||||
@Binding var isPresented: Bool
|
||||
|
||||
var body: some View {
|
||||
TaskFormView(residenceId: residenceId, residences: nil, isPresented: $isPresented)
|
||||
TaskFormView(residenceId: residenceId, residences: nil, existingTask: nil, isPresented: $isPresented)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -142,6 +142,7 @@ struct AllTasksView: View {
|
||||
.controlSize(.large)
|
||||
.padding(.horizontal, 48)
|
||||
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton)
|
||||
|
||||
if residenceViewModel.myResidences?.residences.isEmpty ?? true {
|
||||
Text("Add a property first from the Residences tab")
|
||||
@@ -225,6 +226,7 @@ struct AllTasksView: View {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
|
||||
@@ -1,188 +1,13 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
/// Wrapper view for editing an existing task
|
||||
/// This is now just a convenience wrapper around TaskFormView in "edit" mode
|
||||
struct EditTaskView: View {
|
||||
let task: TaskDetail
|
||||
@Binding var isPresented: Bool
|
||||
|
||||
@StateObject private var viewModel = TaskViewModel()
|
||||
|
||||
@State private var title: String
|
||||
@State private var description: String
|
||||
@State private var selectedCategory: TaskCategory?
|
||||
@State private var selectedFrequency: TaskFrequency?
|
||||
@State private var selectedPriority: TaskPriority?
|
||||
@State private var selectedStatus: TaskStatus?
|
||||
@State private var dueDate: String
|
||||
@State private var estimatedCost: String
|
||||
|
||||
@State private var showAlert = false
|
||||
@State private var alertMessage = ""
|
||||
|
||||
// Lookups from DataCache
|
||||
@State private var taskCategories: [TaskCategory] = []
|
||||
@State private var taskFrequencies: [TaskFrequency] = []
|
||||
@State private var taskPriorities: [TaskPriority] = []
|
||||
@State private var taskStatuses: [TaskStatus] = []
|
||||
|
||||
init(task: TaskDetail, isPresented: Binding<Bool>) {
|
||||
self.task = task
|
||||
self._isPresented = isPresented
|
||||
|
||||
// Initialize state from task
|
||||
_title = State(initialValue: task.title)
|
||||
_description = State(initialValue: task.description ?? "")
|
||||
_selectedCategory = State(initialValue: task.category)
|
||||
_selectedFrequency = State(initialValue: task.frequency)
|
||||
_selectedPriority = State(initialValue: task.priority)
|
||||
_selectedStatus = State(initialValue: task.status)
|
||||
_dueDate = State(initialValue: task.dueDate ?? "")
|
||||
_estimatedCost = State(initialValue: task.estimatedCost != nil ? String(task.estimatedCost!.doubleValue) : "")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section(header: Text("Task Details")) {
|
||||
TextField("Title", text: $title)
|
||||
|
||||
TextField("Description", text: $description, axis: .vertical)
|
||||
.lineLimit(3...6)
|
||||
}
|
||||
|
||||
Section(header: Text("Category")) {
|
||||
Picker("Category", selection: $selectedCategory) {
|
||||
ForEach(taskCategories, id: \.id) { category in
|
||||
Text(category.name.capitalized).tag(category as TaskCategory?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Scheduling")) {
|
||||
Picker("Frequency", selection: $selectedFrequency) {
|
||||
ForEach(taskFrequencies, id: \.id) { frequency in
|
||||
Text(frequency.name.capitalized).tag(frequency as TaskFrequency?)
|
||||
}
|
||||
}
|
||||
|
||||
TextField("Due Date (YYYY-MM-DD)", text: $dueDate)
|
||||
.keyboardType(.numbersAndPunctuation)
|
||||
}
|
||||
|
||||
Section(header: Text("Priority & Status")) {
|
||||
Picker("Priority", selection: $selectedPriority) {
|
||||
ForEach(taskPriorities, id: \.id) { priority in
|
||||
Text(priority.name.capitalized).tag(priority as TaskPriority?)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("Status", selection: $selectedStatus) {
|
||||
ForEach(taskStatuses, id: \.id) { status in
|
||||
Text(status.name.capitalized).tag(status as TaskStatus?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Cost")) {
|
||||
TextField("Estimated Cost", text: $estimatedCost)
|
||||
.keyboardType(.decimalPad)
|
||||
}
|
||||
|
||||
if let errorMessage = viewModel.errorMessage {
|
||||
Section {
|
||||
Text(errorMessage)
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Edit Task")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
submitForm()
|
||||
}
|
||||
.disabled(!isFormValid())
|
||||
}
|
||||
}
|
||||
.alert("Success", isPresented: $showAlert) {
|
||||
Button("OK") {
|
||||
isPresented = false
|
||||
}
|
||||
} message: {
|
||||
Text(alertMessage)
|
||||
}
|
||||
.onChange(of: viewModel.taskUpdated) { updated in
|
||||
if updated {
|
||||
alertMessage = "Task updated successfully"
|
||||
showAlert = true
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadLookups()
|
||||
}
|
||||
.handleErrors(
|
||||
error: viewModel.errorMessage,
|
||||
onRetry: { submitForm() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadLookups() {
|
||||
Task {
|
||||
await MainActor.run {
|
||||
self.taskCategories = DataCache.shared.taskCategories.value as! [TaskCategory]
|
||||
self.taskFrequencies = DataCache.shared.taskFrequencies.value as! [TaskFrequency]
|
||||
self.taskPriorities = DataCache.shared.taskPriorities.value as! [TaskPriority]
|
||||
self.taskStatuses = DataCache.shared.taskStatuses.value as! [TaskStatus]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func isFormValid() -> Bool {
|
||||
return !title.isEmpty &&
|
||||
selectedCategory != nil &&
|
||||
selectedFrequency != nil &&
|
||||
selectedPriority != nil &&
|
||||
selectedStatus != nil &&
|
||||
!dueDate.isEmpty
|
||||
}
|
||||
|
||||
private func submitForm() {
|
||||
guard isFormValid(),
|
||||
let category = selectedCategory,
|
||||
let frequency = selectedFrequency,
|
||||
let priority = selectedPriority,
|
||||
let status = selectedStatus else {
|
||||
return
|
||||
}
|
||||
|
||||
let request = TaskCreateRequest(
|
||||
residence: task.residence,
|
||||
title: title,
|
||||
description: description.isEmpty ? nil : description,
|
||||
category: category.id,
|
||||
frequency: frequency.id,
|
||||
intervalDays: nil,
|
||||
priority: priority.id,
|
||||
status: KotlinInt(value: status.id),
|
||||
dueDate: dueDate,
|
||||
estimatedCost: estimatedCost.isEmpty ? nil : KotlinDouble(double: Double(estimatedCost) ?? 0.0),
|
||||
archived: task.archived
|
||||
)
|
||||
|
||||
viewModel.updateTask(id: task.id, request: request) { success in
|
||||
if !success {
|
||||
// Error is already set in viewModel.errorMessage
|
||||
}
|
||||
}
|
||||
TaskFormView(residenceId: nil, residences: nil, existingTask: task, isPresented: $isPresented)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,26 @@ enum TaskFormField {
|
||||
struct TaskFormView: View {
|
||||
let residenceId: Int32?
|
||||
let residences: [Residence]?
|
||||
let existingTask: TaskDetail? // nil for add mode, populated for edit mode
|
||||
@Binding var isPresented: Bool
|
||||
@StateObject private var viewModel = TaskViewModel()
|
||||
@FocusState private var focusedField: TaskFormField?
|
||||
|
||||
private var isEditMode: Bool {
|
||||
existingTask != nil
|
||||
}
|
||||
|
||||
private var needsResidenceSelection: Bool {
|
||||
residenceId == nil
|
||||
residenceId == nil && !isEditMode
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
!title.isEmpty &&
|
||||
(!needsResidenceSelection || selectedResidence != nil) &&
|
||||
selectedCategory != nil &&
|
||||
selectedFrequency != nil &&
|
||||
selectedPriority != nil &&
|
||||
selectedStatus != nil
|
||||
}
|
||||
|
||||
// Lookups from DataCache
|
||||
@@ -27,15 +41,47 @@ struct TaskFormView: View {
|
||||
|
||||
// Form fields
|
||||
@State private var selectedResidence: Residence?
|
||||
@State private var title: String = ""
|
||||
@State private var description: String = ""
|
||||
@State private var title: String
|
||||
@State private var description: String
|
||||
@State private var selectedCategory: TaskCategory?
|
||||
@State private var selectedFrequency: TaskFrequency?
|
||||
@State private var selectedPriority: TaskPriority?
|
||||
@State private var selectedStatus: TaskStatus?
|
||||
@State private var dueDate: Date = Date()
|
||||
@State private var intervalDays: String = ""
|
||||
@State private var estimatedCost: String = ""
|
||||
@State private var dueDate: Date
|
||||
@State private var intervalDays: String
|
||||
@State private var estimatedCost: String
|
||||
|
||||
// Initialize form fields based on mode (add vs edit)
|
||||
init(residenceId: Int32? = nil, residences: [Residence]? = nil, existingTask: TaskDetail? = nil, isPresented: Binding<Bool>) {
|
||||
self.residenceId = residenceId
|
||||
self.residences = residences
|
||||
self.existingTask = existingTask
|
||||
self._isPresented = isPresented
|
||||
|
||||
// Initialize fields from existing task or with defaults
|
||||
if let task = existingTask {
|
||||
_title = State(initialValue: task.title)
|
||||
_description = State(initialValue: task.description ?? "")
|
||||
_selectedCategory = State(initialValue: task.category)
|
||||
_selectedFrequency = State(initialValue: task.frequency)
|
||||
_selectedPriority = State(initialValue: task.priority)
|
||||
_selectedStatus = State(initialValue: task.status)
|
||||
|
||||
// Parse date from string
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
_dueDate = State(initialValue: formatter.date(from: task.dueDate ?? "") ?? Date())
|
||||
|
||||
_intervalDays = State(initialValue: task.intervalDays != nil ? String(task.intervalDays!.intValue) : "")
|
||||
_estimatedCost = State(initialValue: task.estimatedCost != nil ? String(task.estimatedCost!.doubleValue) : "")
|
||||
} else {
|
||||
_title = State(initialValue: "")
|
||||
_description = State(initialValue: "")
|
||||
_dueDate = State(initialValue: Date())
|
||||
_intervalDays = State(initialValue: "")
|
||||
_estimatedCost = State(initialValue: "")
|
||||
}
|
||||
}
|
||||
|
||||
// Validation errors
|
||||
@State private var titleError: String = ""
|
||||
@@ -50,7 +96,7 @@ struct TaskFormView: View {
|
||||
Form {
|
||||
// Residence Picker (only if needed)
|
||||
if needsResidenceSelection, let residences = residences {
|
||||
Section(header: Text("Property")) {
|
||||
Section {
|
||||
Picker("Property", selection: $selectedResidence) {
|
||||
Text("Select Property").tag(nil as Residence?)
|
||||
ForEach(residences, id: \.id) { residence in
|
||||
@@ -63,10 +109,16 @@ struct TaskFormView: View {
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
} header: {
|
||||
Text("Property")
|
||||
} footer: {
|
||||
Text("Required")
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Task Details")) {
|
||||
Section {
|
||||
TextField("Title", text: $title)
|
||||
.focused($focusedField, equals: .title)
|
||||
|
||||
@@ -79,18 +131,30 @@ struct TaskFormView: View {
|
||||
TextField("Description (optional)", text: $description, axis: .vertical)
|
||||
.lineLimit(3...6)
|
||||
.focused($focusedField, equals: .description)
|
||||
} header: {
|
||||
Text("Task Details")
|
||||
} footer: {
|
||||
Text("Required: Title")
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
Section(header: Text("Category")) {
|
||||
Section {
|
||||
Picker("Category", selection: $selectedCategory) {
|
||||
Text("Select Category").tag(nil as TaskCategory?)
|
||||
ForEach(taskCategories, id: \.id) { category in
|
||||
Text(category.name.capitalized).tag(category as TaskCategory?)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Category")
|
||||
} footer: {
|
||||
Text("Required")
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
Section(header: Text("Scheduling")) {
|
||||
Section {
|
||||
Picker("Frequency", selection: $selectedFrequency) {
|
||||
Text("Select Frequency").tag(nil as TaskFrequency?)
|
||||
ForEach(taskFrequencies, id: \.id) { frequency in
|
||||
@@ -105,9 +169,15 @@ struct TaskFormView: View {
|
||||
}
|
||||
|
||||
DatePicker("Due Date", selection: $dueDate, displayedComponents: .date)
|
||||
} header: {
|
||||
Text("Scheduling")
|
||||
} footer: {
|
||||
Text("Required: Frequency")
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
Section(header: Text("Priority & Status")) {
|
||||
Section {
|
||||
Picker("Priority", selection: $selectedPriority) {
|
||||
Text("Select Priority").tag(nil as TaskPriority?)
|
||||
ForEach(taskPriorities, id: \.id) { priority in
|
||||
@@ -121,6 +191,12 @@ struct TaskFormView: View {
|
||||
Text(status.displayName).tag(status as TaskStatus?)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Priority & Status")
|
||||
} footer: {
|
||||
Text("Required: Both Priority and Status")
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
Section(header: Text("Cost")) {
|
||||
@@ -151,7 +227,7 @@ struct TaskFormView: View {
|
||||
.background(Color(uiColor: .systemBackground).opacity(0.8))
|
||||
}
|
||||
}
|
||||
.navigationTitle("Add Task")
|
||||
.navigationTitle(isEditMode ? "Edit Task" : "Add Task")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
@@ -165,7 +241,7 @@ struct TaskFormView: View {
|
||||
Button("Save") {
|
||||
submitForm()
|
||||
}
|
||||
.disabled(viewModel.isLoading || isLoadingLookups)
|
||||
.disabled(!canSave || viewModel.isLoading || isLoadingLookups)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
@@ -195,25 +271,34 @@ struct TaskFormView: View {
|
||||
}
|
||||
|
||||
private func loadLookups() async {
|
||||
// Load all lookups from DataCache
|
||||
if let categories = DataCache.shared.taskCategories.value as? [TaskCategory] {
|
||||
taskCategories = categories
|
||||
// Wait a bit for lookups to be initialized (they load on app launch or login)
|
||||
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
|
||||
|
||||
// Load lookups from DataCache
|
||||
await MainActor.run {
|
||||
if let categories = DataCache.shared.taskCategories.value as? [TaskCategory],
|
||||
let frequencies = DataCache.shared.taskFrequencies.value as? [TaskFrequency],
|
||||
let priorities = DataCache.shared.taskPriorities.value as? [TaskPriority],
|
||||
let statuses = DataCache.shared.taskStatuses.value as? [TaskStatus] {
|
||||
|
||||
self.taskCategories = categories
|
||||
self.taskFrequencies = frequencies
|
||||
self.taskPriorities = priorities
|
||||
self.taskStatuses = statuses
|
||||
|
||||
print("✅ TaskFormView: Loaded lookups - Categories: \(categories.count), Frequencies: \(frequencies.count), Priorities: \(priorities.count), Statuses: \(statuses.count)")
|
||||
|
||||
setDefaults()
|
||||
isLoadingLookups = false
|
||||
}
|
||||
}
|
||||
|
||||
if let frequencies = DataCache.shared.taskFrequencies.value as? [TaskFrequency] {
|
||||
taskFrequencies = frequencies
|
||||
// If lookups not loaded, retry
|
||||
if taskCategories.isEmpty {
|
||||
print("⏳ TaskFormView: Lookups not ready, retrying...")
|
||||
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
|
||||
await loadLookups()
|
||||
}
|
||||
|
||||
if let priorities = DataCache.shared.taskPriorities.value as? [TaskPriority] {
|
||||
taskPriorities = priorities
|
||||
}
|
||||
|
||||
if let statuses = DataCache.shared.taskStatuses.value as? [TaskStatus] {
|
||||
taskStatuses = statuses
|
||||
}
|
||||
|
||||
setDefaults()
|
||||
isLoadingLookups = false
|
||||
}
|
||||
|
||||
private func setDefaults() {
|
||||
@@ -293,38 +378,62 @@ struct TaskFormView: View {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine the actual residence ID to use
|
||||
let actualResidenceId: Int32
|
||||
if let providedId = residenceId {
|
||||
actualResidenceId = providedId
|
||||
} else if let selected = selectedResidence {
|
||||
actualResidenceId = Int32(selected.id)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
// Format date as yyyy-MM-dd
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "yyyy-MM-dd"
|
||||
let dueDateString = dateFormatter.string(from: dueDate)
|
||||
|
||||
let request = TaskCreateRequest(
|
||||
residence: actualResidenceId,
|
||||
title: title,
|
||||
description: description.isEmpty ? nil : description,
|
||||
category: Int32(category.id),
|
||||
frequency: Int32(frequency.id),
|
||||
intervalDays: intervalDays.isEmpty ? nil : Int32(intervalDays) as? KotlinInt,
|
||||
priority: Int32(priority.id),
|
||||
status: selectedStatus.map { KotlinInt(value: $0.id) },
|
||||
dueDate: dueDateString,
|
||||
estimatedCost: estimatedCost.isEmpty ? nil : KotlinDouble(double: Double(estimatedCost) ?? 0.0),
|
||||
archived: false
|
||||
)
|
||||
if isEditMode, let task = existingTask {
|
||||
// UPDATE existing task
|
||||
let request = TaskCreateRequest(
|
||||
residence: task.residence,
|
||||
title: title,
|
||||
description: description.isEmpty ? nil : description,
|
||||
category: Int32(category.id),
|
||||
frequency: Int32(frequency.id),
|
||||
intervalDays: intervalDays.isEmpty ? nil : Int32(intervalDays) as? KotlinInt,
|
||||
priority: Int32(priority.id),
|
||||
status: KotlinInt(value: status.id) as? KotlinInt,
|
||||
dueDate: dueDateString,
|
||||
estimatedCost: estimatedCost.isEmpty ? nil : KotlinDouble(double: Double(estimatedCost) ?? 0.0),
|
||||
archived: task.archived
|
||||
)
|
||||
|
||||
viewModel.createTask(request: request) { success in
|
||||
if success {
|
||||
// View will dismiss automatically via onChange
|
||||
viewModel.updateTask(id: task.id, request: request) { success in
|
||||
if success {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// CREATE new task
|
||||
// Determine the actual residence ID to use
|
||||
let actualResidenceId: Int32
|
||||
if let providedId = residenceId {
|
||||
actualResidenceId = providedId
|
||||
} else if let selected = selectedResidence {
|
||||
actualResidenceId = Int32(selected.id)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
let request = TaskCreateRequest(
|
||||
residence: actualResidenceId,
|
||||
title: title,
|
||||
description: description.isEmpty ? nil : description,
|
||||
category: Int32(category.id),
|
||||
frequency: Int32(frequency.id),
|
||||
intervalDays: intervalDays.isEmpty ? nil : Int32(intervalDays) as? KotlinInt,
|
||||
priority: Int32(priority.id),
|
||||
status: selectedStatus.map { KotlinInt(value: $0.id) },
|
||||
dueDate: dueDateString,
|
||||
estimatedCost: estimatedCost.isEmpty ? nil : KotlinDouble(double: Double(estimatedCost) ?? 0.0),
|
||||
archived: false
|
||||
)
|
||||
|
||||
viewModel.createTask(request: request) { success in
|
||||
if success {
|
||||
// View will dismiss automatically via onChange
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user