Add comprehensive i18n localization for KMM and iOS

KMM (Android/Shared):
- Add strings.xml with 200+ localized strings
- Add translation files for es, fr, de, pt languages
- Update all screens to use stringResource() for i18n
- Add Accept-Language header to API client for all platforms

iOS:
- Add L10n.swift helper with type-safe string accessors
- Add Localizable.xcstrings with translations for all 5 languages
- Update all SwiftUI views to use L10n.* for localized strings
- Localize Auth, Residence, Task, Contractor, Document, and Profile views

Supported languages: English, Spanish, French, German, Portuguese

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-02 02:02:00 -06:00
parent e62e7d4371
commit c726320c1e
59 changed files with 19839 additions and 757 deletions

View File

@@ -57,11 +57,11 @@ struct AllTasksView: View {
.sheet(isPresented: $showingUpgradePrompt) {
UpgradePromptView(triggerKey: "add_11th_task", isPresented: $showingUpgradePrompt)
}
.alert("Archive Task", isPresented: $showArchiveConfirmation) {
Button("Cancel", role: .cancel) {
.alert(L10n.Tasks.archiveTask, isPresented: $showArchiveConfirmation) {
Button(L10n.Common.cancel, role: .cancel) {
selectedTaskForArchive = nil
}
Button("Archive", role: .destructive) {
Button(L10n.Tasks.archive, role: .destructive) {
if let task = selectedTaskForArchive {
taskViewModel.archiveTask(id: task.id) { _ in
loadAllTasks()
@@ -71,14 +71,14 @@ struct AllTasksView: View {
}
} message: {
if let task = selectedTaskForArchive {
Text("Are you sure you want to archive \"\(task.title)\"? You can unarchive it later from archived tasks.")
Text(L10n.Tasks.archiveConfirm.replacingOccurrences(of: "this task", with: "\"\(task.title)\""))
}
}
.alert("Delete Task", isPresented: $showCancelConfirmation) {
Button("Cancel", role: .cancel) {
.alert(L10n.Tasks.deleteTask, isPresented: $showCancelConfirmation) {
Button(L10n.Common.cancel, role: .cancel) {
selectedTaskForCancel = nil
}
Button("Archive", role: .destructive) {
Button(L10n.Tasks.archive, role: .destructive) {
if let task = selectedTaskForCancel {
taskViewModel.cancelTask(id: task.id) { _ in
loadAllTasks()
@@ -88,7 +88,7 @@ struct AllTasksView: View {
}
} message: {
if let task = selectedTaskForCancel {
Text("Are you sure you want to archive \"\(task.title)\"? You can unarchive it later from archived tasks.")
Text(L10n.Tasks.archiveConfirm.replacingOccurrences(of: "this task", with: "\"\(task.title)\""))
}
}
.onChange(of: showAddTask) { isShowing in
@@ -129,16 +129,16 @@ struct AllTasksView: View {
.font(.system(size: 64))
.foregroundStyle(Color.appPrimary.opacity(0.6))
Text("No tasks yet")
Text(L10n.Tasks.noTasksYet)
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(Color.appTextPrimary)
Text("Create your first task to get started")
Text(L10n.Tasks.createFirst)
.font(.body)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
Button(action: {
// Check if we should show upgrade prompt before adding
if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") {
@@ -149,7 +149,7 @@ struct AllTasksView: View {
}) {
HStack(spacing: 8) {
Image(systemName: "plus")
Text("Add Task")
Text(L10n.Tasks.addButton)
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
@@ -160,13 +160,13 @@ struct AllTasksView: View {
.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")
Text(L10n.Tasks.addPropertyFirst)
.font(.caption)
.foregroundColor(Color.appError)
}
Spacer()
}
.padding()
@@ -235,7 +235,7 @@ struct AllTasksView: View {
}
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle("All Tasks")
.navigationTitle(L10n.Tasks.allTasks)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {

View File

@@ -51,7 +51,7 @@ struct CompleteTaskView: View {
}
}
} header: {
Text("Task Details")
Text(L10n.Tasks.taskDetails)
}
.listRowBackground(Color.appBackgroundSecondary)
@@ -61,7 +61,7 @@ struct CompleteTaskView: View {
showContractorPicker = true
}) {
HStack {
Label("Select Contractor", systemImage: "wrench.and.screwdriver")
Label(L10n.Tasks.selectContractor, systemImage: "wrench.and.screwdriver")
.foregroundStyle(.primary)
Spacer()
@@ -77,7 +77,7 @@ struct CompleteTaskView: View {
}
}
} else {
Text("None")
Text(L10n.Tasks.none)
.foregroundStyle(.tertiary)
}
@@ -87,20 +87,20 @@ struct CompleteTaskView: View {
}
}
} header: {
Text("Contractor (Optional)")
Text(L10n.Tasks.contractorOptional)
} footer: {
Text("Select a contractor if they completed this work, or leave blank for manual entry.")
Text(L10n.Tasks.contractorHelper)
}
.listRowBackground(Color.appBackgroundSecondary)
// Completion Details Section
Section {
LabeledContent {
TextField("Your name", text: $completedByName)
TextField(L10n.Tasks.yourName, text: $completedByName)
.multilineTextAlignment(.trailing)
.disabled(selectedContractor != nil)
} label: {
Label("Completed By", systemImage: "person")
Label(L10n.Tasks.completedBy, systemImage: "person")
}
LabeledContent {
@@ -113,19 +113,19 @@ struct CompleteTaskView: View {
}
.padding(.leading, 12)
} label: {
Label("Actual Cost", systemImage: "dollarsign.circle")
Label(L10n.Tasks.actualCost, systemImage: "dollarsign.circle")
}
} header: {
Text("Optional Information")
Text(L10n.Tasks.optionalInfo)
} footer: {
Text("Add any additional details about completing this task.")
Text(L10n.Tasks.optionalDetails)
}
.listRowBackground(Color.appBackgroundSecondary)
// Notes Section
Section {
VStack(alignment: .leading, spacing: 8) {
Label("Notes", systemImage: "note.text")
Label(L10n.Tasks.notes, systemImage: "note.text")
.font(.subheadline)
.foregroundStyle(.secondary)
@@ -134,7 +134,7 @@ struct CompleteTaskView: View {
.scrollContentBackground(.hidden)
}
} footer: {
Text("Optional notes about the work completed.")
Text(L10n.Tasks.optionalNotes)
}
.listRowBackground(Color.appBackgroundSecondary)
@@ -142,7 +142,7 @@ struct CompleteTaskView: View {
Section {
VStack(spacing: 12) {
HStack {
Label("Quality Rating", systemImage: "star")
Label(L10n.Tasks.qualityRating, systemImage: "star")
.font(.subheadline)
Spacer()
@@ -168,7 +168,7 @@ struct CompleteTaskView: View {
.frame(maxWidth: .infinity)
}
} footer: {
Text("Rate the quality of work from 1 to 5 stars.")
Text(L10n.Tasks.rateQuality)
}
.listRowBackground(Color.appBackgroundSecondary)
@@ -179,7 +179,7 @@ struct CompleteTaskView: View {
Button(action: {
showCamera = true
}) {
Label("Take Photo", systemImage: "camera")
Label(L10n.Tasks.takePhoto, systemImage: "camera")
.frame(maxWidth: .infinity)
.foregroundStyle(Color.appPrimary)
}
@@ -191,7 +191,7 @@ struct CompleteTaskView: View {
matching: .images,
photoLibrary: .shared()
) {
Label("Library", systemImage: "photo.on.rectangle.angled")
Label(L10n.Tasks.library, systemImage: "photo.on.rectangle.angled")
.frame(maxWidth: .infinity)
.foregroundStyle(Color.appPrimary)
}
@@ -230,9 +230,9 @@ struct CompleteTaskView: View {
}
}
} header: {
Text("Photos (\(selectedImages.count)/5)")
Text("\(L10n.Tasks.photos) (\(selectedImages.count)/5)")
} footer: {
Text("Add up to 5 photos documenting the completed work.")
Text(L10n.Tasks.addPhotos)
}
.listRowBackground(Color.appBackgroundSecondary)
@@ -244,7 +244,7 @@ struct CompleteTaskView: View {
ProgressView()
.tint(.white)
} else {
Label("Complete Task", systemImage: "checkmark.circle.fill")
Label(L10n.Tasks.completeTask, systemImage: "checkmark.circle.fill")
}
}
.frame(maxWidth: .infinity)
@@ -258,17 +258,17 @@ struct CompleteTaskView: View {
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle("Complete Task")
.navigationTitle(L10n.Tasks.completeTask)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
Button(L10n.Common.cancel) {
dismiss()
}
}
}
.alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) {}
.alert(L10n.Tasks.error, isPresented: $showError) {
Button(L10n.Common.ok, role: .cancel) {}
} message: {
Text(errorMessage)
}
@@ -386,9 +386,9 @@ struct ContractorPickerView: View {
}) {
HStack {
VStack(alignment: .leading) {
Text("None (Manual Entry)")
Text(L10n.Tasks.noneManual)
.foregroundStyle(.primary)
Text("Enter name manually")
Text(L10n.Tasks.enterManually)
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -450,11 +450,11 @@ struct ContractorPickerView: View {
}
}
}
.navigationTitle("Select Contractor")
.navigationTitle(L10n.Tasks.selectContractor)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
Button(L10n.Common.cancel) {
dismiss()
}
}

View File

@@ -21,11 +21,11 @@ struct CompletionHistorySheet: View {
completionsList
}
}
.navigationTitle("Completion History")
.navigationTitle(L10n.Tasks.completionHistory)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") {
Button(L10n.Common.done) {
isPresented = false
}
}
@@ -47,7 +47,7 @@ struct CompletionHistorySheet: View {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Color.appPrimary))
.scaleEffect(1.5)
Text("Loading completions...")
Text(L10n.Tasks.loadingCompletions)
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
}
@@ -60,7 +60,7 @@ struct CompletionHistorySheet: View {
.font(.system(size: 48))
.foregroundColor(Color.appError)
Text("Failed to load completions")
Text(L10n.Tasks.failedToLoad)
.font(.headline)
.foregroundColor(Color.appTextPrimary)
@@ -73,7 +73,7 @@ struct CompletionHistorySheet: View {
Button(action: {
viewModel.loadCompletions(taskId: taskId)
}) {
Label("Retry", systemImage: "arrow.clockwise")
Label(L10n.Common.retry, systemImage: "arrow.clockwise")
.foregroundColor(Color.appPrimary)
}
.padding(.top, AppSpacing.sm)
@@ -87,11 +87,11 @@ struct CompletionHistorySheet: View {
.font(.system(size: 48))
.foregroundColor(Color.appTextSecondary.opacity(0.5))
Text("No Completions Yet")
Text(L10n.Tasks.noCompletionsYet)
.font(.headline)
.foregroundColor(Color.appTextPrimary)
Text("This task has not been completed.")
Text(L10n.Tasks.notCompleted)
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
}
@@ -110,7 +110,7 @@ struct CompletionHistorySheet: View {
.fontWeight(.semibold)
.foregroundColor(Color.appTextPrimary)
Spacer()
Text("\(viewModel.completions.count) \(viewModel.completions.count == 1 ? "completion" : "completions")")
Text("\(viewModel.completions.count) \(viewModel.completions.count == 1 ? L10n.Tasks.completion : L10n.Tasks.completions)")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
@@ -146,7 +146,7 @@ struct CompletionHistoryCard: View {
HStack(spacing: 4) {
Image(systemName: "person.fill")
.font(.caption2)
Text("Completed by \(completedBy)")
Text("\(L10n.Tasks.completedByName) \(completedBy)")
.font(.caption)
}
.foregroundColor(Color.appTextSecondary)
@@ -213,7 +213,7 @@ struct CompletionHistoryCard: View {
// Notes
if !completion.notes.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Notes")
Text(L10n.Tasks.notes)
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(Color.appTextSecondary)
@@ -233,7 +233,7 @@ struct CompletionHistoryCard: View {
HStack {
Image(systemName: "photo.on.rectangle.angled")
.font(.subheadline)
Text("View Photos (\(completion.images.count))")
Text("\(L10n.Tasks.viewPhotos) (\(completion.images.count))")
.font(.subheadline)
.fontWeight(.semibold)
}

View File

@@ -97,8 +97,8 @@ struct TaskFormView: View {
// Residence Picker (only if needed)
if needsResidenceSelection, let residences = residences {
Section {
Picker("Property", selection: $selectedResidence) {
Text("Select Property").tag(nil as ResidenceResponse?)
Picker(L10n.Tasks.property, selection: $selectedResidence) {
Text(L10n.Tasks.selectProperty).tag(nil as ResidenceResponse?)
ForEach(residences, id: \.id) { residence in
Text(residence.name).tag(residence as ResidenceResponse?)
}
@@ -110,9 +110,9 @@ struct TaskFormView: View {
.foregroundColor(Color.appError)
}
} header: {
Text("Property")
Text(L10n.Tasks.property)
} footer: {
Text("Required")
Text(L10n.Tasks.required)
.font(.caption)
.foregroundColor(Color.appError)
}
@@ -120,7 +120,7 @@ struct TaskFormView: View {
}
Section {
TextField("Title", text: $title)
TextField(L10n.Tasks.titleLabel, text: $title)
.focused($focusedField, equals: .title)
if !titleError.isEmpty {
@@ -129,83 +129,83 @@ struct TaskFormView: View {
.foregroundColor(Color.appError)
}
TextField("Description (optional)", text: $description, axis: .vertical)
TextField(L10n.Tasks.descriptionOptional, text: $description, axis: .vertical)
.lineLimit(3...6)
.focused($focusedField, equals: .description)
} header: {
Text("Task Details")
Text(L10n.Tasks.taskDetails)
} footer: {
Text("Required: Title")
Text(L10n.Tasks.titleRequired)
.font(.caption)
.foregroundColor(Color.appError)
}
.listRowBackground(Color.appBackgroundSecondary)
Section {
Picker("Category", selection: $selectedCategory) {
Text("Select Category").tag(nil as TaskCategory?)
Picker(L10n.Tasks.category, selection: $selectedCategory) {
Text(L10n.Tasks.selectCategory).tag(nil as TaskCategory?)
ForEach(taskCategories, id: \.id) { category in
Text(category.name.capitalized).tag(category as TaskCategory?)
}
}
} header: {
Text("Category")
Text(L10n.Tasks.category)
} footer: {
Text("Required")
Text(L10n.Tasks.required)
.font(.caption)
.foregroundColor(Color.appError)
}
.listRowBackground(Color.appBackgroundSecondary)
Section {
Picker("Frequency", selection: $selectedFrequency) {
Text("Select Frequency").tag(nil as TaskFrequency?)
Picker(L10n.Tasks.frequency, selection: $selectedFrequency) {
Text(L10n.Tasks.selectFrequency).tag(nil as TaskFrequency?)
ForEach(taskFrequencies, id: \.id) { frequency in
Text(frequency.displayName).tag(frequency as TaskFrequency?)
}
}
if selectedFrequency?.name != "once" {
TextField("Custom Interval (days, optional)", text: $intervalDays)
TextField(L10n.Tasks.customInterval, text: $intervalDays)
.keyboardType(.numberPad)
.focused($focusedField, equals: .intervalDays)
}
DatePicker("Due Date", selection: $dueDate, displayedComponents: .date)
DatePicker(L10n.Tasks.dueDate, selection: $dueDate, displayedComponents: .date)
} header: {
Text("Scheduling")
Text(L10n.Tasks.scheduling)
} footer: {
Text("Required: Frequency")
Text(L10n.Tasks.required)
.font(.caption)
.foregroundColor(Color.appError)
}
.listRowBackground(Color.appBackgroundSecondary)
Section {
Picker("Priority", selection: $selectedPriority) {
Text("Select Priority").tag(nil as TaskPriority?)
Picker(L10n.Tasks.priority, selection: $selectedPriority) {
Text(L10n.Tasks.selectPriority).tag(nil as TaskPriority?)
ForEach(taskPriorities, id: \.id) { priority in
Text(priority.displayName).tag(priority as TaskPriority?)
}
}
Picker("Status", selection: $selectedStatus) {
Text("Select Status").tag(nil as TaskStatus?)
Picker(L10n.Tasks.status, selection: $selectedStatus) {
Text(L10n.Tasks.selectStatus).tag(nil as TaskStatus?)
ForEach(taskStatuses, id: \.id) { status in
Text(status.displayName).tag(status as TaskStatus?)
}
}
} header: {
Text("Priority & Status")
Text(L10n.Tasks.priorityAndStatus)
} footer: {
Text("Required: Both Priority and Status")
Text(L10n.Tasks.bothRequired)
.font(.caption)
.foregroundColor(Color.appError)
}
.listRowBackground(Color.appBackgroundSecondary)
Section(header: Text("Cost")) {
TextField("Estimated Cost (optional)", text: $estimatedCost)
Section(header: Text(L10n.Tasks.cost)) {
TextField(L10n.Tasks.estimatedCost, text: $estimatedCost)
.keyboardType(.decimalPad)
.focused($focusedField, equals: .estimatedCost)
}
@@ -227,7 +227,7 @@ struct TaskFormView: View {
VStack(spacing: 16) {
ProgressView()
.scaleEffect(1.5)
Text("Loading...")
Text(L10n.Tasks.loading)
.foregroundColor(Color.appTextSecondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -237,18 +237,18 @@ struct TaskFormView: View {
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle(isEditMode ? "Edit Task" : "Add Task")
.navigationTitle(isEditMode ? L10n.Tasks.editTitle : L10n.Tasks.addTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
Button(L10n.Common.cancel) {
isPresented = false
}
.disabled(isLoadingLookups)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
Button(L10n.Common.save) {
submitForm()
}
.disabled(!canSave || viewModel.isLoading || isLoadingLookups)