i18n: complete app-wide localization (10 languages) + audit tooling
Android UI Tests / ui-tests (push) Has been cancelled
Android UI Tests / ui-tests (push) Has been cancelled
Localize all user-facing strings across iOS (SwiftUI), shared Kotlin, and Android Compose into en/es/fr/de/pt/it/ja/ko/nl/zh: - iOS String Catalogs: main + widget Localizable.xcstrings, InfoPlist.xcstrings (permissions), plural variations, ~200 new keys translated - Shared Kotlin ClientStrings table + Android composeResources/values-* (884 keys ×10), routed Api/ViewModel/util error & UI strings through localization - Backend-localized lookups/suggestions consumed via display names - Widget extension catalog; theme names, home-profile fallbacks, validation, network errors, accessibility labels all localized Add re-runnable verification gates: - scripts/i18n_audit.py — enumerate every literal, partition to GAP=0 - scripts/i18n_coverage.py — all 10 locales translated, format-specifier parity Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,65 +1,78 @@
|
||||
import Foundation
|
||||
|
||||
/// Centralized accessibility labels for VoiceOver
|
||||
/// These labels provide human-readable descriptions for screen reader users
|
||||
/// Centralized, localized accessibility labels for VoiceOver.
|
||||
struct A11y {
|
||||
|
||||
// MARK: - Authentication
|
||||
struct Auth {
|
||||
static let loginButton = "Sign in"
|
||||
static let appleSignIn = "Sign in with Apple"
|
||||
static let googleSignIn = "Sign in with Google"
|
||||
static let forgotPassword = "Forgot password"
|
||||
static let signUp = "Create account"
|
||||
static let passwordToggle = "Toggle password visibility"
|
||||
static let appLogo = "honeyDue app logo"
|
||||
static var loginButton: String { String(localized: "Sign in") }
|
||||
static var appleSignIn: String { String(localized: "Sign in with Apple") }
|
||||
static var googleSignIn: String { String(localized: "Sign in with Google") }
|
||||
static var forgotPassword: String { String(localized: "Forgot password") }
|
||||
static var signUp: String { String(localized: "Create account") }
|
||||
static var passwordToggle: String { String(localized: "Toggle password visibility") }
|
||||
static var appLogo: String { String(localized: "honeyDue app logo") }
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
struct Navigation {
|
||||
static let residencesTab = "Properties"
|
||||
static let tasksTab = "Tasks"
|
||||
static let contractorsTab = "Contractors"
|
||||
static let documentsTab = "Documents"
|
||||
static let settingsButton = "Settings"
|
||||
static let addButton = "Add"
|
||||
static let backButton = "Back"
|
||||
static let closeButton = "Close"
|
||||
static let editButton = "Edit"
|
||||
static let deleteButton = "Delete"
|
||||
static let saveButton = "Save"
|
||||
static let cancelButton = "Cancel"
|
||||
static var residencesTab: String { String(localized: "Properties") }
|
||||
static var tasksTab: String { String(localized: "Tasks") }
|
||||
static var contractorsTab: String { String(localized: "Contractors") }
|
||||
static var documentsTab: String { String(localized: "Documents") }
|
||||
static var settingsButton: String { String(localized: "Settings") }
|
||||
static var addButton: String { String(localized: "Add") }
|
||||
static var backButton: String { String(localized: "Back") }
|
||||
static var closeButton: String { String(localized: "Close") }
|
||||
static var editButton: String { String(localized: "Edit") }
|
||||
static var deleteButton: String { String(localized: "Delete") }
|
||||
static var saveButton: String { String(localized: "Save") }
|
||||
static var cancelButton: String { String(localized: "Cancel") }
|
||||
}
|
||||
|
||||
// MARK: - Residence
|
||||
struct Residence {
|
||||
static func card(name: String, taskCount: Int, overdueCount: Int) -> String {
|
||||
"\(name), \(taskCount) tasks, \(overdueCount) overdue"
|
||||
String(format: String(localized: "%1$@, %2$lld tasks, %3$lld overdue"), name, taskCount, overdueCount)
|
||||
}
|
||||
static var addProperty: String { String(localized: "Add new property") }
|
||||
static var primaryBadge: String { String(localized: "Primary property") }
|
||||
static func openInMaps(address: String) -> String {
|
||||
String(format: String(localized: "Open %@ in Maps"), address)
|
||||
}
|
||||
static func shareCode(code: String) -> String {
|
||||
String(format: String(localized: "Share code: %@"), code)
|
||||
}
|
||||
static var copyShareCode: String { String(localized: "Copy share code") }
|
||||
static var generateShareCode: String { String(localized: "Generate new share code") }
|
||||
static func removeUser(name: String) -> String {
|
||||
String(format: String(localized: "Remove %@ from property"), name)
|
||||
}
|
||||
static let addProperty = "Add new property"
|
||||
static let primaryBadge = "Primary property"
|
||||
static func openInMaps(address: String) -> String { "Open \(address) in Maps" }
|
||||
static func shareCode(code: String) -> String { "Share code: \(code)" }
|
||||
static let copyShareCode = "Copy share code"
|
||||
static let generateShareCode = "Generate new share code"
|
||||
static func removeUser(name: String) -> String { "Remove \(name) from property" }
|
||||
}
|
||||
|
||||
// MARK: - Task
|
||||
struct Task {
|
||||
static func card(title: String, priority: String, dueDate: String) -> String {
|
||||
"\(title), \(priority) priority, due \(dueDate)"
|
||||
String(format: String(localized: "%1$@, %2$@ priority, due %3$@"), title, priority, dueDate)
|
||||
}
|
||||
static let addTask = "Add new task"
|
||||
static let taskActions = "Task actions"
|
||||
static func priorityBadge(level: String) -> String { "Priority: \(level)" }
|
||||
static func statusBadge(status: String) -> String { "Status: \(status)" }
|
||||
static func completionCount(count: Int) -> String { "View \(count) completions" }
|
||||
static func rating(value: Int) -> String { "Rated \(value) out of 5" }
|
||||
static let markInProgress = "Mark as in progress"
|
||||
static let completeTask = "Complete task"
|
||||
static let archiveTask = "Archive task"
|
||||
static let cancelTask = "Cancel task"
|
||||
static var addTask: String { String(localized: "Add new task") }
|
||||
static var taskActions: String { String(localized: "Task actions") }
|
||||
static func priorityBadge(level: String) -> String {
|
||||
String(format: String(localized: "Priority: %@"), level)
|
||||
}
|
||||
static func statusBadge(status: String) -> String {
|
||||
String(format: String(localized: "Status: %@"), status)
|
||||
}
|
||||
static func completionCount(count: Int) -> String {
|
||||
String(format: String(localized: "View %lld completions"), count)
|
||||
}
|
||||
static func rating(value: Int) -> String {
|
||||
String(format: String(localized: "Rated %lld out of 5"), value)
|
||||
}
|
||||
static var markInProgress: String { String(localized: "Mark as in progress") }
|
||||
static var completeTask: String { String(localized: "Complete task") }
|
||||
static var archiveTask: String { String(localized: "Archive task") }
|
||||
static var cancelTask: String { String(localized: "Cancel task") }
|
||||
}
|
||||
|
||||
// MARK: - Contractor
|
||||
@@ -67,25 +80,33 @@ struct A11y {
|
||||
static func card(name: String, company: String?, specialty: String) -> String {
|
||||
[name, company, specialty].compactMap { $0 }.joined(separator: ", ")
|
||||
}
|
||||
static let addContractor = "Add new contractor"
|
||||
static var addContractor: String { String(localized: "Add new contractor") }
|
||||
static func toggleFavorite(name: String, isFavorite: Bool) -> String {
|
||||
isFavorite ? "Remove \(name) from favorites" : "Add \(name) to favorites"
|
||||
isFavorite
|
||||
? String(format: String(localized: "Remove %@ from favorites"), name)
|
||||
: String(format: String(localized: "Add %@ to favorites"), name)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Document
|
||||
struct Document {
|
||||
static func card(title: String, type: String) -> String { "\(title), \(type)" }
|
||||
static let addDocument = "Add new document"
|
||||
static func card(title: String, type: String) -> String {
|
||||
String(format: String(localized: "%1$@, %2$@"), title, type)
|
||||
}
|
||||
static var addDocument: String { String(localized: "Add new document") }
|
||||
}
|
||||
|
||||
// MARK: - Common
|
||||
struct Common {
|
||||
static func stat(value: String, label: String) -> String { "\(value) \(label)" }
|
||||
static func stat(value: String, label: String) -> String {
|
||||
String(format: String(localized: "%1$@ %2$@"), value, label)
|
||||
}
|
||||
static let decorative = "" // For .accessibilityHidden(true)
|
||||
static let retryButton = "Try again"
|
||||
static let dismissError = "Dismiss error"
|
||||
static func photo(index: Int) -> String { "Photo \(index)" }
|
||||
static let removePhoto = "Remove photo"
|
||||
static var retryButton: String { String(localized: "Try again") }
|
||||
static var dismissError: String { String(localized: "Dismiss error") }
|
||||
static func photo(index: Int) -> String {
|
||||
String(format: String(localized: "Photo %lld"), index)
|
||||
}
|
||||
static var removePhoto: String { String(localized: "Remove photo") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ enum DateUtils {
|
||||
|
||||
private static let dateTimeFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM d, yyyy 'at' h:mm a"
|
||||
formatter.dateFormat = "MMM d, yyyy 'at' h:mm a" // i18n-ignore: DateFormatter pattern (non-UI)
|
||||
return formatter
|
||||
}()
|
||||
|
||||
@@ -53,11 +53,11 @@ enum DateUtils {
|
||||
|
||||
switch daysDiff {
|
||||
case 0:
|
||||
return "Today"
|
||||
return String(localized: "Today")
|
||||
case 1:
|
||||
return "Tomorrow"
|
||||
return String(localized: "Tomorrow")
|
||||
case -1:
|
||||
return "Yesterday"
|
||||
return String(localized: "Yesterday")
|
||||
default:
|
||||
return mediumDateFormatter.string(from: date)
|
||||
}
|
||||
@@ -102,11 +102,11 @@ enum DateUtils {
|
||||
|
||||
switch daysDiff {
|
||||
case 0:
|
||||
return "Today"
|
||||
return String(localized: "Today")
|
||||
case 1:
|
||||
return "Tomorrow"
|
||||
return String(localized: "Tomorrow")
|
||||
case -1:
|
||||
return "Yesterday"
|
||||
return String(localized: "Yesterday")
|
||||
default:
|
||||
return mediumDateFormatter.string(from: parsedDate)
|
||||
}
|
||||
@@ -149,15 +149,15 @@ enum DateUtils {
|
||||
|
||||
switch daysDiff {
|
||||
case 0:
|
||||
return "Today"
|
||||
return String(localized: "Today")
|
||||
case 1:
|
||||
return "Tomorrow"
|
||||
return String(localized: "Tomorrow")
|
||||
case -1:
|
||||
return "Yesterday"
|
||||
return String(localized: "Yesterday")
|
||||
case 2...7:
|
||||
return "in \(daysDiff) days"
|
||||
return String(format: String(localized: "in %lld days"), daysDiff)
|
||||
case -7 ... -2:
|
||||
return "\(-daysDiff) days ago"
|
||||
return String(format: String(localized: "%lld days ago"), -daysDiff)
|
||||
default:
|
||||
return mediumDateFormatter.string(from: date)
|
||||
}
|
||||
@@ -247,13 +247,13 @@ enum DateUtils {
|
||||
static func formatHour(_ hour: Int) -> String {
|
||||
switch hour {
|
||||
case 0:
|
||||
return "12:00 AM"
|
||||
return String(localized: "12:00 AM")
|
||||
case 1..<12:
|
||||
return "\(hour):00 AM"
|
||||
return String(format: String(localized: "%lld:00 AM"), hour)
|
||||
case 12:
|
||||
return "12:00 PM"
|
||||
return String(localized: "12:00 PM")
|
||||
default:
|
||||
return "\(hour - 12):00 PM"
|
||||
return String(format: String(localized: "%lld:00 PM"), hour - 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ struct ErrorAlertModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.alert(
|
||||
error?.title ?? "Network Error",
|
||||
error?.title ?? String(localized: "Network Error"),
|
||||
isPresented: Binding(
|
||||
get: { error != nil },
|
||||
set: { if !$0 { onDismiss() } }
|
||||
@@ -35,7 +35,7 @@ struct ErrorAlertInfo: Identifiable {
|
||||
let title: String
|
||||
let message: String
|
||||
|
||||
init(title: String = "Network Error", message: String) {
|
||||
init(title: String = String(localized: "Network Error"), message: String) {
|
||||
self.title = title
|
||||
self.message = message
|
||||
}
|
||||
|
||||
@@ -8,154 +8,155 @@ enum ErrorMessageParser {
|
||||
/// 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.",
|
||||
"error.invalid_credentials": String(localized: "Invalid username or password. Please try again."),
|
||||
"error.invalid_token": String(localized: "Your session has expired. Please log in again."),
|
||||
"error.not_authenticated": String(localized: "Please log in to continue."),
|
||||
"error.account_inactive": String(localized: "Your account is inactive. Please contact support."),
|
||||
"error.username_taken": String(localized: "This username is already taken. Please choose another."),
|
||||
"error.email_taken": String(localized: "This email is already registered. Try logging in instead."),
|
||||
"error.email_already_taken": String(localized: "This email is already in use."),
|
||||
"error.registration_failed": String(localized: "Registration failed. Please try again."),
|
||||
"error.failed_to_get_user": String(localized: "Unable to load your profile. Please try again."),
|
||||
"error.failed_to_update_profile": String(localized: "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.",
|
||||
"error.invalid_verification_code": String(localized: "Invalid verification code. Please check and try again."),
|
||||
"error.verification_code_expired": String(localized: "Your verification code has expired. Please request a new one."),
|
||||
"error.email_already_verified": String(localized: "Your email is already verified."),
|
||||
"error.verification_failed": String(localized: "Verification failed. Please try again."),
|
||||
"error.failed_to_resend_verification": String(localized: "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_requests": "Too many requests. Please try again later.",
|
||||
"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.",
|
||||
"error.rate_limit_exceeded": String(localized: "Too many attempts. Please wait a few minutes and try again."),
|
||||
"error.too_many_requests": String(localized: "Too many requests. Please try again later."),
|
||||
"error.too_many_attempts": String(localized: "Too many attempts. Please request a new code."),
|
||||
"error.invalid_reset_token": String(localized: "This reset link has expired. Please request a new one."),
|
||||
"error.password_reset_failed": String(localized: "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.",
|
||||
"error.apple_signin_not_configured": String(localized: "Apple Sign In is not available. Please use email login."),
|
||||
"error.apple_signin_failed": String(localized: "Apple Sign In failed. Please try again."),
|
||||
"error.invalid_apple_token": String(localized: "Apple Sign In failed. Please try again."),
|
||||
"error.google_signin_not_configured": String(localized: "Google Sign In is not available. Please use email login."),
|
||||
"error.google_signin_failed": String(localized: "Google Sign In failed. Please try again."),
|
||||
"error.invalid_google_token": String(localized: "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.",
|
||||
"error.task_not_found": String(localized: "Task not found. It may have been deleted."),
|
||||
"error.residence_not_found": String(localized: "Property not found. It may have been deleted."),
|
||||
"error.contractor_not_found": String(localized: "Contractor not found. It may have been deleted."),
|
||||
"error.document_not_found": String(localized: "Document not found. It may have been deleted."),
|
||||
"error.completion_not_found": String(localized: "Task completion not found."),
|
||||
"error.user_not_found": String(localized: "User not found."),
|
||||
"error.notification_not_found": String(localized: "Notification not found."),
|
||||
"error.template_not_found": String(localized: "Task template not found."),
|
||||
"error.upgrade_trigger_not_found": String(localized: "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.",
|
||||
"error.task_access_denied": String(localized: "You don't have permission to view this task."),
|
||||
"error.residence_access_denied": String(localized: "You don't have permission to view this property."),
|
||||
"error.contractor_access_denied": String(localized: "You don't have permission to view this contractor."),
|
||||
"error.document_access_denied": String(localized: "You don't have permission to view this document."),
|
||||
"error.not_residence_owner": String(localized: "Only the property owner can do this."),
|
||||
"error.cannot_remove_owner": String(localized: "The property owner cannot be removed."),
|
||||
"error.access_denied": String(localized: "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.",
|
||||
"error.share_code_invalid": String(localized: "Invalid share code. Please check and try again."),
|
||||
"error.share_code_expired": String(localized: "This share code has expired. Please request a new one."),
|
||||
"error.user_already_member": String(localized: "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.",
|
||||
"error.properties_limit_reached": String(localized: "You've reached your property limit. Upgrade to add more."),
|
||||
"error.properties_limit_exceeded": String(localized: "You've reached your property limit. Upgrade to add more."),
|
||||
"error.tasks_limit_exceeded": String(localized: "You've reached your task limit. Upgrade to add more."),
|
||||
"error.contractors_limit_exceeded": String(localized: "You've reached your contractor limit. Upgrade to add more."),
|
||||
"error.documents_limit_exceeded": String(localized: "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.",
|
||||
"error.task_already_cancelled": String(localized: "This task has already been cancelled."),
|
||||
"error.task_already_archived": String(localized: "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.",
|
||||
"error.failed_to_parse_form": String(localized: "Unable to process the form. Please try again."),
|
||||
"error.task_id_required": String(localized: "Task ID is required."),
|
||||
"error.residence_id_required": String(localized: "Property ID is required."),
|
||||
"error.title_required": String(localized: "Title is required."),
|
||||
"error.failed_to_upload_image": String(localized: "Unable to upload image. Please try again."),
|
||||
"error.failed_to_upload_file": String(localized: "Unable to upload file. Please try again."),
|
||||
"error.no_file_provided": String(localized: "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.",
|
||||
"error.invalid_task_id": String(localized: "Invalid task."),
|
||||
"error.invalid_task_id_value": String(localized: "Invalid task."),
|
||||
"error.invalid_residence_id": String(localized: "Invalid property."),
|
||||
"error.invalid_residence_id_value": String(localized: "Invalid property."),
|
||||
"error.invalid_contractor_id": String(localized: "Invalid contractor."),
|
||||
"error.invalid_document_id": String(localized: "Invalid document."),
|
||||
"error.invalid_completion_id": String(localized: "Invalid task completion."),
|
||||
"error.invalid_user_id": String(localized: "Invalid user."),
|
||||
"error.invalid_notification_id": String(localized: "Invalid notification."),
|
||||
"error.invalid_device_id": String(localized: "Invalid device."),
|
||||
"error.invalid_platform": String(localized: "Invalid platform."),
|
||||
"error.invalid_id": String(localized: "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.",
|
||||
"error.failed_to_fetch_residence_types": String(localized: "Unable to load property types. Please try again."),
|
||||
"error.failed_to_fetch_task_categories": String(localized: "Unable to load task categories. Please try again."),
|
||||
"error.failed_to_fetch_task_priorities": String(localized: "Unable to load task priorities. Please try again."),
|
||||
"error.failed_to_fetch_task_frequencies": String(localized: "Unable to load task frequencies. Please try again."),
|
||||
"error.failed_to_fetch_task_statuses": String(localized: "Unable to load task statuses. Please try again."),
|
||||
"error.failed_to_fetch_contractor_specialties": String(localized: "Unable to load contractor specialties. Please try again."),
|
||||
"error.failed_to_fetch_templates": String(localized: "Unable to load task templates. Please try again."),
|
||||
"error.failed_to_search_templates": String(localized: "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.",
|
||||
"error.receipt_data_required": String(localized: "Purchase verification failed. Please try again."),
|
||||
"error.purchase_token_required": String(localized: "Purchase verification failed. Please try again."),
|
||||
|
||||
// Media errors
|
||||
"error.file_not_found": "File not found.",
|
||||
"error.image_not_found": "Image not found.",
|
||||
"error.file_not_found": String(localized: "File not found."),
|
||||
"error.image_not_found": String(localized: "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."
|
||||
"error.invalid_request": String(localized: "Invalid request. Please try again."),
|
||||
"error.invalid_request_body": String(localized: "Invalid request. Please check your input."),
|
||||
"error.internal": String(localized: "Something went wrong. Please try again."),
|
||||
"error.query_required": String(localized: "Search query is required."),
|
||||
"error.query_too_short": String(localized: "Search query is too short.")
|
||||
]
|
||||
|
||||
// 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."),
|
||||
("Too many requests", "Too many requests. Please try again later.")
|
||||
("Could not connect to the server", String(localized: "Unable to connect. Please check your internet connection.")), // i18n-ignore: lhs=error-detection substring
|
||||
("NSURLErrorDomain", String(localized: "Unable to connect. Please check your internet connection.")), // i18n-ignore: lhs=error-detection substring
|
||||
("The Internet connection appears to be offline", String(localized: "No internet connection. Please check your network.")), // i18n-ignore: lhs=error-detection substring
|
||||
("A server with the specified hostname could not be found", String(localized: "Unable to connect. Please check your internet connection.")), // i18n-ignore: lhs=error-detection substring
|
||||
("The request timed out", String(localized: "Request timed out. Please try again.")), // i18n-ignore: lhs=error-detection substring
|
||||
("The network connection was lost", String(localized: "Connection lost. Please try again.")), // i18n-ignore: lhs=error-detection substring
|
||||
("An SSL error has occurred", String(localized: "Secure connection failed. Please try again.")), // i18n-ignore: lhs=error-detection substring
|
||||
("CFNetwork", String(localized: "Unable to connect. Please check your internet connection.")), // i18n-ignore: lhs=error-detection substring
|
||||
("kCFStreamError", String(localized: "Unable to connect. Please check your internet connection.")), // i18n-ignore: lhs=error-detection substring
|
||||
("Code=-1004", String(localized: "Unable to connect. Please check your internet connection.")), // i18n-ignore: lhs=error-detection substring
|
||||
("Code=-1009", String(localized: "No internet connection. Please check your network.")), // i18n-ignore: lhs=error-detection substring
|
||||
("Code=-1001", String(localized: "Request timed out. Please try again.")), // i18n-ignore: lhs=error-detection substring
|
||||
("Code=-1003", String(localized: "Unable to connect. Please check your internet connection.")), // i18n-ignore: lhs=error-detection substring
|
||||
("Code=-1005", String(localized: "Connection lost. Please try again.")), // i18n-ignore: lhs=error-detection substring
|
||||
("Code=-1200", String(localized: "Secure connection failed. Please try again.")), // i18n-ignore: lhs=error-detection substring
|
||||
("UnresolvedAddressException", String(localized: "Unable to connect. Please check your internet connection.")), // i18n-ignore: lhs=error-detection substring
|
||||
("ConnectException", String(localized: "Unable to connect. Please check your internet connection.")), // i18n-ignore: lhs=error-detection substring
|
||||
("SocketTimeoutException", String(localized: "Request timed out. Please try again.")), // i18n-ignore: lhs=error-detection substring
|
||||
("Connection refused", String(localized: "Unable to connect. The server may be temporarily unavailable.")), // i18n-ignore: lhs=error-detection substring
|
||||
("Connection reset", String(localized: "Connection lost. Please try again.")), // i18n-ignore: lhs=error-detection substring
|
||||
("Too many requests", String(localized: "Too many requests. Please try again later.")) // i18n-ignore: lhs=error-detection substring
|
||||
]
|
||||
|
||||
// MARK: - Technical Error Indicators
|
||||
|
||||
/// Indicators that a message is technical/developer-facing
|
||||
// i18n-ignore-begin: error-detection substrings matched against incoming exception text (non-UI)
|
||||
private static let technicalIndicators = [
|
||||
"Exception",
|
||||
"Error Domain=",
|
||||
@@ -177,6 +178,7 @@ enum ErrorMessageParser {
|
||||
"NSUnderlyingError",
|
||||
"_kCF"
|
||||
]
|
||||
// i18n-ignore-end
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
@@ -205,19 +207,19 @@ enum ErrorMessageParser {
|
||||
|
||||
// Check if it looks like a technical exception message
|
||||
if isTechnicalError(trimmed) {
|
||||
return "Something went wrong. Please try again."
|
||||
return String(localized: "Something went wrong. Please try again.")
|
||||
}
|
||||
|
||||
// Check if the message looks like JSON (starts with { or [)
|
||||
guard trimmed.hasPrefix("{") || trimmed.hasPrefix("[") else {
|
||||
// Not JSON - if it's a short, readable message, return it
|
||||
// Otherwise return a generic message
|
||||
return isUserFriendly(trimmed) ? trimmed : "Something went wrong. Please try again."
|
||||
return isUserFriendly(trimmed) ? trimmed : String(localized: "Something went wrong. Please try again.")
|
||||
}
|
||||
|
||||
// If it's JSON, try to parse and extract meaningful error info
|
||||
guard let data = trimmed.data(using: .utf8) else {
|
||||
return "An error occurred. Please try again."
|
||||
return String(localized: "An error occurred. Please try again.")
|
||||
}
|
||||
|
||||
do {
|
||||
@@ -236,7 +238,7 @@ enum ErrorMessageParser {
|
||||
|
||||
// Check if this looks like a data object (has id, title, etc)
|
||||
if json["id"] != nil && (json["title"] != nil || json["name"] != nil) {
|
||||
return "Request failed. Please check your input and try again."
|
||||
return String(localized: "Request failed. Please check your input and try again.")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -244,7 +246,7 @@ enum ErrorMessageParser {
|
||||
}
|
||||
|
||||
// If we couldn't parse or extract a message, return a generic error
|
||||
return "An error occurred. Please try again."
|
||||
return String(localized: "An error occurred. Please try again.")
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
@@ -252,7 +254,7 @@ enum ErrorMessageParser {
|
||||
/// 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: "")
|
||||
var message = code.replacingOccurrences(of: "error.", with: "") // i18n-ignore: error-code key prefix (non-UI)
|
||||
message = message.replacingOccurrences(of: "_", with: " ")
|
||||
|
||||
// Capitalize first letter
|
||||
@@ -260,7 +262,7 @@ enum ErrorMessageParser {
|
||||
message = firstChar.uppercased() + message.dropFirst()
|
||||
}
|
||||
|
||||
return message + ". Please try again."
|
||||
return String(format: String(localized: "%@. Please try again."), message)
|
||||
}
|
||||
|
||||
/// Checks if the message looks like a technical/developer error (stack trace, exception, etc)
|
||||
|
||||
@@ -27,16 +27,16 @@ enum PresignedUploaderError: Error, LocalizedError {
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notAuthenticated:
|
||||
return "You're not signed in."
|
||||
return String(localized: "You're not signed in.")
|
||||
case .presignFailed(let status, _):
|
||||
switch status {
|
||||
case 413: return "That photo is too large after resizing. Try a different one."
|
||||
case 422: return "That image format isn't supported."
|
||||
case 429: return "You're uploading too many photos. Try again in a few minutes."
|
||||
default: return "Couldn't start upload (server returned \(status))."
|
||||
case 413: return String(localized: "That photo is too large after resizing. Try a different one.")
|
||||
case 422: return String(localized: "That image format isn't supported.")
|
||||
case 429: return String(localized: "You're uploading too many photos. Try again in a few minutes.")
|
||||
default: return String(format: String(localized: "Couldn't start upload (server returned %lld)."), status)
|
||||
}
|
||||
case .uploadFailed(let status, _):
|
||||
return "Upload failed (storage returned \(status))."
|
||||
return String(format: String(localized: "Upload failed (storage returned %lld)."), status)
|
||||
case .sessionError(let err):
|
||||
return err.localizedDescription
|
||||
}
|
||||
@@ -163,7 +163,7 @@ final class PresignedUploader {
|
||||
contentLength: Int64
|
||||
) async throws -> PresignResponse {
|
||||
guard var url = URL(string: apiBaseURL) else {
|
||||
throw PresignedUploaderError.presignFailed(status: 0, body: "invalid base url")
|
||||
throw PresignedUploaderError.presignFailed(status: 0, body: "invalid base url") // i18n-ignore: internal diagnostic body, not surfaced (errorDescription ignores it)
|
||||
}
|
||||
url.appendPathComponent("uploads/presign/")
|
||||
|
||||
@@ -185,7 +185,7 @@ final class PresignedUploader {
|
||||
throw PresignedUploaderError.sessionError(error)
|
||||
}
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw PresignedUploaderError.presignFailed(status: 0, body: "no response")
|
||||
throw PresignedUploaderError.presignFailed(status: 0, body: "no response") // i18n-ignore: internal diagnostic body, not surfaced
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw PresignedUploaderError.presignFailed(
|
||||
@@ -196,7 +196,7 @@ final class PresignedUploader {
|
||||
do {
|
||||
return try JSONDecoder().decode(PresignResponse.self, from: body)
|
||||
} catch {
|
||||
throw PresignedUploaderError.presignFailed(status: http.statusCode, body: "decode failed: \(error)")
|
||||
throw PresignedUploaderError.presignFailed(status: http.statusCode, body: "decode failed: \(error)") // i18n-ignore: internal diagnostic body, not surfaced
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ final class PresignedUploader {
|
||||
contentType: String
|
||||
) async throws {
|
||||
guard let url = URL(string: uploadURL) else {
|
||||
throw PresignedUploaderError.uploadFailed(status: 0, body: "invalid upload url")
|
||||
throw PresignedUploaderError.uploadFailed(status: 0, body: "invalid upload url") // i18n-ignore: internal diagnostic body, not surfaced
|
||||
}
|
||||
|
||||
var req = URLRequest(url: url)
|
||||
@@ -241,7 +241,7 @@ final class PresignedUploader {
|
||||
throw PresignedUploaderError.sessionError(error)
|
||||
}
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw PresignedUploaderError.uploadFailed(status: 0, body: "no response")
|
||||
throw PresignedUploaderError.uploadFailed(status: 0, body: "no response") // i18n-ignore: internal diagnostic body, not surfaced
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw PresignedUploaderError.uploadFailed(
|
||||
|
||||
@@ -5,6 +5,7 @@ import Combine
|
||||
|
||||
// MARK: - Theme ID Enum
|
||||
enum ThemeID: String, CaseIterable, Codable {
|
||||
// i18n-ignore-begin: rawValues are stable persistence keys + asset-catalog name roots (non-UI); localized labels are in displayName below
|
||||
case bright = "Default"
|
||||
case teal = "Teal"
|
||||
case ocean = "Ocean"
|
||||
@@ -16,35 +17,59 @@ enum ThemeID: String, CaseIterable, Codable {
|
||||
case midnight = "Midnight"
|
||||
case desert = "Desert"
|
||||
case mint = "Mint"
|
||||
// i18n-ignore-end
|
||||
|
||||
var displayName: String {
|
||||
rawValue
|
||||
switch self {
|
||||
case .bright:
|
||||
return String(localized: "Default")
|
||||
case .teal:
|
||||
return String(localized: "Teal")
|
||||
case .ocean:
|
||||
return String(localized: "Ocean")
|
||||
case .forest:
|
||||
return String(localized: "Forest")
|
||||
case .sunset:
|
||||
return String(localized: "Sunset")
|
||||
case .monochrome:
|
||||
return String(localized: "Monochrome")
|
||||
case .lavender:
|
||||
return String(localized: "Lavender")
|
||||
case .crimson:
|
||||
return String(localized: "Crimson")
|
||||
case .midnight:
|
||||
return String(localized: "Midnight")
|
||||
case .desert:
|
||||
return String(localized: "Desert")
|
||||
case .mint:
|
||||
return String(localized: "Mint")
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .bright:
|
||||
return "Vibrant iOS system colors"
|
||||
return String(localized: "Vibrant iOS system colors")
|
||||
case .teal:
|
||||
return "Blue-green with warm accents"
|
||||
return String(localized: "Blue-green with warm accents")
|
||||
case .ocean:
|
||||
return "Deep blues and coral tones"
|
||||
return String(localized: "Deep blues and coral tones")
|
||||
case .forest:
|
||||
return "Earth greens and golden hues"
|
||||
return String(localized: "Earth greens and golden hues")
|
||||
case .sunset:
|
||||
return "Warm oranges and reds"
|
||||
return String(localized: "Warm oranges and reds")
|
||||
case .monochrome:
|
||||
return "Elegant grayscale"
|
||||
return String(localized: "Elegant grayscale")
|
||||
case .lavender:
|
||||
return "Soft purple with pink accents"
|
||||
return String(localized: "Soft purple with pink accents")
|
||||
case .crimson:
|
||||
return "Bold red with warm highlights"
|
||||
return String(localized: "Bold red with warm highlights")
|
||||
case .midnight:
|
||||
return "Deep navy with sky blue"
|
||||
return String(localized: "Deep navy with sky blue")
|
||||
case .desert:
|
||||
return "Warm terracotta and sand tones"
|
||||
return String(localized: "Warm terracotta and sand tones")
|
||||
case .mint:
|
||||
return "Fresh green with turquoise"
|
||||
return String(localized: "Fresh green with turquoise")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +91,7 @@ private let appGroupID: String = {
|
||||
private let sharedDefaults: UserDefaults = {
|
||||
guard let defaults = UserDefaults(suiteName: appGroupID) else {
|
||||
#if DEBUG
|
||||
assertionFailure("App Group '\(appGroupID)' not configured — theme won't sync to widgets")
|
||||
assertionFailure("App Group '\(appGroupID)' not configured — theme won't sync to widgets") // i18n-ignore: DEBUG assertion message (non-UI)
|
||||
#endif
|
||||
return UserDefaults.standard
|
||||
}
|
||||
@@ -92,8 +117,8 @@ class ThemeManager {
|
||||
sharedDefaults.bool(forKey: honeycombKey)
|
||||
}
|
||||
|
||||
private let themeKey = "selectedTheme"
|
||||
private let honeycombKey = "honeycombEnabled"
|
||||
private let themeKey = "selectedTheme" // i18n-ignore: UserDefaults key (non-UI)
|
||||
private let honeycombKey = "honeycombEnabled" // i18n-ignore: UserDefaults key (non-UI)
|
||||
|
||||
private init() {}
|
||||
}
|
||||
@@ -115,8 +140,8 @@ class ThemeManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private let themeKey = "selectedTheme"
|
||||
private let honeycombKey = "honeycombEnabled"
|
||||
private let themeKey = "selectedTheme" // i18n-ignore: UserDefaults key (non-UI)
|
||||
private let honeycombKey = "honeycombEnabled" // i18n-ignore: UserDefaults key (non-UI)
|
||||
|
||||
private init() {
|
||||
// Load saved theme from shared App Group defaults
|
||||
|
||||
@@ -4,11 +4,13 @@ import ComposeApp
|
||||
|
||||
/// Runtime contract between the app and XCUITests.
|
||||
enum UITestRuntime {
|
||||
// i18n-ignore-begin: launch-argument flag identifiers for XCUITests (non-UI)
|
||||
static let uiTestingFlag = "--ui-testing"
|
||||
static let disableAnimationsFlag = "--disable-animations"
|
||||
static let resetStateFlag = "--reset-state"
|
||||
static let mockAuthFlag = "--ui-test-mock-auth"
|
||||
static let completeOnboardingFlag = "--complete-onboarding"
|
||||
// i18n-ignore-end
|
||||
|
||||
static var launchArguments: [String] {
|
||||
ProcessInfo.processInfo.arguments
|
||||
|
||||
@@ -61,7 +61,7 @@ final class WidgetActionProcessor {
|
||||
let request = TaskCompletionCreateRequest(
|
||||
taskId: Int32(taskId),
|
||||
completedAt: nil, // Defaults to now on server
|
||||
notes: "Completed from widget",
|
||||
notes: String(localized: "Completed from widget"),
|
||||
actualCost: nil,
|
||||
rating: nil,
|
||||
uploadIds: nil
|
||||
|
||||
Reference in New Issue
Block a user