i18n: complete app-wide localization (10 languages) + audit tooling
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:
Trey T
2026-06-04 20:52:28 -05:00
parent 6058013951
commit db65db6232
211 changed files with 81756 additions and 22467 deletions
+70 -49
View File
@@ -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") }
}
}
+16 -16
View File
@@ -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
}
+118 -116
View File
@@ -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)
+11 -11
View File
@@ -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(
+42 -17
View File
@@ -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