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:
@@ -59,17 +59,21 @@ private func formatWidgetDate(_ dateString: String) -> String {
|
||||
let dueDay = calendar.startOfDay(for: parsedDate)
|
||||
|
||||
if calendar.isDateInToday(parsedDate) {
|
||||
return "Today"
|
||||
return String(localized: "Today")
|
||||
}
|
||||
|
||||
let components = calendar.dateComponents([.day], from: today, to: dueDay)
|
||||
let days = components.day ?? 0
|
||||
|
||||
if days > 0 {
|
||||
return days == 1 ? "in 1 day" : "in \(days) days"
|
||||
return days == 1
|
||||
? String(localized: "in 1 day")
|
||||
: String(format: String(localized: "in %lld days"), days)
|
||||
} else {
|
||||
let overdueDays = abs(days)
|
||||
return overdueDays == 1 ? "1 day ago" : "\(overdueDays) days ago"
|
||||
return overdueDays == 1
|
||||
? String(localized: "1 day ago")
|
||||
: String(format: String(localized: "%lld days ago"), overdueDays)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,7 +384,9 @@ struct FreeWidgetView: View {
|
||||
)
|
||||
}
|
||||
|
||||
Text(entry.taskCount == 1 ? "task waiting" : "tasks waiting")
|
||||
Text(entry.taskCount == 1
|
||||
? String(localized: "task waiting")
|
||||
: String(localized: "tasks waiting"))
|
||||
.font(.system(size: 14, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -423,7 +429,7 @@ struct SmallWidgetView: View {
|
||||
)
|
||||
|
||||
if entry.taskCount > 0 {
|
||||
Text(entry.taskCount == 1 ? "task" : "tasks")
|
||||
Text(entry.taskCount == 1 ? String(localized: "task") : String(localized: "tasks"))
|
||||
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(Color.appTextSecondary)
|
||||
}
|
||||
@@ -556,7 +562,7 @@ struct MediumWidgetView: View {
|
||||
)
|
||||
}
|
||||
|
||||
Text(entry.taskCount == 1 ? "task" : "tasks")
|
||||
Text(entry.taskCount == 1 ? String(localized: "task") : String(localized: "tasks"))
|
||||
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(Color.appTextSecondary)
|
||||
|
||||
@@ -748,7 +754,7 @@ struct LargeWidgetView: View {
|
||||
}
|
||||
|
||||
if entry.upcomingTasks.count > maxTasksToShow {
|
||||
Text("+ \(entry.upcomingTasks.count - maxTasksToShow) more")
|
||||
Text(String(format: String(localized: "+ %lld more"), entry.upcomingTasks.count - maxTasksToShow))
|
||||
.font(.system(size: 10, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(Color.appTextSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
@@ -776,21 +782,21 @@ struct OrganicStatsView: View {
|
||||
// Overdue
|
||||
OrganicStatPillWidget(
|
||||
value: entry.overdueCount,
|
||||
label: "Overdue",
|
||||
label: String(localized: "Overdue"),
|
||||
color: entry.overdueCount > 0 ? Color.appError : Color.appTextSecondary
|
||||
)
|
||||
|
||||
// Next 7 Days
|
||||
OrganicStatPillWidget(
|
||||
value: entry.dueNext7DaysCount,
|
||||
label: "7 Days",
|
||||
label: String(localized: "7 Days"),
|
||||
color: Color.appAccent
|
||||
)
|
||||
|
||||
// Next 30 Days
|
||||
OrganicStatPillWidget(
|
||||
value: entry.dueNext30DaysCount,
|
||||
label: "30 Days",
|
||||
label: String(localized: "30 Days"),
|
||||
color: Color.appPrimary
|
||||
)
|
||||
}
|
||||
@@ -816,7 +822,7 @@ struct OrganicStatPillWidget: View {
|
||||
)
|
||||
)
|
||||
|
||||
Text(label)
|
||||
Text(LocalizedStringKey(label))
|
||||
.font(.system(size: 9, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(Color.appTextSecondary)
|
||||
.lineLimit(1)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@ final class AnalyticsManager {
|
||||
|
||||
private static let host = "https://analytics.88oakapps.com"
|
||||
|
||||
private static let optOutKey = "analyticsOptedOut"
|
||||
private static let optOutKey = "analyticsOptedOut" // i18n-ignore: UserDefaults key (non-UI)
|
||||
private static let sessionReplayKey = "analytics_session_replay_enabled"
|
||||
|
||||
private var isConfigured = false
|
||||
@@ -110,7 +110,7 @@ final class AnalyticsManager {
|
||||
guard isConfigured else { return }
|
||||
PostHogSDK.shared.capture("$exception", properties: [
|
||||
"$exception_type": exception.name.rawValue,
|
||||
"$exception_message": exception.reason ?? "No reason",
|
||||
"$exception_message": exception.reason ?? "No reason", // i18n-ignore: analytics event payload (non-UI)
|
||||
"$exception_stack_trace_raw": exception.callStackSymbols.joined(separator: "\n"),
|
||||
"$exception_is_fatal": isFatal
|
||||
])
|
||||
@@ -124,7 +124,7 @@ final class AnalyticsManager {
|
||||
// so call PostHogSDK directly.
|
||||
PostHogSDK.shared.capture("$exception", properties: [
|
||||
"$exception_type": exception.name.rawValue,
|
||||
"$exception_message": exception.reason ?? "No reason",
|
||||
"$exception_message": exception.reason ?? "No reason", // i18n-ignore: analytics event payload (non-UI)
|
||||
"$exception_stack_trace_raw": exception.callStackSymbols.joined(separator: "\n"),
|
||||
"$exception_is_fatal": true
|
||||
])
|
||||
|
||||
@@ -182,7 +182,7 @@ final class BackgroundTaskManager {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
formatter.timeStyle = .short
|
||||
return "Next refresh scheduled for approximately: \(formatter.string(from: nextDate))"
|
||||
return "Next refresh scheduled for approximately: \(formatter.string(from: nextDate))" // i18n-ignore: debug-only scheduling status (non-UI)
|
||||
}
|
||||
|
||||
/// Force a background refresh for testing (only works in debug builds with Xcode)
|
||||
|
||||
@@ -110,7 +110,7 @@ private class AuthenticatedImageLoader: ObservableObject {
|
||||
_ = Self.memoryWarningObserver
|
||||
|
||||
guard let mediaURL = mediaURL, !mediaURL.isEmpty else {
|
||||
state = .failure(NSError(domain: "AuthenticatedImage", code: -1, userInfo: [NSLocalizedDescriptionKey: "No URL provided"]))
|
||||
state = .failure(NSError(domain: "AuthenticatedImage", code: -1, userInfo: [NSLocalizedDescriptionKey: "No URL provided"])) // i18n-ignore: internal NSError description, never surfaced to UI (failure shows generic errorView)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ private class AuthenticatedImageLoader: ObservableObject {
|
||||
private func loadImage(mediaURL: String) async {
|
||||
// Get auth token
|
||||
guard let token = TokenStorage.shared.getToken() else {
|
||||
state = .failure(NSError(domain: "AuthenticatedImage", code: 401, userInfo: [NSLocalizedDescriptionKey: "Not authenticated"]))
|
||||
state = .failure(NSError(domain: "AuthenticatedImage", code: 401, userInfo: [NSLocalizedDescriptionKey: "Not authenticated"])) // i18n-ignore: internal NSError description, never surfaced to UI
|
||||
return
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ private class AuthenticatedImageLoader: ObservableObject {
|
||||
let fullURL = baseURL + mediaURL
|
||||
|
||||
guard let url = URL(string: fullURL) else {
|
||||
state = .failure(NSError(domain: "AuthenticatedImage", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL: \(fullURL)"]))
|
||||
state = .failure(NSError(domain: "AuthenticatedImage", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL: \(fullURL)"])) // i18n-ignore: internal NSError description, never surfaced to UI
|
||||
return
|
||||
}
|
||||
|
||||
@@ -170,14 +170,14 @@ private class AuthenticatedImageLoader: ObservableObject {
|
||||
// Validate response
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
guard (200...299).contains(httpResponse.statusCode) else {
|
||||
state = .failure(NSError(domain: "AuthenticatedImage", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "HTTP \(httpResponse.statusCode)"]))
|
||||
state = .failure(NSError(domain: "AuthenticatedImage", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "HTTP \(httpResponse.statusCode)"])) // i18n-ignore: internal NSError description, never surfaced to UI
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to UIImage
|
||||
guard let image = UIImage(data: data) else {
|
||||
state = .failure(NSError(domain: "AuthenticatedImage", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid image data"]))
|
||||
state = .failure(NSError(domain: "AuthenticatedImage", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid image data"])) // i18n-ignore: internal NSError description, never surfaced to UI
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ struct ContractorCard: View {
|
||||
.foregroundColor(contractor.isFavorite ? Color.appAccent : Color.appTextSecondary.opacity(0.7))
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.accessibilityLabel(contractor.isFavorite ? "Remove \(contractor.name) from favorites" : "Add \(contractor.name) to favorites")
|
||||
.accessibilityLabel(contractor.isFavorite ? String(format: String(localized: "Remove %@ from favorites"), contractor.name) : String(format: String(localized: "Add %@ to favorites"), contractor.name))
|
||||
|
||||
// Chevron
|
||||
Image(systemName: "chevron.right")
|
||||
|
||||
@@ -230,7 +230,7 @@ struct ContractorDetailView: View {
|
||||
HStack(spacing: AppSpacing.xxs) {
|
||||
Image(systemName: "wrench.and.screwdriver")
|
||||
.font(.caption)
|
||||
Text(specialty.name)
|
||||
Text(specialty.displayName)
|
||||
.font(.body)
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.sm)
|
||||
@@ -293,7 +293,7 @@ struct ContractorDetailView: View {
|
||||
label: L10n.Contractors.callAction,
|
||||
color: Color.appPrimary
|
||||
) {
|
||||
if let url = URL(string: "tel:\(phone.replacingOccurrences(of: " ", with: ""))") {
|
||||
if let url = URL(string: "tel:\(phone.replacingOccurrences(of: " ", with: ""))") { // i18n-ignore: tel: URL construction (non-UI)
|
||||
openURL(url)
|
||||
}
|
||||
}
|
||||
@@ -385,7 +385,7 @@ struct ContractorDetailView: View {
|
||||
value: phone,
|
||||
iconColor: Color.appPrimary
|
||||
) {
|
||||
if let url = URL(string: "tel:\(phone.replacingOccurrences(of: " ", with: ""))") {
|
||||
if let url = URL(string: "tel:\(phone.replacingOccurrences(of: " ", with: ""))") { // i18n-ignore: tel: URL construction (non-UI)
|
||||
openURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,7 +413,7 @@ struct ContractorFormSheet: View {
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Text(specialty.name)
|
||||
Text(specialty.displayName)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
Spacer()
|
||||
if selectedSpecialtyIds.contains(specialty.id) {
|
||||
|
||||
@@ -30,7 +30,7 @@ class ContractorSharingManager: ObservableObject {
|
||||
/// - Returns: URL to the temporary file, or nil if creation failed
|
||||
func createShareableFile(contractor: Contractor) -> URL? {
|
||||
// Get current username for export metadata
|
||||
let currentUsername = DataManagerObservable.shared.currentUser?.username ?? "Unknown"
|
||||
let currentUsername = DataManagerObservable.shared.currentUser?.username ?? "Unknown" // i18n-ignore: export-metadata fallback serialized into share file (non-UI)
|
||||
|
||||
let jsonContent = HoneyDueShareCodec.shared.encodeContractorPackage(
|
||||
contractor: contractor,
|
||||
@@ -68,7 +68,7 @@ class ContractorSharingManager: ObservableObject {
|
||||
|
||||
// Verify user is authenticated
|
||||
guard TokenStorage.shared.getToken() != nil else {
|
||||
importError = "You must be logged in to import a contractor"
|
||||
importError = String(localized: "You must be logged in to import a contractor")
|
||||
isImporting = false
|
||||
completion(false)
|
||||
return
|
||||
@@ -109,7 +109,7 @@ class ContractorSharingManager: ObservableObject {
|
||||
self.isImporting = false
|
||||
completion(false)
|
||||
} else {
|
||||
self.importError = "Unknown error occurred"
|
||||
self.importError = String(localized: "Unknown error occurred")
|
||||
self.isImporting = false
|
||||
completion(false)
|
||||
}
|
||||
@@ -120,7 +120,7 @@ class ContractorSharingManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
importError = "Failed to read contractor file: \(error.localizedDescription)"
|
||||
importError = String(format: String(localized: "Failed to read contractor file: %@"), error.localizedDescription)
|
||||
isImporting = false
|
||||
completion(false)
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ class ContractorViewModel: ObservableObject {
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
} else {
|
||||
self.errorMessage = "Failed to load contractors"
|
||||
self.errorMessage = String(localized: "Failed to load contractors")
|
||||
self.isLoading = false
|
||||
}
|
||||
} catch {
|
||||
@@ -119,7 +119,7 @@ class ContractorViewModel: ObservableObject {
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
} else {
|
||||
self.errorMessage = "Failed to load contractor details"
|
||||
self.errorMessage = String(localized: "Failed to load contractor details")
|
||||
self.isLoading = false
|
||||
}
|
||||
} catch {
|
||||
@@ -138,7 +138,7 @@ class ContractorViewModel: ObservableObject {
|
||||
let result = try await APILayer.shared.createContractor(request: request)
|
||||
|
||||
if let success = result as? ApiResultSuccess<Contractor> {
|
||||
self.successMessage = "Contractor added successfully"
|
||||
self.successMessage = String(localized: "Contractor added successfully")
|
||||
self.isCreating = false
|
||||
// Update selectedContractor with the newly created contractor
|
||||
self.lastMutationTime = Date()
|
||||
@@ -149,7 +149,7 @@ class ContractorViewModel: ObservableObject {
|
||||
self.isCreating = false
|
||||
completion(false)
|
||||
} else {
|
||||
self.errorMessage = "Failed to create contractor"
|
||||
self.errorMessage = String(localized: "Failed to create contractor")
|
||||
self.isCreating = false
|
||||
completion(false)
|
||||
}
|
||||
@@ -170,7 +170,7 @@ class ContractorViewModel: ObservableObject {
|
||||
let result = try await APILayer.shared.updateContractor(id: id, request: request)
|
||||
|
||||
if let success = result as? ApiResultSuccess<Contractor> {
|
||||
self.successMessage = "Contractor updated successfully"
|
||||
self.successMessage = String(localized: "Contractor updated successfully")
|
||||
self.isUpdating = false
|
||||
// Update selectedContractor immediately so detail views stay fresh
|
||||
self.lastMutationTime = Date()
|
||||
@@ -181,7 +181,7 @@ class ContractorViewModel: ObservableObject {
|
||||
self.isUpdating = false
|
||||
completion(false)
|
||||
} else {
|
||||
self.errorMessage = "Failed to update contractor"
|
||||
self.errorMessage = String(localized: "Failed to update contractor")
|
||||
self.isUpdating = false
|
||||
completion(false)
|
||||
}
|
||||
@@ -202,7 +202,7 @@ class ContractorViewModel: ObservableObject {
|
||||
let result = try await APILayer.shared.deleteContractor(id: id)
|
||||
|
||||
if result is ApiResultSuccess<KotlinUnit> {
|
||||
self.successMessage = "Contractor deleted successfully"
|
||||
self.successMessage = String(localized: "Contractor deleted successfully")
|
||||
self.isDeleting = false
|
||||
// DataManager is updated by APILayer, view updates via observation
|
||||
completion(true)
|
||||
@@ -211,7 +211,7 @@ class ContractorViewModel: ObservableObject {
|
||||
self.isDeleting = false
|
||||
completion(false)
|
||||
} else {
|
||||
self.errorMessage = "Failed to delete contractor"
|
||||
self.errorMessage = String(localized: "Failed to delete contractor")
|
||||
self.isDeleting = false
|
||||
completion(false)
|
||||
}
|
||||
@@ -237,7 +237,7 @@ class ContractorViewModel: ObservableObject {
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
completion(false)
|
||||
} else {
|
||||
self.errorMessage = "Failed to update favorite status"
|
||||
self.errorMessage = String(localized: "Failed to update favorite status")
|
||||
completion(false)
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -119,7 +119,7 @@ struct ContractorsListView: View {
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(showFavoritesOnly ? Color.appAccent : Color.appTextSecondary)
|
||||
}
|
||||
.accessibilityLabel(showFavoritesOnly ? "Show all contractors" : "Show favorites only")
|
||||
.accessibilityLabel(showFavoritesOnly ? String(localized: "Show all contractors") : String(localized: "Show favorites only"))
|
||||
|
||||
// Specialty Filter
|
||||
Menu {
|
||||
@@ -331,7 +331,7 @@ private struct OrganicContractorCard: View {
|
||||
if !contractor.specialties.isEmpty {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(contractor.specialties.prefix(2), id: \.id) { specialty in
|
||||
Text(specialty.name)
|
||||
Text(specialty.displayName)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.padding(.horizontal, 8)
|
||||
@@ -357,7 +357,7 @@ private struct OrganicContractorCard: View {
|
||||
.foregroundColor(contractor.isFavorite ? Color.appAccent : Color.appTextSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(contractor.isFavorite ? "Remove \(contractor.name) from favorites" : "Add \(contractor.name) to favorites")
|
||||
.accessibilityLabel(contractor.isFavorite ? String(format: String(localized: "Remove %@ from favorites"), contractor.name) : String(format: String(localized: "Add %@ to favorites"), contractor.name))
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
|
||||
@@ -48,7 +48,7 @@ struct DocumentFormState: FormState {
|
||||
}
|
||||
|
||||
mutating func validateAll() {
|
||||
title.validate { ValidationRules.validateRequired($0, fieldName: "Title") }
|
||||
title.validate { ValidationRules.validateRequired($0, fieldName: String(localized: "Title")) }
|
||||
|
||||
// Validate email if provided
|
||||
if !claimEmail.isEmpty {
|
||||
|
||||
@@ -34,7 +34,7 @@ struct ResidenceFormState: FormState {
|
||||
}
|
||||
|
||||
mutating func validateAll() {
|
||||
name.validate { ValidationRules.validateRequired($0, fieldName: "Name") }
|
||||
name.validate { ValidationRules.validateRequired($0, fieldName: String(localized: "Name")) }
|
||||
}
|
||||
|
||||
mutating func reset() {
|
||||
|
||||
@@ -19,7 +19,7 @@ struct CompleteTaskFormState: FormState {
|
||||
|
||||
mutating func validateAll() {
|
||||
completedByName.validate { value in
|
||||
ValidationRules.validateRequired(value, fieldName: "Completed By")
|
||||
ValidationRules.validateRequired(value, fieldName: String(localized: "Completed By"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ struct TaskFormState: FormState {
|
||||
|
||||
mutating func validateAll() {
|
||||
title.validate { value in
|
||||
ValidationRules.validateRequired(value, fieldName: "Title")
|
||||
ValidationRules.validateRequired(value, fieldName: String(localized: "Title"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,25 +17,25 @@ enum ValidationError: LocalizedError {
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .required(let field):
|
||||
return "\(field) is required"
|
||||
return String(format: String(localized: "%@ is required"), field)
|
||||
case .invalidEmail:
|
||||
return "Please enter a valid email address"
|
||||
return String(localized: "Please enter a valid email address")
|
||||
case .passwordTooShort(let minLength):
|
||||
return "Password must be at least \(minLength) characters"
|
||||
return String(format: String(localized: "Password must be at least %lld characters"), minLength)
|
||||
case .passwordMismatch:
|
||||
return "Passwords do not match"
|
||||
return String(localized: "Passwords do not match")
|
||||
case .passwordMissingLetter:
|
||||
return "Password must contain at least one letter"
|
||||
return String(localized: "Password must contain at least one letter")
|
||||
case .passwordMissingUppercase:
|
||||
return "Password must contain at least one uppercase letter"
|
||||
return String(localized: "Password must contain at least one uppercase letter")
|
||||
case .passwordMissingLowercase:
|
||||
return "Password must contain at least one lowercase letter"
|
||||
return String(localized: "Password must contain at least one lowercase letter")
|
||||
case .passwordMissingNumber:
|
||||
return "Password must contain at least one number"
|
||||
return String(localized: "Password must contain at least one number")
|
||||
case .invalidCode(let length):
|
||||
return "Code must be \(length) digits"
|
||||
return String(format: String(localized: "Code must be %lld digits"), length)
|
||||
case .invalidUsername:
|
||||
return "Username can only contain letters, numbers, and underscores"
|
||||
return String(localized: "Username can only contain letters, numbers, and underscores")
|
||||
case .custom(let message):
|
||||
return message
|
||||
}
|
||||
@@ -55,7 +55,7 @@ enum ValidationRules {
|
||||
let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if trimmed.isEmpty {
|
||||
return .required(field: "Email")
|
||||
return .required(field: String(localized: "Email"))
|
||||
}
|
||||
|
||||
let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
|
||||
@@ -86,7 +86,7 @@ enum ValidationRules {
|
||||
/// - Returns: ValidationError if invalid, nil if valid
|
||||
static func validatePassword(_ password: String, minLength: Int = 8) -> ValidationError? {
|
||||
if password.isEmpty {
|
||||
return .required(field: "Password")
|
||||
return .required(field: String(localized: "Password"))
|
||||
}
|
||||
|
||||
if password.count < minLength {
|
||||
@@ -101,7 +101,7 @@ enum ValidationRules {
|
||||
/// - Returns: ValidationError if invalid, nil if valid
|
||||
static func validatePasswordStrength(_ password: String) -> ValidationError? {
|
||||
if password.isEmpty {
|
||||
return .required(field: "Password")
|
||||
return .required(field: String(localized: "Password"))
|
||||
}
|
||||
|
||||
let hasUppercase = password.range(of: "[A-Z]", options: .regularExpression) != nil
|
||||
@@ -156,7 +156,7 @@ enum ValidationRules {
|
||||
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if trimmed.isEmpty {
|
||||
return .required(field: "Code")
|
||||
return .required(field: String(localized: "Code"))
|
||||
}
|
||||
|
||||
if trimmed.count != expectedLength || !trimmed.allSatisfy({ $0.isNumber }) {
|
||||
@@ -175,7 +175,7 @@ enum ValidationRules {
|
||||
let trimmed = username.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if trimmed.isEmpty {
|
||||
return .required(field: "Username")
|
||||
return .required(field: String(localized: "Username"))
|
||||
}
|
||||
|
||||
let usernameRegex = "^[A-Za-z0-9_]+$"
|
||||
|
||||
@@ -67,6 +67,16 @@ class DataManagerObservable: ObservableObject {
|
||||
@Published var taskCategories: [TaskCategory] = []
|
||||
@Published var contractorSpecialties: [ContractorSpecialty] = []
|
||||
|
||||
/// Home-profile field options (e.g. "heating_type" -> options), served
|
||||
/// localized by the backend. Keyed by the backend field name. Uses the
|
||||
/// shared Kotlin type (fully qualified to avoid colliding with the iOS-side
|
||||
/// `HomeProfileOption` view struct).
|
||||
@Published var homeProfileOptions: [String: [ComposeApp.HomeProfileOption]] = [:]
|
||||
|
||||
/// Document type / category options, served localized by the backend.
|
||||
@Published var documentTypes: [ComposeApp.HomeProfileOption] = []
|
||||
@Published var documentCategories: [ComposeApp.HomeProfileOption] = []
|
||||
|
||||
// MARK: - Task Templates
|
||||
|
||||
@Published var taskTemplates: [TaskTemplate] = []
|
||||
@@ -150,6 +160,9 @@ class DataManagerObservable: ObservableObject {
|
||||
self.taskPriorities = fixture.taskPriorities.value
|
||||
self.taskCategories = fixture.taskCategories.value
|
||||
self.contractorSpecialties = fixture.contractorSpecialties.value
|
||||
// Home-profile options aren't part of the IDataManager fixture protocol;
|
||||
// previews fall back to the bundled defaults in the home-profile view.
|
||||
self.homeProfileOptions = [:]
|
||||
|
||||
// Task Templates
|
||||
self.taskTemplates = fixture.taskTemplates.value
|
||||
@@ -435,6 +448,32 @@ class DataManagerObservable: ObservableObject {
|
||||
}
|
||||
observationTasks.append(contractorSpecialtiesTask)
|
||||
|
||||
// Lookups - Home-profile field options (localized by the backend)
|
||||
let homeProfileOptionsTask = Task { [weak self] in
|
||||
for await items in DataManager.shared.homeProfileOptions {
|
||||
guard let self else { return }
|
||||
self.homeProfileOptions = items
|
||||
}
|
||||
}
|
||||
observationTasks.append(homeProfileOptionsTask)
|
||||
|
||||
// Lookups - Document types / categories (localized by the backend)
|
||||
let documentTypesTask = Task { [weak self] in
|
||||
for await items in DataManager.shared.documentTypes {
|
||||
guard let self else { return }
|
||||
self.documentTypes = items
|
||||
}
|
||||
}
|
||||
observationTasks.append(documentTypesTask)
|
||||
|
||||
let documentCategoriesTask = Task { [weak self] in
|
||||
for await items in DataManager.shared.documentCategories {
|
||||
guard let self else { return }
|
||||
self.documentCategories = items
|
||||
}
|
||||
}
|
||||
observationTasks.append(documentCategoriesTask)
|
||||
|
||||
// Task Templates
|
||||
let taskTemplatesTask = Task { [weak self] in
|
||||
for await items in DataManager.shared.taskTemplates {
|
||||
|
||||
@@ -88,7 +88,7 @@ struct DocumentCard: View {
|
||||
}
|
||||
|
||||
private func getDocTypeDisplayName(_ type: String) -> String {
|
||||
return DocumentType.companion.fromValue(value: type).displayName
|
||||
return DocumentTypeHelper.displayName(for: type)
|
||||
}
|
||||
|
||||
private func formatFileSize(_ bytes: Int) -> String {
|
||||
|
||||
@@ -119,6 +119,6 @@ struct WarrantyCard: View {
|
||||
}
|
||||
|
||||
private func getCategoryDisplayName(_ category: String) -> String {
|
||||
return DocumentCategory.companion.fromValue(value: category).displayName
|
||||
return DocumentCategoryHelper.displayName(for: category)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,12 +141,12 @@ struct DocumentDetailView: View {
|
||||
// or DocumentDownloadManager — architectural refactor deferred.
|
||||
private func downloadFile(document: Document) {
|
||||
guard let fileUrl = document.fileUrl else {
|
||||
downloadError = "No file URL available"
|
||||
downloadError = String(localized: "No file URL available")
|
||||
return
|
||||
}
|
||||
|
||||
guard let token = TokenStorage.shared.getToken() else {
|
||||
downloadError = "Not authenticated"
|
||||
downloadError = String(localized: "Not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ struct DocumentDetailView: View {
|
||||
|
||||
guard let url = URL(string: fullURLString) else {
|
||||
await MainActor.run {
|
||||
downloadError = "Invalid URL"
|
||||
downloadError = String(localized: "Invalid URL")
|
||||
isDownloading = false
|
||||
}
|
||||
return
|
||||
@@ -179,7 +179,7 @@ struct DocumentDetailView: View {
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
guard (200...299).contains(httpResponse.statusCode) else {
|
||||
await MainActor.run {
|
||||
downloadError = "Download failed: HTTP \(httpResponse.statusCode)"
|
||||
downloadError = String(format: String(localized: "Download failed: HTTP %lld"), httpResponse.statusCode)
|
||||
isDownloading = false
|
||||
}
|
||||
return
|
||||
@@ -216,7 +216,7 @@ struct DocumentDetailView: View {
|
||||
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
downloadError = "Download failed: \(error.localizedDescription)"
|
||||
downloadError = String(format: String(localized: "Download failed: %@"), error.localizedDescription)
|
||||
isDownloading = false
|
||||
}
|
||||
}
|
||||
@@ -366,10 +366,10 @@ struct DocumentDetailView: View {
|
||||
sectionHeader(L10n.Documents.associations)
|
||||
|
||||
if let residenceId = document.residenceId {
|
||||
detailRow(label: L10n.Documents.residence, value: "Residence #\(residenceId)")
|
||||
detailRow(label: L10n.Documents.residence, value: String(format: String(localized: "Residence #%lld"), Int(residenceId.intValue)))
|
||||
}
|
||||
if let taskId = document.taskId {
|
||||
detailRow(label: "Task", value: "Task #\(taskId)")
|
||||
detailRow(label: String(localized: "Task"), value: String(format: String(localized: "Task #%lld"), Int(taskId.intValue)))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
@@ -69,7 +69,7 @@ class DocumentViewModel: ObservableObject {
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
} else {
|
||||
self.errorMessage = "Failed to load documents"
|
||||
self.errorMessage = String(localized: "Failed to load documents")
|
||||
self.isLoading = false
|
||||
}
|
||||
} catch {
|
||||
@@ -148,7 +148,7 @@ class DocumentViewModel: ObservableObject {
|
||||
success.data?.title == nil ? "title" : nil
|
||||
].compactMap { $0 }.joined(separator: ", ")
|
||||
print("DocumentViewModel: Document creation returned incomplete data (missing: \(missingFields)), skipping image upload")
|
||||
self.errorMessage = "Document created with incomplete data — images were not uploaded"
|
||||
self.errorMessage = String(localized: "Document created with incomplete data — images were not uploaded")
|
||||
self.isLoading = false
|
||||
completion(false, self.errorMessage)
|
||||
return
|
||||
@@ -170,7 +170,7 @@ class DocumentViewModel: ObservableObject {
|
||||
self.isLoading = false
|
||||
completion(false, self.errorMessage)
|
||||
} else {
|
||||
self.errorMessage = "Failed to create document"
|
||||
self.errorMessage = String(localized: "Failed to create document")
|
||||
self.isLoading = false
|
||||
completion(false, self.errorMessage)
|
||||
}
|
||||
@@ -250,7 +250,7 @@ class DocumentViewModel: ObservableObject {
|
||||
self.isLoading = false
|
||||
completion(false, self.errorMessage)
|
||||
} else {
|
||||
self.errorMessage = "Failed to update document"
|
||||
self.errorMessage = String(localized: "Failed to update document")
|
||||
self.isLoading = false
|
||||
completion(false, self.errorMessage)
|
||||
}
|
||||
@@ -279,7 +279,7 @@ class DocumentViewModel: ObservableObject {
|
||||
self.isLoading = false
|
||||
completion(false)
|
||||
} else {
|
||||
self.errorMessage = "Failed to delete document"
|
||||
self.errorMessage = String(localized: "Failed to delete document")
|
||||
self.isLoading = false
|
||||
completion(false)
|
||||
}
|
||||
@@ -317,7 +317,7 @@ class DocumentViewModel: ObservableObject {
|
||||
private func uploadImages(documentId: Int32, images: [UIImage]) async -> String? {
|
||||
for (index, image) in images.enumerated() {
|
||||
guard let compressedData = ImageCompression.compressImage(image) else {
|
||||
return "Failed to process image \(index + 1)"
|
||||
return String(format: String(localized: "Failed to process image %lld"), index + 1)
|
||||
}
|
||||
|
||||
let uploadResult: Any
|
||||
@@ -338,7 +338,7 @@ class DocumentViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
if !(uploadResult is ApiResultSuccess<Document>) {
|
||||
return "Failed to upload image \(index + 1)"
|
||||
return String(format: String(localized: "Failed to upload image %lld"), index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
self.documentsState = DocumentStateError(message: error.message)
|
||||
} else {
|
||||
self.documentsState = DocumentStateError(message: "Failed to load documents")
|
||||
self.documentsState = DocumentStateError(message: String(localized: "Failed to load documents"))
|
||||
}
|
||||
} catch {
|
||||
self.documentsState = DocumentStateError(message: error.localizedDescription)
|
||||
@@ -170,7 +170,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
self.documentDetailState = DocumentDetailStateError(message: error.message)
|
||||
} else {
|
||||
self.documentDetailState = DocumentDetailStateError(message: "Failed to load document")
|
||||
self.documentDetailState = DocumentDetailStateError(message: String(localized: "Failed to load document"))
|
||||
}
|
||||
} catch {
|
||||
self.documentDetailState = DocumentDetailStateError(message: error.localizedDescription)
|
||||
@@ -233,7 +233,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
self.updateState = UpdateStateError(message: error.message)
|
||||
} else {
|
||||
self.updateState = UpdateStateError(message: "Failed to update document")
|
||||
self.updateState = UpdateStateError(message: String(localized: "Failed to update document"))
|
||||
}
|
||||
} catch {
|
||||
self.updateState = UpdateStateError(message: error.localizedDescription)
|
||||
@@ -253,7 +253,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
self.deleteState = DeleteStateError(message: error.message)
|
||||
} else {
|
||||
self.deleteState = DeleteStateError(message: "Failed to delete document")
|
||||
self.deleteState = DeleteStateError(message: String(localized: "Failed to delete document"))
|
||||
}
|
||||
} catch {
|
||||
self.deleteState = DeleteStateError(message: error.localizedDescription)
|
||||
@@ -283,7 +283,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
self.deleteImageState = DeleteImageStateError(message: error.message)
|
||||
} else {
|
||||
self.deleteImageState = DeleteImageStateError(message: "Failed to delete image")
|
||||
self.deleteImageState = DeleteImageStateError(message: String(localized: "Failed to delete image"))
|
||||
}
|
||||
} catch {
|
||||
self.deleteImageState = DeleteImageStateError(message: error.localizedDescription)
|
||||
|
||||
@@ -118,7 +118,7 @@ struct DocumentsWarrantiesView: View {
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(showActiveOnly ? Color.appPrimary : Color.appTextSecondary)
|
||||
}
|
||||
.accessibilityLabel(showActiveOnly ? "Show all warranties" : "Show active warranties only")
|
||||
.accessibilityLabel(showActiveOnly ? String(localized: "Show all warranties") : String(localized: "Show active warranties only"))
|
||||
}
|
||||
|
||||
// Filter Menu
|
||||
@@ -201,7 +201,7 @@ struct DocumentsWarrantiesView: View {
|
||||
UpgradePromptView(triggerKey: "view_documents", isPresented: $showingUpgradePrompt)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .navigateToDocument)) { notification in
|
||||
if let documentId = notification.userInfo?["documentId"] as? Int {
|
||||
if let documentId = notification.userInfo?["documentId"] as? Int { // i18n-ignore: NotificationCenter userInfo key (non-UI)
|
||||
navigateToDocumentFromPush(documentId: documentId)
|
||||
} else {
|
||||
selectedTab = .warranties
|
||||
@@ -383,16 +383,7 @@ extension DocumentCategory: CaseIterable {
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .appliance: return "Appliance"
|
||||
case .hvac: return "HVAC"
|
||||
case .plumbing: return "Plumbing"
|
||||
case .electrical: return "Electrical"
|
||||
case .roofing: return "Roofing"
|
||||
case .structural: return "Structural"
|
||||
case .other: return "Other"
|
||||
default: return "Unknown"
|
||||
}
|
||||
DocumentCategoryHelper.displayName(for: self.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,18 +393,6 @@ extension DocumentType: CaseIterable {
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .warranty: return "Warranty"
|
||||
case .manual: return "Manual"
|
||||
case .receipt: return "Receipt"
|
||||
case .inspection: return "Inspection"
|
||||
case .permit: return "Permit"
|
||||
case .deed: return "Deed"
|
||||
case .insurance: return "Insurance"
|
||||
case .contract: return "Contract"
|
||||
case .photo: return "Photo"
|
||||
case .other: return "Other"
|
||||
default: return "Unknown"
|
||||
}
|
||||
DocumentTypeHelper.displayName(for: self.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
import Foundation
|
||||
import ComposeApp
|
||||
|
||||
struct DocumentTypeHelper {
|
||||
static let allTypes = ["warranty", "manual", "receipt", "inspection", "permit", "deed", "insurance", "contract", "photo", "other"]
|
||||
|
||||
/// Localized label for a document type. Prefers the backend-served
|
||||
/// (localized) list; falls back to the bundled English defaults when the
|
||||
/// lookup data hasn't loaded yet.
|
||||
static func displayName(for value: String) -> String {
|
||||
if let opt = DataManager.shared.documentTypes.value.first(where: { $0.value == value }) {
|
||||
return opt.displayName
|
||||
}
|
||||
return fallbackDisplayName(for: value)
|
||||
}
|
||||
|
||||
static func fallbackDisplayName(for value: String) -> String {
|
||||
switch value {
|
||||
case "warranty": return "Warranty"
|
||||
case "manual": return "User Manual"
|
||||
case "receipt": return "Receipt/Invoice"
|
||||
case "inspection": return "Inspection Report"
|
||||
case "permit": return "Permit"
|
||||
case "deed": return "Deed/Title"
|
||||
case "insurance": return "Insurance"
|
||||
case "contract": return "Contract"
|
||||
case "photo": return "Photo"
|
||||
default: return "Other"
|
||||
case "warranty": return String(localized: "Warranty")
|
||||
case "manual": return String(localized: "User Manual")
|
||||
case "receipt": return String(localized: "Receipt/Invoice")
|
||||
case "inspection": return String(localized: "Inspection Report")
|
||||
case "permit": return String(localized: "Permit")
|
||||
case "deed": return String(localized: "Deed/Title")
|
||||
case "insurance": return String(localized: "Insurance")
|
||||
case "contract": return String(localized: "Contract")
|
||||
case "photo": return String(localized: "Photo")
|
||||
default: return String(localized: "Other")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,17 +33,26 @@ struct DocumentTypeHelper {
|
||||
struct DocumentCategoryHelper {
|
||||
static let allCategories = ["appliance", "hvac", "plumbing", "electrical", "roofing", "structural", "landscaping", "general", "other"]
|
||||
|
||||
/// Localized label for a document category. Prefers the backend-served
|
||||
/// (localized) list; falls back to the bundled English defaults.
|
||||
static func displayName(for value: String) -> String {
|
||||
if let opt = DataManager.shared.documentCategories.value.first(where: { $0.value == value }) {
|
||||
return opt.displayName
|
||||
}
|
||||
return fallbackDisplayName(for: value)
|
||||
}
|
||||
|
||||
static func fallbackDisplayName(for value: String) -> String {
|
||||
switch value {
|
||||
case "appliance": return "Appliance"
|
||||
case "hvac": return "HVAC"
|
||||
case "plumbing": return "Plumbing"
|
||||
case "electrical": return "Electrical"
|
||||
case "roofing": return "Roofing"
|
||||
case "structural": return "Structural"
|
||||
case "landscaping": return "Landscaping"
|
||||
case "general": return "General"
|
||||
default: return "Other"
|
||||
case "appliance": return String(localized: "Appliance")
|
||||
case "hvac": return String(localized: "HVAC")
|
||||
case "plumbing": return String(localized: "Plumbing")
|
||||
case "electrical": return String(localized: "Electrical")
|
||||
case "roofing": return String(localized: "Roofing")
|
||||
case "structural": return String(localized: "Structural")
|
||||
case "landscaping": return String(localized: "Landscaping")
|
||||
case "general": return String(localized: "General")
|
||||
default: return String(localized: "Other")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"strings": {
|
||||
"NSCameraUsageDescription": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDue needs camera access to take photos of tasks, documents, and receipts."
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDue necesita acceso a la cámara para tomar fotos de tareas, documentos y recibos."
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDue a besoin d'accéder à l'appareil photo pour prendre des photos de tâches, de documents et de reçus."
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDue benötigt Zugriff auf die Kamera, um Fotos von Aufgaben, Dokumenten und Belegen aufzunehmen."
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDue ha bisogno dell'accesso alla fotocamera per scattare foto di attività, documenti e ricevute."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDueは、タスク、書類、領収書の写真を撮影するためにカメラへのアクセスを必要とします。"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDue에서 작업, 문서, 영수증의 사진을 촬영하려면 카메라 접근 권한이 필요합니다."
|
||||
}
|
||||
},
|
||||
"nl": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDue heeft toegang tot de camera nodig om foto's van taken, documenten en bonnen te maken."
|
||||
}
|
||||
},
|
||||
"pt": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "O honeyDue precisa de acesso à câmera para tirar fotos de tarefas, documentos e recibos."
|
||||
}
|
||||
},
|
||||
"zh": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDue 需要访问相机,以便拍摄任务、文档和收据的照片。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSPhotoLibraryAddUsageDescription": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDue needs permission to save photos to your library."
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDue necesita permiso para guardar fotos en tu biblioteca."
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDue a besoin de votre autorisation pour enregistrer des photos dans votre photothèque."
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDue benötigt die Berechtigung, um Fotos in deiner Mediathek zu speichern."
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDue ha bisogno dell'autorizzazione per salvare le foto nella tua libreria."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDueは、写真をライブラリに保存するための許可を必要とします。"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDue에서 사진을 보관함에 저장하려면 권한이 필요합니다."
|
||||
}
|
||||
},
|
||||
"nl": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDue heeft toestemming nodig om foto's in je bibliotheek op te slaan."
|
||||
}
|
||||
},
|
||||
"pt": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "O honeyDue precisa de permissão para salvar fotos na sua biblioteca."
|
||||
}
|
||||
},
|
||||
"zh": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDue 需要权限以将照片保存到您的图库。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSPhotoLibraryUsageDescription": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDue needs photo library access to attach photos to tasks and documents."
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDue necesita acceso a la biblioteca de fotos para adjuntar fotos a tareas y documentos."
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDue a besoin d'accéder à votre photothèque pour joindre des photos aux tâches et aux documents."
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDue benötigt Zugriff auf deine Fotomediathek, um Aufgaben und Dokumenten Fotos hinzuzufügen."
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDue ha bisogno dell'accesso alla libreria foto per allegare foto ad attività e documenti."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDueは、タスクや書類に写真を添付するために写真ライブラリへのアクセスを必要とします。"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDue에서 작업과 문서에 사진을 첨부하려면 사진 보관함 접근 권한이 필요합니다."
|
||||
}
|
||||
},
|
||||
"nl": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDue heeft toegang tot je fotobibliotheek nodig om foto's aan taken en documenten toe te voegen."
|
||||
}
|
||||
},
|
||||
"pt": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "O honeyDue precisa de acesso à biblioteca de fotos para anexar fotos a tarefas e documentos."
|
||||
}
|
||||
},
|
||||
"zh": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "honeyDue 需要访问照片图库,以便将照片附加到任务和文档。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "1.0"
|
||||
}
|
||||
+71490
-20773
File diff suppressed because it is too large
Load Diff
@@ -159,19 +159,19 @@ enum AppleSignInError: LocalizedError {
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidCredential:
|
||||
return "Invalid Apple ID credential"
|
||||
return String(localized: "Invalid Apple ID credential")
|
||||
case .missingIdentityToken:
|
||||
return "Missing identity token from Apple"
|
||||
return String(localized: "Missing identity token from Apple")
|
||||
case .userCancelled:
|
||||
return nil // Don't show error for user cancellation
|
||||
case .authorizationFailed:
|
||||
return "Apple Sign In authorization failed"
|
||||
return String(localized: "Apple Sign In authorization failed")
|
||||
case .invalidResponse:
|
||||
return "Invalid response from Apple"
|
||||
return String(localized: "Invalid response from Apple")
|
||||
case .notHandled:
|
||||
return "Apple Sign In request was not handled"
|
||||
return String(localized: "Apple Sign In request was not handled")
|
||||
case .notInteractive:
|
||||
return "Apple Sign In requires user interaction"
|
||||
return String(localized: "Apple Sign In requires user interaction")
|
||||
case .serverError(let message):
|
||||
return message
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ class AppleSignInViewModel: ObservableObject {
|
||||
self.handleBackendError(error)
|
||||
} else {
|
||||
self.isLoading = false
|
||||
self.errorMessage = "Sign in failed. Please try again."
|
||||
self.errorMessage = String(localized: "Sign in failed. Please try again.")
|
||||
}
|
||||
} catch {
|
||||
self.isLoading = false
|
||||
@@ -129,9 +129,9 @@ class AppleSignInViewModel: ObservableObject {
|
||||
if let code = error.code?.intValue {
|
||||
switch code {
|
||||
case 401:
|
||||
errorMessage = "Authentication failed. Please try again."
|
||||
errorMessage = String(localized: "Authentication failed. Please try again.")
|
||||
case 403:
|
||||
errorMessage = "Access denied"
|
||||
errorMessage = String(localized: "Access denied")
|
||||
case 409:
|
||||
// Conflict - let backend message explain the issue
|
||||
errorMessage = ErrorMessageParser.parse(error.message)
|
||||
@@ -139,7 +139,7 @@ class AppleSignInViewModel: ObservableObject {
|
||||
// Bad request - validation errors from backend
|
||||
errorMessage = ErrorMessageParser.parse(error.message)
|
||||
case 500...599:
|
||||
errorMessage = "Server error. Please try again later."
|
||||
errorMessage = String(localized: "Server error. Please try again later.")
|
||||
default:
|
||||
errorMessage = ErrorMessageParser.parse(error.message)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
|
||||
guard !isLoading else { return }
|
||||
|
||||
guard let clientId = resolvedClientID() else {
|
||||
errorMessage = "Google Sign-In is not configured. A Google Cloud client ID is required."
|
||||
errorMessage = String(localized: "Google Sign-In is not configured. A Google Cloud client ID is required.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
|
||||
URLQueryItem(name: "client_id", value: clientId),
|
||||
URLQueryItem(name: "redirect_uri", value: redirectURI),
|
||||
URLQueryItem(name: "response_type", value: "code"),
|
||||
URLQueryItem(name: "scope", value: "openid email profile"),
|
||||
URLQueryItem(name: "scope", value: "openid email profile"), // i18n-ignore: OAuth scope value (non-UI)
|
||||
URLQueryItem(name: "access_type", value: "offline"),
|
||||
URLQueryItem(name: "prompt", value: "select_account"),
|
||||
URLQueryItem(name: "code_challenge", value: challenge),
|
||||
@@ -60,7 +60,7 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
|
||||
|
||||
guard let authURL = components.url else {
|
||||
isLoading = false
|
||||
errorMessage = "Failed to build authentication URL"
|
||||
errorMessage = String(localized: "Failed to build authentication URL")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
|
||||
self.isLoading = false
|
||||
// Don't show error for user cancellation
|
||||
if (error as NSError).code != ASWebAuthenticationSessionError.canceledLogin.rawValue {
|
||||
self.errorMessage = "Sign in failed: \(error.localizedDescription)"
|
||||
self.errorMessage = String(format: String(localized: "Sign in failed: %@"), error.localizedDescription)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -85,14 +85,14 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
|
||||
let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false) else {
|
||||
self.isLoading = false
|
||||
self.resetOAuthState()
|
||||
self.errorMessage = "Failed to get authorization code from Google"
|
||||
self.errorMessage = String(localized: "Failed to get authorization code from Google")
|
||||
return
|
||||
}
|
||||
|
||||
if let oauthError = components.queryItems?.first(where: { $0.name == "error" })?.value {
|
||||
self.isLoading = false
|
||||
self.resetOAuthState()
|
||||
self.errorMessage = "Google Sign-In error: \(oauthError)"
|
||||
self.errorMessage = String(format: String(localized: "Google Sign-In error: %@"), oauthError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
|
||||
callbackState == self.oauthState else {
|
||||
self.isLoading = false
|
||||
self.resetOAuthState()
|
||||
self.errorMessage = "Invalid Google OAuth state. Please try again."
|
||||
self.errorMessage = String(localized: "Invalid Google OAuth state. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
|
||||
let verifier = self.codeVerifier else {
|
||||
self.isLoading = false
|
||||
self.resetOAuthState()
|
||||
self.errorMessage = "Failed to get authorization code from Google"
|
||||
self.errorMessage = String(localized: "Failed to get authorization code from Google")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
|
||||
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
|
||||
resetOAuthState()
|
||||
isLoading = false
|
||||
errorMessage = "Failed to exchange authorization code"
|
||||
errorMessage = String(localized: "Failed to exchange authorization code")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
|
||||
guard let parsed = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
resetOAuthState()
|
||||
isLoading = false
|
||||
errorMessage = "Failed to get ID token from Google"
|
||||
errorMessage = String(localized: "Failed to get ID token from Google")
|
||||
return
|
||||
}
|
||||
json = parsed
|
||||
@@ -187,14 +187,14 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
|
||||
print("GoogleSignInManager: Failed to parse token response JSON: \(error)")
|
||||
resetOAuthState()
|
||||
isLoading = false
|
||||
errorMessage = "Failed to get ID token from Google"
|
||||
errorMessage = String(localized: "Failed to get ID token from Google")
|
||||
return
|
||||
}
|
||||
|
||||
guard let idToken = json["id_token"] as? String else {
|
||||
resetOAuthState()
|
||||
isLoading = false
|
||||
errorMessage = "Failed to get ID token from Google"
|
||||
errorMessage = String(localized: "Failed to get ID token from Google")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
|
||||
} catch {
|
||||
resetOAuthState()
|
||||
isLoading = false
|
||||
errorMessage = "Network error: \(error.localizedDescription)"
|
||||
errorMessage = String(format: String(localized: "Network error: %@"), error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
|
||||
print("GoogleSignInManager: Backend sign-in request failed: \(error)")
|
||||
resetOAuthState()
|
||||
isLoading = false
|
||||
errorMessage = "Sign in failed: \(error.localizedDescription)"
|
||||
errorMessage = String(format: String(localized: "Sign in failed: %@"), error.localizedDescription)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
|
||||
} else {
|
||||
resetOAuthState()
|
||||
isLoading = false
|
||||
errorMessage = "Sign in failed. Please try again."
|
||||
errorMessage = String(localized: "Sign in failed. Please try again.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
|
||||
}
|
||||
|
||||
private static func randomURLSafeString(length: Int) -> String {
|
||||
let allowed = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
|
||||
let allowed = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~") // i18n-ignore: URL-safe character set for nonce generation (non-UI)
|
||||
var bytes = [UInt8](repeating: 0, count: length)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
return String(bytes.map { allowed[Int($0) % allowed.count] })
|
||||
|
||||
@@ -358,7 +358,7 @@ struct LoginView: View {
|
||||
)) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
Text(googleSignInManager.errorMessage ?? "An error occurred.")
|
||||
Text(googleSignInManager.errorMessage ?? String(localized: "An error occurred."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,12 +55,12 @@ class LoginViewModel: ObservableObject {
|
||||
// MARK: - Public Methods
|
||||
func login() {
|
||||
guard !username.isEmpty else {
|
||||
errorMessage = "Username is required"
|
||||
errorMessage = String(localized: "Username is required")
|
||||
return
|
||||
}
|
||||
|
||||
guard !password.isEmpty else {
|
||||
errorMessage = "Password is required"
|
||||
errorMessage = String(localized: "Password is required")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ class LoginViewModel: ObservableObject {
|
||||
onLoginSuccess?(true)
|
||||
} else {
|
||||
isLoading = false
|
||||
errorMessage = "Invalid username or password"
|
||||
errorMessage = String(localized: "Invalid username or password")
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -95,7 +95,7 @@ class LoginViewModel: ObservableObject {
|
||||
|
||||
#if DEBUG
|
||||
print("Login successful!")
|
||||
print("User: \(response.user.username ?? "unknown"), Verified: \(self.isVerified)")
|
||||
print("User: \(response.user.username ?? "unknown"), Verified: \(self.isVerified)") // i18n-ignore: DEBUG log statement (non-UI)
|
||||
#endif
|
||||
|
||||
// Share token and API URL with widget extension
|
||||
@@ -115,7 +115,7 @@ class LoginViewModel: ObservableObject {
|
||||
self.handleLoginError(error)
|
||||
} else {
|
||||
self.isLoading = false
|
||||
self.errorMessage = "Failed to sign in"
|
||||
self.errorMessage = String(localized: "Failed to sign in")
|
||||
}
|
||||
} catch {
|
||||
self.isLoading = false
|
||||
@@ -129,11 +129,11 @@ class LoginViewModel: ObservableObject {
|
||||
if let code = error.code?.intValue {
|
||||
switch code {
|
||||
case 401:
|
||||
self.errorMessage = "Invalid username or password"
|
||||
self.errorMessage = String(localized: "Invalid username or password")
|
||||
case 403:
|
||||
self.errorMessage = "Access denied. Please check your credentials."
|
||||
self.errorMessage = String(localized: "Access denied. Please check your credentials.")
|
||||
case 404:
|
||||
self.errorMessage = "Service not found. Please try again later."
|
||||
self.errorMessage = String(localized: "Service not found. Please try again later.")
|
||||
case 409:
|
||||
// Conflict - let backend message explain the issue
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
@@ -141,7 +141,7 @@ class LoginViewModel: ObservableObject {
|
||||
// Bad request - validation errors from backend
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
case 500...599:
|
||||
self.errorMessage = "Server error. Please try again later."
|
||||
self.errorMessage = String(localized: "Server error. Please try again later.")
|
||||
default:
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ struct OnboardingCreateAccountContent: View {
|
||||
VStack(spacing: 16) {
|
||||
OrganicOnboardingTextField(
|
||||
icon: "person.fill",
|
||||
placeholder: "Username",
|
||||
placeholder: String(localized: "Username"),
|
||||
text: $viewModel.username,
|
||||
isFocused: focusedField == .username,
|
||||
accessibilityIdentifier: AccessibilityIdentifiers.Onboarding.usernameField
|
||||
@@ -233,7 +233,7 @@ struct OnboardingCreateAccountContent: View {
|
||||
|
||||
OrganicOnboardingTextField(
|
||||
icon: "envelope.fill",
|
||||
placeholder: "Email",
|
||||
placeholder: String(localized: "Email"),
|
||||
text: $viewModel.email,
|
||||
isFocused: focusedField == .email,
|
||||
accessibilityIdentifier: AccessibilityIdentifiers.Onboarding.emailField
|
||||
@@ -247,7 +247,7 @@ struct OnboardingCreateAccountContent: View {
|
||||
|
||||
OrganicOnboardingSecureField(
|
||||
icon: "lock.fill",
|
||||
placeholder: "Password",
|
||||
placeholder: String(localized: "Password"),
|
||||
text: $viewModel.password,
|
||||
isFocused: focusedField == .password,
|
||||
accessibilityIdentifier: AccessibilityIdentifiers.Onboarding.passwordField
|
||||
@@ -257,7 +257,7 @@ struct OnboardingCreateAccountContent: View {
|
||||
|
||||
OrganicOnboardingSecureField(
|
||||
icon: "lock.fill",
|
||||
placeholder: "Confirm Password",
|
||||
placeholder: String(localized: "Confirm Password"),
|
||||
text: $viewModel.confirmPassword,
|
||||
isFocused: focusedField == .confirmPassword,
|
||||
accessibilityIdentifier: AccessibilityIdentifiers.Onboarding.confirmPasswordField
|
||||
@@ -268,11 +268,11 @@ struct OnboardingCreateAccountContent: View {
|
||||
// Password Requirements
|
||||
if !viewModel.password.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
OnboardingPasswordRequirementRow(isMet: hasMinLength, text: "At least 8 characters")
|
||||
OnboardingPasswordRequirementRow(isMet: hasUppercase, text: "Contains an uppercase letter")
|
||||
OnboardingPasswordRequirementRow(isMet: hasLowercase, text: "Contains a lowercase letter")
|
||||
OnboardingPasswordRequirementRow(isMet: hasDigit, text: "Contains a number")
|
||||
OnboardingPasswordRequirementRow(isMet: passwordsMatch, text: "Passwords match")
|
||||
OnboardingPasswordRequirementRow(isMet: hasMinLength, text: String(localized: "At least 8 characters"))
|
||||
OnboardingPasswordRequirementRow(isMet: hasUppercase, text: String(localized: "Contains an uppercase letter"))
|
||||
OnboardingPasswordRequirementRow(isMet: hasLowercase, text: String(localized: "Contains a lowercase letter"))
|
||||
OnboardingPasswordRequirementRow(isMet: hasDigit, text: String(localized: "Contains a number"))
|
||||
OnboardingPasswordRequirementRow(isMet: passwordsMatch, text: String(localized: "Passwords match"))
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||
@@ -311,7 +311,7 @@ struct OnboardingCreateAccountContent: View {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
Text(viewModel.isLoading ? "Creating Account..." : "Create Account")
|
||||
Text(viewModel.isLoading ? String(localized: "Creating Account...") : String(localized: "Create Account"))
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -362,7 +362,7 @@ struct OnboardingCreateAccountContent: View {
|
||||
)) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
Text(googleSignInManager.errorMessage ?? "An error occurred.")
|
||||
Text(googleSignInManager.errorMessage ?? String(localized: "An error occurred."))
|
||||
}
|
||||
.onChange(of: viewModel.isRegistered) { _, isRegistered in
|
||||
if isRegistered {
|
||||
@@ -406,7 +406,7 @@ private struct OnboardingPasswordRequirementRow: View {
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(isMet ? Color.appPrimary : Color.appTextSecondary)
|
||||
|
||||
Text(text)
|
||||
Text(LocalizedStringKey(text))
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(isMet ? Color.appTextPrimary : Color.appTextSecondary)
|
||||
}
|
||||
@@ -434,7 +434,7 @@ private struct OrganicOnboardingTextField: View {
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
|
||||
TextField(placeholder, text: $text)
|
||||
TextField(LocalizedStringKey(placeholder), text: $text)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.accessibilityIdentifier(accessibilityIdentifier ?? "")
|
||||
}
|
||||
@@ -477,12 +477,12 @@ private struct OrganicOnboardingSecureField: View {
|
||||
}
|
||||
|
||||
if showPassword {
|
||||
TextField(placeholder, text: $text)
|
||||
TextField(LocalizedStringKey(placeholder), text: $text)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.textContentType(.password)
|
||||
.accessibilityIdentifier(accessibilityIdentifier ?? "")
|
||||
} else {
|
||||
SecureField(placeholder, text: $text)
|
||||
SecureField(LocalizedStringKey(placeholder), text: $text)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.textContentType(.password)
|
||||
.accessibilityIdentifier(accessibilityIdentifier ?? "")
|
||||
@@ -541,7 +541,7 @@ private struct OrganicDividerWithText: View {
|
||||
)
|
||||
.frame(height: 1)
|
||||
|
||||
Text(text)
|
||||
Text(LocalizedStringKey(text))
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import ComposeApp
|
||||
|
||||
/// Tab selection for task browsing
|
||||
enum OnboardingTaskTab: String, CaseIterable {
|
||||
case forYou = "For You"
|
||||
case browse = "Browse All"
|
||||
case forYou = "For You" // i18n-ignore: rawValue is the catalog key, localized at render via Text(LocalizedStringKey(tab.rawValue))
|
||||
case browse = "Browse All" // i18n-ignore: rawValue is the catalog key, localized at render via Text(LocalizedStringKey(tab.rawValue))
|
||||
}
|
||||
|
||||
/// First-task onboarding content — pure server-driven, no hardcoded catalog.
|
||||
@@ -102,7 +102,7 @@ struct OnboardingFirstTaskContent: View {
|
||||
let stableId: Int32 = rawId ?? Int32(truncatingIfNeeded: abs(group.categoryName.hashValue)) * -1
|
||||
return OnboardingTaskCategory(
|
||||
id: stableId,
|
||||
name: group.categoryName.isEmpty ? "Uncategorized" : group.categoryName,
|
||||
name: group.categoryName.isEmpty ? String(localized: "Uncategorized") : group.categoryName,
|
||||
icon: Self.icon(for: group.categoryName),
|
||||
color: Self.color(for: rawId),
|
||||
tasks: group.templates.map { Self.template(from: $0, categoryName: group.categoryName) }
|
||||
@@ -122,7 +122,7 @@ struct OnboardingFirstTaskContent: View {
|
||||
if map[t.id] == nil {
|
||||
map[t.id] = Self.template(
|
||||
from: t,
|
||||
categoryName: t.category?.name ?? "Suggested"
|
||||
categoryName: t.category?.name ?? String(localized: "Suggested")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ struct OnboardingFirstTaskContent: View {
|
||||
description: t.description_.isEmpty ? nil : t.description_,
|
||||
categoryId: t.categoryId?.int32Value,
|
||||
frequencyId: t.frequencyId?.int32Value,
|
||||
frequencyLabel: t.frequency?.displayName ?? "One time",
|
||||
frequencyLabel: t.frequency?.displayName ?? String(localized: "One time"),
|
||||
icon: t.iconIos.isEmpty ? Self.icon(for: categoryName) : t.iconIos,
|
||||
color: Self.color(for: t.categoryId?.int32Value)
|
||||
)
|
||||
@@ -259,7 +259,7 @@ struct OnboardingFirstTaskContent: View {
|
||||
Image(systemName: selectedCount > 0 ? "checkmark.seal.fill" : "checkmark.circle.fill")
|
||||
.foregroundColor(selectedCount > 0 ? Color.appAccent : Color.appPrimary)
|
||||
|
||||
Text("\(selectedCount) task\(selectedCount == 1 ? "" : "s") selected")
|
||||
Text("\(selectedCount) tasks selected")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
@@ -268,25 +268,25 @@ struct OnboardingFirstTaskContent: View {
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.clipShape(Capsule())
|
||||
.animation(.spring(response: 0.3), value: selectedCount)
|
||||
.accessibilityLabel("\(selectedCount) task\(selectedCount == 1 ? "" : "s") selected")
|
||||
.accessibilityLabel("\(selectedCount) tasks selected")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var forYouTab: some View {
|
||||
if vm.isLoadingSuggestions {
|
||||
OnboardingLoadingPane(message: "Finding tasks for your home...")
|
||||
OnboardingLoadingPane(message: String(localized: "Finding tasks for your home..."))
|
||||
} else if let errorMessage = vm.suggestionsError {
|
||||
OnboardingErrorPane(
|
||||
headline: "Couldn't load your suggestions",
|
||||
headline: String(localized: "Couldn't load your suggestions"),
|
||||
message: errorMessage,
|
||||
retry: retrySuggestions,
|
||||
skip: { skip(reason: "network_error_for_you") },
|
||||
secondary: vm.grouped != nil ? .init(label: "Browse All", action: { selectedTab = .browse }) : nil
|
||||
secondary: vm.grouped != nil ? .init(label: String(localized: "Browse All"), action: { selectedTab = .browse }) : nil
|
||||
)
|
||||
} else if vm.suggestions.isEmpty && vm.suggestionsAttempted {
|
||||
OnboardingEmptyPane(
|
||||
message: "No personalised suggestions yet — browse the full catalog or skip this step.",
|
||||
primary: .init(label: vm.grouped != nil ? "Browse All" : "Skip", action: {
|
||||
message: String(localized: "No personalised suggestions yet — browse the full catalog or skip this step."),
|
||||
primary: .init(label: vm.grouped != nil ? String(localized: "Browse All") : String(localized: "Skip"), action: {
|
||||
if vm.grouped != nil {
|
||||
selectedTab = .browse
|
||||
} else {
|
||||
@@ -319,18 +319,18 @@ struct OnboardingFirstTaskContent: View {
|
||||
@ViewBuilder
|
||||
private var browseTab: some View {
|
||||
if vm.isLoadingGrouped && vm.grouped == nil {
|
||||
OnboardingLoadingPane(message: "Loading the task catalog...")
|
||||
OnboardingLoadingPane(message: String(localized: "Loading the task catalog..."))
|
||||
} else if let errorMessage = vm.groupedError, vm.grouped == nil {
|
||||
OnboardingErrorPane(
|
||||
headline: "Couldn't load the task catalog",
|
||||
headline: String(localized: "Couldn't load the task catalog"),
|
||||
message: errorMessage,
|
||||
retry: retryGrouped,
|
||||
skip: { skip(reason: "network_error_browse") }
|
||||
)
|
||||
} else if browseCategories.isEmpty {
|
||||
OnboardingEmptyPane(
|
||||
message: "No templates available right now.",
|
||||
primary: .init(label: "Skip", action: { skip(reason: "empty_catalog") })
|
||||
message: String(localized: "No templates available right now."),
|
||||
primary: .init(label: String(localized: "Skip"), action: { skip(reason: "empty_catalog") })
|
||||
)
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
@@ -366,8 +366,8 @@ struct OnboardingFirstTaskContent: View {
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
} else {
|
||||
Text(selectedCount > 0
|
||||
? "Add \(selectedCount) Task\(selectedCount == 1 ? "" : "s") & Continue"
|
||||
: "Skip for Now")
|
||||
? String(format: String(localized: "Add %lld Tasks & Continue"), selectedCount)
|
||||
: String(localized: "Skip for Now"))
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
|
||||
Image(systemName: "arrow.right")
|
||||
@@ -591,7 +591,7 @@ private struct OnboardingTaskTabBar: View {
|
||||
var body: some View {
|
||||
Picker("", selection: $selectedTab) {
|
||||
ForEach(OnboardingTaskTab.allCases, id: \.self) { tab in
|
||||
Text(tab.rawValue).tag(tab)
|
||||
Text(LocalizedStringKey(tab.rawValue)).tag(tab)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
@@ -656,8 +656,8 @@ private struct OnboardingSuggestionRow: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("\(AccessibilityIdentifiers.Onboarding.templateRowPrefix).\(suggestion.template.id)")
|
||||
.accessibilityLabel("\(suggestion.template.title), \(suggestion.template.frequencyDisplay), \(relevancePercent)% match")
|
||||
.accessibilityValue(isSelected ? "selected" : "not selected")
|
||||
.accessibilityLabel(String(format: String(localized: "%@, %@, %lld%% match"), suggestion.template.title, suggestion.template.frequencyDisplay, relevancePercent))
|
||||
.accessibilityValue(isSelected ? String(localized: "selected") : String(localized: "not selected"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -803,7 +803,7 @@ private struct OnboardingTemplateRow: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("\(AccessibilityIdentifiers.Onboarding.templateRowPrefix).\(template.id)")
|
||||
.accessibilityLabel("\(template.title), \(template.frequencyLabel)")
|
||||
.accessibilityValue(isSelected ? "selected" : "not selected")
|
||||
.accessibilityValue(isSelected ? String(localized: "selected") : String(localized: "not selected"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -822,7 +822,7 @@ private struct OnboardingLoadingPane: View {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: Color.appPrimary))
|
||||
.scaleEffect(1.2)
|
||||
Text(message)
|
||||
Text(LocalizedStringKey(message))
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -850,7 +850,7 @@ private struct OnboardingErrorPane: View {
|
||||
.foregroundColor(Color.appError)
|
||||
}
|
||||
|
||||
Text(headline)
|
||||
Text(LocalizedStringKey(headline))
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -926,7 +926,7 @@ private struct OnboardingEmptyPane: View {
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
|
||||
Text(message)
|
||||
Text(LocalizedStringKey(message))
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -6,9 +6,19 @@ struct OnboardingHomeProfileContent: View {
|
||||
var onSkip: () -> Void
|
||||
|
||||
@ObservedObject private var onboardingState = OnboardingState.shared
|
||||
@ObservedObject private var dataManager = DataManagerObservable.shared
|
||||
@State private var isAnimating = false
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
/// Resolve a field's dropdown options: prefer the backend-served (and
|
||||
/// localized) list, falling back to the bundled English defaults when the
|
||||
/// lookup data hasn't loaded yet (e.g. offline first run).
|
||||
private func resolvedOptions(_ field: String, _ fallback: [HomeProfileOption]) -> [HomeProfileOption] {
|
||||
let server = dataManager.homeProfileOptions[field] ?? []
|
||||
guard !server.isEmpty else { return fallback }
|
||||
return server.map { HomeProfileOption(value: $0.value, display: $0.displayName) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
@@ -129,75 +139,75 @@ struct OnboardingHomeProfileContent: View {
|
||||
.padding(.top, OrganicSpacing.cozy)
|
||||
|
||||
// Systems section
|
||||
ProfileSection(title: "Systems", icon: "gearshape.2.fill", color: .appPrimary) {
|
||||
ProfileSection(title: String(localized: "Systems"), icon: "gearshape.2.fill", color: .appPrimary) {
|
||||
VStack(spacing: 12) {
|
||||
ProfilePicker(
|
||||
label: "Heating",
|
||||
label: String(localized: "Heating"),
|
||||
icon: "flame.fill",
|
||||
selection: $onboardingState.pendingHeatingType,
|
||||
options: HomeProfileOptions.heatingTypes
|
||||
options: resolvedOptions("heating_type", HomeProfileOptions.heatingTypes)
|
||||
)
|
||||
ProfilePicker(
|
||||
label: "Cooling",
|
||||
label: String(localized: "Cooling"),
|
||||
icon: "snowflake",
|
||||
selection: $onboardingState.pendingCoolingType,
|
||||
options: HomeProfileOptions.coolingTypes
|
||||
options: resolvedOptions("cooling_type", HomeProfileOptions.coolingTypes)
|
||||
)
|
||||
ProfilePicker(
|
||||
label: "Water Heater",
|
||||
label: String(localized: "Water Heater"),
|
||||
icon: "drop.fill",
|
||||
selection: $onboardingState.pendingWaterHeaterType,
|
||||
options: HomeProfileOptions.waterHeaterTypes
|
||||
options: resolvedOptions("water_heater_type", HomeProfileOptions.waterHeaterTypes)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Features section
|
||||
ProfileSection(title: "Features", icon: "star.fill", color: .appAccent) {
|
||||
ProfileSection(title: String(localized: "Features"), icon: "star.fill", color: .appAccent) {
|
||||
HomeFeatureChipGrid(
|
||||
features: [
|
||||
FeatureToggle(label: "Pool", icon: "figure.pool.swim", isOn: $onboardingState.pendingHasPool),
|
||||
FeatureToggle(label: "Sprinklers", icon: "sprinkler.and.droplets.fill", isOn: $onboardingState.pendingHasSprinklerSystem),
|
||||
FeatureToggle(label: "Fireplace", icon: "fireplace.fill", isOn: $onboardingState.pendingHasFireplace),
|
||||
FeatureToggle(label: "Garage", icon: "car.fill", isOn: $onboardingState.pendingHasGarage),
|
||||
FeatureToggle(label: "Basement", icon: "arrow.down.square.fill", isOn: $onboardingState.pendingHasBasement),
|
||||
FeatureToggle(label: "Attic", icon: "arrow.up.square.fill", isOn: $onboardingState.pendingHasAttic),
|
||||
FeatureToggle(label: "Septic", icon: "drop.triangle.fill", isOn: $onboardingState.pendingHasSeptic),
|
||||
FeatureToggle(label: String(localized: "Pool"), icon: "figure.pool.swim", isOn: $onboardingState.pendingHasPool),
|
||||
FeatureToggle(label: String(localized: "Sprinklers"), icon: "sprinkler.and.droplets.fill", isOn: $onboardingState.pendingHasSprinklerSystem),
|
||||
FeatureToggle(label: String(localized: "Fireplace"), icon: "fireplace.fill", isOn: $onboardingState.pendingHasFireplace),
|
||||
FeatureToggle(label: String(localized: "Garage"), icon: "car.fill", isOn: $onboardingState.pendingHasGarage),
|
||||
FeatureToggle(label: String(localized: "Basement"), icon: "arrow.down.square.fill", isOn: $onboardingState.pendingHasBasement),
|
||||
FeatureToggle(label: String(localized: "Attic"), icon: "arrow.up.square.fill", isOn: $onboardingState.pendingHasAttic),
|
||||
FeatureToggle(label: String(localized: "Septic"), icon: "drop.triangle.fill", isOn: $onboardingState.pendingHasSeptic),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// Exterior section
|
||||
ProfileSection(title: "Exterior", icon: "house.fill", color: Color(hex: "#34C759") ?? .green) {
|
||||
ProfileSection(title: String(localized: "Exterior"), icon: "house.fill", color: Color(hex: "#34C759") ?? .green) {
|
||||
VStack(spacing: 12) {
|
||||
ProfilePicker(
|
||||
label: "Roof Type",
|
||||
label: String(localized: "Roof Type"),
|
||||
icon: "triangle.fill",
|
||||
selection: $onboardingState.pendingRoofType,
|
||||
options: HomeProfileOptions.roofTypes
|
||||
options: resolvedOptions("roof_type", HomeProfileOptions.roofTypes)
|
||||
)
|
||||
ProfilePicker(
|
||||
label: "Exterior",
|
||||
label: String(localized: "Exterior"),
|
||||
icon: "square.stack.3d.up.fill",
|
||||
selection: $onboardingState.pendingExteriorType,
|
||||
options: HomeProfileOptions.exteriorTypes
|
||||
options: resolvedOptions("exterior_type", HomeProfileOptions.exteriorTypes)
|
||||
)
|
||||
ProfilePicker(
|
||||
label: "Landscaping",
|
||||
label: String(localized: "Landscaping"),
|
||||
icon: "leaf.fill",
|
||||
selection: $onboardingState.pendingLandscapingType,
|
||||
options: HomeProfileOptions.landscapingTypes
|
||||
options: resolvedOptions("landscaping_type", HomeProfileOptions.landscapingTypes)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Interior section
|
||||
ProfileSection(title: "Interior", icon: "sofa.fill", color: Color(hex: "#AF52DE") ?? .purple) {
|
||||
ProfileSection(title: String(localized: "Interior"), icon: "sofa.fill", color: Color(hex: "#AF52DE") ?? .purple) {
|
||||
ProfilePicker(
|
||||
label: "Primary Flooring",
|
||||
label: String(localized: "Primary Flooring"),
|
||||
icon: "square.grid.3x3.fill",
|
||||
selection: $onboardingState.pendingFlooringPrimary,
|
||||
options: HomeProfileOptions.flooringTypes
|
||||
options: resolvedOptions("flooring_primary", HomeProfileOptions.flooringTypes)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -277,7 +287,7 @@ private struct ProfileSection<Content: View>: View {
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
Text(title)
|
||||
Text(LocalizedStringKey(title))
|
||||
.font(.system(size: 16, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
}
|
||||
@@ -312,7 +322,7 @@ private struct ProfilePicker: View {
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.frame(width: 24)
|
||||
|
||||
Text(label)
|
||||
Text(LocalizedStringKey(label))
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
@@ -351,7 +361,7 @@ private struct ProfilePicker: View {
|
||||
}
|
||||
|
||||
private var displayValue: String {
|
||||
guard let selection = selection else { return "Select" }
|
||||
guard let selection = selection else { return String(localized: "Select") }
|
||||
return options.first { $0.value == selection }?.display ?? selection
|
||||
}
|
||||
}
|
||||
@@ -398,7 +408,7 @@ private struct HomeFeatureChip: View {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
|
||||
Text(label)
|
||||
Text(LocalizedStringKey(label))
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(isSelected ? .white : Color.appTextPrimary)
|
||||
@@ -424,7 +434,7 @@ private struct HomeFeatureChip: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(label)
|
||||
.accessibilityValue(isSelected ? "selected" : "not selected")
|
||||
.accessibilityValue(isSelected ? String(localized: "selected") : String(localized: "not selected"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,71 +447,71 @@ struct HomeProfileOption {
|
||||
|
||||
enum HomeProfileOptions {
|
||||
static let heatingTypes: [HomeProfileOption] = [
|
||||
HomeProfileOption(value: "gas_furnace", display: "Gas Furnace"),
|
||||
HomeProfileOption(value: "electric_furnace", display: "Electric Furnace"),
|
||||
HomeProfileOption(value: "heat_pump", display: "Heat Pump"),
|
||||
HomeProfileOption(value: "boiler", display: "Boiler"),
|
||||
HomeProfileOption(value: "radiant", display: "Radiant"),
|
||||
HomeProfileOption(value: "other", display: "Other"),
|
||||
HomeProfileOption(value: "gas_furnace", display: String(localized: "Gas Furnace")),
|
||||
HomeProfileOption(value: "electric_furnace", display: String(localized: "Electric Furnace")),
|
||||
HomeProfileOption(value: "heat_pump", display: String(localized: "Heat Pump")),
|
||||
HomeProfileOption(value: "boiler", display: String(localized: "Boiler")),
|
||||
HomeProfileOption(value: "radiant", display: String(localized: "Radiant")),
|
||||
HomeProfileOption(value: "other", display: String(localized: "Other")),
|
||||
]
|
||||
|
||||
static let coolingTypes: [HomeProfileOption] = [
|
||||
HomeProfileOption(value: "central_ac", display: "Central AC"),
|
||||
HomeProfileOption(value: "window_ac", display: "Window AC"),
|
||||
HomeProfileOption(value: "heat_pump", display: "Heat Pump"),
|
||||
HomeProfileOption(value: "evaporative", display: "Evaporative"),
|
||||
HomeProfileOption(value: "none", display: "None"),
|
||||
HomeProfileOption(value: "other", display: "Other"),
|
||||
HomeProfileOption(value: "central_ac", display: String(localized: "Central AC")),
|
||||
HomeProfileOption(value: "window_ac", display: String(localized: "Window AC")),
|
||||
HomeProfileOption(value: "heat_pump", display: String(localized: "Heat Pump")),
|
||||
HomeProfileOption(value: "evaporative", display: String(localized: "Evaporative")),
|
||||
HomeProfileOption(value: "none", display: String(localized: "None")),
|
||||
HomeProfileOption(value: "other", display: String(localized: "Other")),
|
||||
]
|
||||
|
||||
static let waterHeaterTypes: [HomeProfileOption] = [
|
||||
HomeProfileOption(value: "tank_gas", display: "Tank (Gas)"),
|
||||
HomeProfileOption(value: "tank_electric", display: "Tank (Electric)"),
|
||||
HomeProfileOption(value: "tankless_gas", display: "Tankless (Gas)"),
|
||||
HomeProfileOption(value: "tankless_electric", display: "Tankless (Electric)"),
|
||||
HomeProfileOption(value: "heat_pump", display: "Heat Pump"),
|
||||
HomeProfileOption(value: "solar", display: "Solar"),
|
||||
HomeProfileOption(value: "other", display: "Other"),
|
||||
HomeProfileOption(value: "tank_gas", display: String(localized: "Tank (Gas)")),
|
||||
HomeProfileOption(value: "tank_electric", display: String(localized: "Tank (Electric)")),
|
||||
HomeProfileOption(value: "tankless_gas", display: String(localized: "Tankless (Gas)")),
|
||||
HomeProfileOption(value: "tankless_electric", display: String(localized: "Tankless (Electric)")),
|
||||
HomeProfileOption(value: "heat_pump", display: String(localized: "Heat Pump")),
|
||||
HomeProfileOption(value: "solar", display: String(localized: "Solar")),
|
||||
HomeProfileOption(value: "other", display: String(localized: "Other")),
|
||||
]
|
||||
|
||||
static let roofTypes: [HomeProfileOption] = [
|
||||
HomeProfileOption(value: "asphalt_shingle", display: "Asphalt Shingle"),
|
||||
HomeProfileOption(value: "metal", display: "Metal"),
|
||||
HomeProfileOption(value: "tile", display: "Tile"),
|
||||
HomeProfileOption(value: "slate", display: "Slate"),
|
||||
HomeProfileOption(value: "wood_shake", display: "Wood Shake"),
|
||||
HomeProfileOption(value: "flat", display: "Flat"),
|
||||
HomeProfileOption(value: "other", display: "Other"),
|
||||
HomeProfileOption(value: "asphalt_shingle", display: String(localized: "Asphalt Shingle")),
|
||||
HomeProfileOption(value: "metal", display: String(localized: "Metal")),
|
||||
HomeProfileOption(value: "tile", display: String(localized: "Tile")),
|
||||
HomeProfileOption(value: "slate", display: String(localized: "Slate")),
|
||||
HomeProfileOption(value: "wood_shake", display: String(localized: "Wood Shake")),
|
||||
HomeProfileOption(value: "flat", display: String(localized: "Flat")),
|
||||
HomeProfileOption(value: "other", display: String(localized: "Other")),
|
||||
]
|
||||
|
||||
static let exteriorTypes: [HomeProfileOption] = [
|
||||
HomeProfileOption(value: "brick", display: "Brick"),
|
||||
HomeProfileOption(value: "vinyl_siding", display: "Vinyl Siding"),
|
||||
HomeProfileOption(value: "wood_siding", display: "Wood Siding"),
|
||||
HomeProfileOption(value: "stucco", display: "Stucco"),
|
||||
HomeProfileOption(value: "stone", display: "Stone"),
|
||||
HomeProfileOption(value: "fiber_cement", display: "Fiber Cement"),
|
||||
HomeProfileOption(value: "other", display: "Other"),
|
||||
HomeProfileOption(value: "brick", display: String(localized: "Brick")),
|
||||
HomeProfileOption(value: "vinyl_siding", display: String(localized: "Vinyl Siding")),
|
||||
HomeProfileOption(value: "wood_siding", display: String(localized: "Wood Siding")),
|
||||
HomeProfileOption(value: "stucco", display: String(localized: "Stucco")),
|
||||
HomeProfileOption(value: "stone", display: String(localized: "Stone")),
|
||||
HomeProfileOption(value: "fiber_cement", display: String(localized: "Fiber Cement")),
|
||||
HomeProfileOption(value: "other", display: String(localized: "Other")),
|
||||
]
|
||||
|
||||
static let flooringTypes: [HomeProfileOption] = [
|
||||
HomeProfileOption(value: "hardwood", display: "Hardwood"),
|
||||
HomeProfileOption(value: "laminate", display: "Laminate"),
|
||||
HomeProfileOption(value: "tile", display: "Tile"),
|
||||
HomeProfileOption(value: "carpet", display: "Carpet"),
|
||||
HomeProfileOption(value: "vinyl", display: "Vinyl"),
|
||||
HomeProfileOption(value: "concrete", display: "Concrete"),
|
||||
HomeProfileOption(value: "other", display: "Other"),
|
||||
HomeProfileOption(value: "hardwood", display: String(localized: "Hardwood")),
|
||||
HomeProfileOption(value: "laminate", display: String(localized: "Laminate")),
|
||||
HomeProfileOption(value: "tile", display: String(localized: "Tile")),
|
||||
HomeProfileOption(value: "carpet", display: String(localized: "Carpet")),
|
||||
HomeProfileOption(value: "vinyl", display: String(localized: "Vinyl")),
|
||||
HomeProfileOption(value: "concrete", display: String(localized: "Concrete")),
|
||||
HomeProfileOption(value: "other", display: String(localized: "Other")),
|
||||
]
|
||||
|
||||
static let landscapingTypes: [HomeProfileOption] = [
|
||||
HomeProfileOption(value: "lawn", display: "Lawn"),
|
||||
HomeProfileOption(value: "desert", display: "Desert"),
|
||||
HomeProfileOption(value: "xeriscape", display: "Xeriscape"),
|
||||
HomeProfileOption(value: "garden", display: "Garden"),
|
||||
HomeProfileOption(value: "mixed", display: "Mixed"),
|
||||
HomeProfileOption(value: "none", display: "None"),
|
||||
HomeProfileOption(value: "other", display: "Other"),
|
||||
HomeProfileOption(value: "lawn", display: String(localized: "Lawn")),
|
||||
HomeProfileOption(value: "desert", display: String(localized: "Desert")),
|
||||
HomeProfileOption(value: "xeriscape", display: String(localized: "Xeriscape")),
|
||||
HomeProfileOption(value: "garden", display: String(localized: "Garden")),
|
||||
HomeProfileOption(value: "mixed", display: String(localized: "Mixed")),
|
||||
HomeProfileOption(value: "none", display: String(localized: "None")),
|
||||
HomeProfileOption(value: "other", display: String(localized: "Other")),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ struct OnboardingJoinResidenceContent: View {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
Text(isLoading ? "Joining..." : "Join Residence")
|
||||
Text(isLoading ? String(localized: "Joining...") : String(localized: "Join Residence"))
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -235,7 +235,7 @@ struct OnboardingJoinResidenceContent: View {
|
||||
|
||||
private func joinResidence() {
|
||||
guard shareCode.count == 6 else {
|
||||
errorMessage = "Share code must be 6 characters"
|
||||
errorMessage = String(localized: "Share code must be 6 characters")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@ struct OnboardingNameResidenceContent: View {
|
||||
}
|
||||
|
||||
private let nameSuggestions = [
|
||||
"Casa de [Your Name]",
|
||||
"The Cozy Corner",
|
||||
"Home Sweet Home",
|
||||
"The Nest",
|
||||
"Château Us"
|
||||
String(localized: "Casa de [Your Name]"),
|
||||
String(localized: "The Cozy Corner"),
|
||||
String(localized: "Home Sweet Home"),
|
||||
String(localized: "The Nest"),
|
||||
String(localized: "Château Us")
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -18,7 +18,7 @@ class OnboardingState: ObservableObject {
|
||||
/// Whether the user has completed onboarding.
|
||||
/// This is the persisted flag read at launch to decide whether to show onboarding.
|
||||
/// When set to `true`, Kotlin DataManager is also updated to keep both layers in sync.
|
||||
@AppStorage("hasCompletedOnboarding") var hasCompletedOnboarding: Bool = false {
|
||||
@AppStorage("hasCompletedOnboarding") var hasCompletedOnboarding: Bool = false { // i18n-ignore: @AppStorage key (non-UI)
|
||||
didSet {
|
||||
// Keep Kotlin DataManager in sync so Android/shared code sees the same value.
|
||||
ComposeApp.DataManager.shared.setHasCompletedOnboarding(completed: hasCompletedOnboarding)
|
||||
@@ -26,10 +26,10 @@ class OnboardingState: ObservableObject {
|
||||
}
|
||||
|
||||
/// The name of the residence being created during onboarding
|
||||
@AppStorage("onboardingResidenceName") var pendingResidenceName: String = ""
|
||||
@AppStorage("onboardingResidenceName") var pendingResidenceName: String = "" // i18n-ignore: @AppStorage key (non-UI)
|
||||
|
||||
/// Backing storage for user intent (persisted across app restarts)
|
||||
@AppStorage("onboardingUserIntent") private var userIntentRaw: String = OnboardingIntent.unknown.rawValue
|
||||
@AppStorage("onboardingUserIntent") private var userIntentRaw: String = OnboardingIntent.unknown.rawValue // i18n-ignore: @AppStorage key (non-UI)
|
||||
|
||||
// MARK: - Transient State (via @Published, reset each session as needed)
|
||||
|
||||
@@ -179,6 +179,7 @@ enum OnboardingStep: Int, CaseIterable {
|
||||
case firstTask = 8
|
||||
case subscriptionUpsell = 9
|
||||
|
||||
// i18n-ignore-begin: dead code — `title` is never rendered (verified); pending removal
|
||||
var title: String {
|
||||
switch self {
|
||||
case .welcome:
|
||||
@@ -203,4 +204,5 @@ enum OnboardingStep: Int, CaseIterable {
|
||||
return "Go Pro"
|
||||
}
|
||||
}
|
||||
// i18n-ignore-end
|
||||
}
|
||||
|
||||
@@ -15,38 +15,38 @@ struct OnboardingSubscriptionContent: View {
|
||||
private let benefits: [SubscriptionBenefit] = [
|
||||
SubscriptionBenefit(
|
||||
icon: "building.2.fill",
|
||||
title: "Unlimited Properties",
|
||||
description: "Track every home you own—vacation houses, rentals, you name it",
|
||||
title: String(localized: "Unlimited Properties"),
|
||||
description: String(localized: "Track every home you own—vacation houses, rentals, you name it"),
|
||||
gradient: [Color.appPrimary, Color.appSecondary]
|
||||
),
|
||||
SubscriptionBenefit(
|
||||
icon: "doc.badge.plus",
|
||||
title: "Document Vault",
|
||||
description: "All your warranties, receipts, and manuals in one searchable place",
|
||||
title: String(localized: "Document Vault"),
|
||||
description: String(localized: "All your warranties, receipts, and manuals in one searchable place"),
|
||||
gradient: [Color(hex: "#34C759") ?? .green, Color(hex: "#30D158") ?? .green]
|
||||
),
|
||||
SubscriptionBenefit(
|
||||
icon: "person.3.fill",
|
||||
title: "Family Sharing",
|
||||
description: "Get everyone on the same page—literally",
|
||||
title: String(localized: "Family Sharing"),
|
||||
description: String(localized: "Get everyone on the same page—literally"),
|
||||
gradient: [Color(hex: "#AF52DE") ?? .purple, Color(hex: "#BF5AF2") ?? .purple]
|
||||
),
|
||||
SubscriptionBenefit(
|
||||
icon: "square.and.arrow.up.fill",
|
||||
title: "Contractor Sharing",
|
||||
description: "Share your trusted contractors with family and friends",
|
||||
title: String(localized: "Contractor Sharing"),
|
||||
description: String(localized: "Share your trusted contractors with family and friends"),
|
||||
gradient: [Color.appAccent, Color(hex: "#FF9500") ?? .orange]
|
||||
),
|
||||
SubscriptionBenefit(
|
||||
icon: "bell.badge.fill",
|
||||
title: "Actionable Notifications",
|
||||
description: "Complete tasks directly from notifications—no app opening needed",
|
||||
title: String(localized: "Actionable Notifications"),
|
||||
description: String(localized: "Complete tasks directly from notifications—no app opening needed"),
|
||||
gradient: [Color(hex: "#FF3B30") ?? .red, Color(hex: "#FF6961") ?? .red]
|
||||
),
|
||||
SubscriptionBenefit(
|
||||
icon: "square.grid.2x2.fill",
|
||||
title: "Home Screen Widgets",
|
||||
description: "Quick actions right from your home screen",
|
||||
title: String(localized: "Home Screen Widgets"),
|
||||
description: String(localized: "Quick actions right from your home screen"),
|
||||
gradient: [Color(hex: "#007AFF") ?? .blue, Color(hex: "#5AC8FA") ?? .blue]
|
||||
)
|
||||
]
|
||||
@@ -275,7 +275,7 @@ struct OnboardingSubscriptionContent: View {
|
||||
.naturalShadow(.medium)
|
||||
}
|
||||
.disabled(isLoading)
|
||||
.a11yButton("Start free trial")
|
||||
.a11yButton(String(localized: "Start free trial"))
|
||||
|
||||
// Continue without
|
||||
Button(action: {
|
||||
@@ -285,11 +285,13 @@ struct OnboardingSubscriptionContent: View {
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.a11yButton("Continue with free plan")
|
||||
.a11yButton(String(localized: "Continue with free plan"))
|
||||
|
||||
// Legal text
|
||||
let trialPriceValue = productForSelectedPlan()?.displayPrice ?? selectedPlan.price
|
||||
let trialPriceText = trialPriceValue + selectedPlan.period
|
||||
VStack(spacing: 4) {
|
||||
Text("7-day free trial, then \(productForSelectedPlan()?.displayPrice ?? selectedPlan.price)\(selectedPlan.period)")
|
||||
Text(String(format: String(localized: "7-day free trial, then %@"), trialPriceText))
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
|
||||
@@ -345,13 +347,13 @@ struct OnboardingSubscriptionContent: View {
|
||||
onSubscribe()
|
||||
}
|
||||
} else {
|
||||
purchaseError = "Purchase was cancelled. You can continue with Free or try again."
|
||||
purchaseError = String(localized: "Purchase was cancelled. You can continue with Free or try again.")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isLoading = false
|
||||
purchaseError = "Unable to start trial: \(error.localizedDescription)"
|
||||
purchaseError = String(format: String(localized: "Unable to start trial: %@"), error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -380,36 +382,36 @@ enum PricingPlan {
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .monthly: return "Monthly"
|
||||
case .yearly: return "Yearly"
|
||||
case .monthly: return String(localized: "Monthly")
|
||||
case .yearly: return String(localized: "Yearly")
|
||||
}
|
||||
}
|
||||
|
||||
var price: String {
|
||||
switch self {
|
||||
case .monthly: return "$2.99"
|
||||
case .yearly: return "$23.99"
|
||||
case .monthly: return String(localized: "$2.99")
|
||||
case .yearly: return String(localized: "$23.99")
|
||||
}
|
||||
}
|
||||
|
||||
var period: String {
|
||||
switch self {
|
||||
case .monthly: return "/month"
|
||||
case .yearly: return "/year"
|
||||
case .monthly: return String(localized: "/month")
|
||||
case .yearly: return String(localized: "/year")
|
||||
}
|
||||
}
|
||||
|
||||
var monthlyEquivalent: String? {
|
||||
switch self {
|
||||
case .monthly: return nil
|
||||
case .yearly: return "Just $1.99/month"
|
||||
case .yearly: return String(localized: "Just $1.99/month")
|
||||
}
|
||||
}
|
||||
|
||||
var savings: String? {
|
||||
switch self {
|
||||
case .monthly: return nil
|
||||
case .yearly: return "Save 30%"
|
||||
case .yearly: return String(localized: "Save 30%")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -442,12 +444,12 @@ private struct OrganicPricingPlanCard: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 8) {
|
||||
Text(plan.title)
|
||||
Text(LocalizedStringKey(plan.title))
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
if let savings = plan.savings {
|
||||
Text(savings)
|
||||
Text(LocalizedStringKey(savings))
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 8)
|
||||
@@ -464,7 +466,7 @@ private struct OrganicPricingPlanCard: View {
|
||||
}
|
||||
|
||||
if let monthlyEquivalent = plan.monthlyEquivalent {
|
||||
Text(monthlyEquivalent)
|
||||
Text(LocalizedStringKey(monthlyEquivalent))
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
@@ -515,7 +517,7 @@ private struct OrganicPricingPlanCard: View {
|
||||
.buttonStyle(.plain)
|
||||
.animation(.easeInOut(duration: 0.2), value: isSelected)
|
||||
.accessibilityLabel("\(plan.title) plan, \(displayPrice ?? plan.price)\(plan.period)\(plan.savings.map { ", \($0)" } ?? "")")
|
||||
.accessibilityValue(isSelected ? "Selected" : "")
|
||||
.accessibilityValue(isSelected ? String(localized: "Selected") : "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,11 +569,11 @@ private struct OrganicSubscriptionBenefitRow: View {
|
||||
.a11yDecorative()
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(benefit.title)
|
||||
Text(LocalizedStringKey(benefit.title))
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text(benefit.description)
|
||||
Text(LocalizedStringKey(benefit.description))
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.lineLimit(2)
|
||||
|
||||
@@ -84,7 +84,7 @@ final class OnboardingTasksViewModel: ObservableObject {
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
suggestionsError = ErrorMessageParser.parse(error.message)
|
||||
} else {
|
||||
suggestionsError = "Could not load suggestions."
|
||||
suggestionsError = String(localized: "Could not load suggestions.")
|
||||
}
|
||||
} catch {
|
||||
suggestionsError = ErrorMessageParser.parse(error.localizedDescription)
|
||||
@@ -107,7 +107,7 @@ final class OnboardingTasksViewModel: ObservableObject {
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
groupedError = ErrorMessageParser.parse(error.message)
|
||||
} else {
|
||||
groupedError = "Could not load templates."
|
||||
groupedError = String(localized: "Could not load templates.")
|
||||
}
|
||||
} catch {
|
||||
groupedError = ErrorMessageParser.parse(error.localizedDescription)
|
||||
@@ -143,7 +143,7 @@ final class OnboardingTasksViewModel: ObservableObject {
|
||||
submitError = ErrorMessageParser.parse(error.message)
|
||||
return false
|
||||
} else {
|
||||
submitError = "Could not create tasks."
|
||||
submitError = String(localized: "Could not create tasks.")
|
||||
return false
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -9,48 +9,48 @@ struct OnboardingValuePropsContent: View {
|
||||
private let features: [FeatureHighlight] = [
|
||||
FeatureHighlight(
|
||||
icon: "clock.badge.checkmark.fill",
|
||||
title: "Never Forget Again",
|
||||
subtitle: "Your memory just got an upgrade",
|
||||
description: "Smart reminders keep you on top of furnace filters, gutter cleaning, and everything in between. No more \"when did I last...?\" moments.",
|
||||
title: String(localized: "Never Forget Again"),
|
||||
subtitle: String(localized: "Your memory just got an upgrade"),
|
||||
description: String(localized: "Smart reminders keep you on top of furnace filters, gutter cleaning, and everything in between. No more \"when did I last...?\" moments."),
|
||||
gradient: [Color.appPrimary, Color.appSecondary],
|
||||
statNumber: "$6,000+",
|
||||
statLabel: "spent yearly on repairs that could've been prevented"
|
||||
statLabel: String(localized: "spent yearly on repairs that could've been prevented")
|
||||
),
|
||||
FeatureHighlight(
|
||||
icon: "doc.text.fill",
|
||||
title: "Warranties at Your Fingertips",
|
||||
subtitle: "No more digging through drawers",
|
||||
description: "Snap a photo of your receipts and warranties. When something breaks, you'll know exactly what's covered—instantly.",
|
||||
title: String(localized: "Warranties at Your Fingertips"),
|
||||
subtitle: String(localized: "No more digging through drawers"),
|
||||
description: String(localized: "Snap a photo of your receipts and warranties. When something breaks, you'll know exactly what's covered—instantly."),
|
||||
gradient: [Color.appAccent, Color(hex: "#FF9500") ?? .orange],
|
||||
statNumber: "47%",
|
||||
statLabel: "of homebuyers lack emergency funds for repairs"
|
||||
statLabel: String(localized: "of homebuyers lack emergency funds for repairs")
|
||||
),
|
||||
FeatureHighlight(
|
||||
icon: "person.2.fill",
|
||||
title: "The Whole Family's In",
|
||||
subtitle: "Teamwork makes the dream work",
|
||||
description: "Share your home with family members. Everyone sees what needs doing, and nobody can claim they \"didn't know.\"",
|
||||
title: String(localized: "The Whole Family's In"),
|
||||
subtitle: String(localized: "Teamwork makes the dream work"),
|
||||
description: String(localized: "Share your home with family members. Everyone sees what needs doing, and nobody can claim they \"didn't know.\""),
|
||||
gradient: [Color(hex: "#AF52DE") ?? .purple, Color(hex: "#BF5AF2") ?? .purple],
|
||||
statNumber: "56%",
|
||||
statLabel: "say sharing chores is key to a happy home"
|
||||
statLabel: String(localized: "say sharing chores is key to a happy home")
|
||||
),
|
||||
FeatureHighlight(
|
||||
icon: "bell.badge.fill",
|
||||
title: "Smart Notifications",
|
||||
subtitle: "Act right from your lock screen",
|
||||
description: "Get actionable reminders that let you complete tasks right from the notification. No need to even open the app.",
|
||||
title: String(localized: "Smart Notifications"),
|
||||
subtitle: String(localized: "Act right from your lock screen"),
|
||||
description: String(localized: "Get actionable reminders that let you complete tasks right from the notification. No need to even open the app."),
|
||||
gradient: [Color(hex: "#FF3B30") ?? .red, Color(hex: "#FF6961") ?? .red],
|
||||
statNumber: "3x",
|
||||
statLabel: "faster task completion with actionable notifications"
|
||||
statLabel: String(localized: "faster task completion with actionable notifications")
|
||||
),
|
||||
FeatureHighlight(
|
||||
icon: "square.grid.2x2.fill",
|
||||
title: "Home Screen Widgets",
|
||||
subtitle: "Your tasks at a glance",
|
||||
description: "Quick access to upcoming tasks and reminders directly from your home screen. Stay on top of maintenance without opening the app.",
|
||||
title: String(localized: "Home Screen Widgets"),
|
||||
subtitle: String(localized: "Your tasks at a glance"),
|
||||
description: String(localized: "Quick access to upcoming tasks and reminders directly from your home screen. Stay on top of maintenance without opening the app."),
|
||||
gradient: [Color(hex: "#007AFF") ?? .blue, Color(hex: "#5AC8FA") ?? .blue],
|
||||
statNumber: "68%",
|
||||
statLabel: "of widget users complete tasks on time"
|
||||
statLabel: String(localized: "of widget users complete tasks on time")
|
||||
)
|
||||
]
|
||||
|
||||
@@ -180,13 +180,13 @@ struct OrganicFeatureCard: View {
|
||||
|
||||
// Text content
|
||||
VStack(spacing: 10) {
|
||||
Text(feature.title)
|
||||
Text(LocalizedStringKey(feature.title))
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
.a11yHeader()
|
||||
|
||||
Text(feature.subtitle)
|
||||
Text(LocalizedStringKey(feature.subtitle))
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
@@ -197,7 +197,7 @@ struct OrganicFeatureCard: View {
|
||||
)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(feature.description)
|
||||
Text(LocalizedStringKey(feature.description))
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -221,7 +221,7 @@ struct OrganicFeatureCard: View {
|
||||
)
|
||||
)
|
||||
|
||||
Text(feature.statLabel)
|
||||
Text(LocalizedStringKey(feature.statLabel))
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -220,7 +220,7 @@ struct OnboardingVerifyEmailContent: View {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
Text(viewModel.isLoading ? "Verifying..." : "Verify")
|
||||
Text(viewModel.isLoading ? String(localized: "Verifying...") : String(localized: "Verify"))
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
@@ -141,7 +141,7 @@ struct ForgotPasswordView: View {
|
||||
} else {
|
||||
Image(systemName: "envelope.fill")
|
||||
}
|
||||
Text(viewModel.isLoading ? "Sending..." : "Send Reset Code")
|
||||
Text(viewModel.isLoading ? String(localized: "Sending...") : String(localized: "Send Reset Code"))
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ class PasswordResetViewModel: ObservableObject {
|
||||
|
||||
if result is ApiResultSuccess<ForgotPasswordResponse> {
|
||||
self.isLoading = false
|
||||
self.successMessage = "Check your email for a 6-digit verification code"
|
||||
self.successMessage = String(localized: "Check your email for a 6-digit verification code")
|
||||
|
||||
// Automatically move to next step after short delay
|
||||
self.delayedTransitionTask?.cancel()
|
||||
@@ -81,7 +81,7 @@ class PasswordResetViewModel: ObservableObject {
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
} else {
|
||||
self.isLoading = false
|
||||
self.errorMessage = "Failed to request password reset"
|
||||
self.errorMessage = String(localized: "Failed to request password reset")
|
||||
}
|
||||
} catch {
|
||||
self.isLoading = false
|
||||
@@ -109,7 +109,7 @@ class PasswordResetViewModel: ObservableObject {
|
||||
let token = response.resetToken
|
||||
self.resetToken = token
|
||||
self.isLoading = false
|
||||
self.successMessage = "Code verified! Now set your new password"
|
||||
self.successMessage = String(localized: "Code verified! Now set your new password")
|
||||
|
||||
// Automatically move to next step after short delay
|
||||
self.delayedTransitionTask?.cancel()
|
||||
@@ -124,7 +124,7 @@ class PasswordResetViewModel: ObservableObject {
|
||||
self.handleVerifyError(ErrorMessageParser.parse(error.message))
|
||||
} else {
|
||||
self.isLoading = false
|
||||
self.errorMessage = "Failed to verify code"
|
||||
self.errorMessage = String(localized: "Failed to verify code")
|
||||
}
|
||||
} catch {
|
||||
self.isLoading = false
|
||||
@@ -151,7 +151,7 @@ class PasswordResetViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
guard let token = resetToken else {
|
||||
errorMessage = "Invalid reset token. Please start over."
|
||||
errorMessage = String(localized: "Invalid reset token. Please start over.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ class PasswordResetViewModel: ObservableObject {
|
||||
|
||||
if result is ApiResultSuccess<ResetPasswordResponse> {
|
||||
// Password reset successful - now auto-login
|
||||
self.successMessage = "Password reset successfully! Logging you in..."
|
||||
self.successMessage = String(localized: "Password reset successfully! Logging you in...")
|
||||
self.currentStep = .loggingIn
|
||||
await self.autoLogin()
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
@@ -176,7 +176,7 @@ class PasswordResetViewModel: ObservableObject {
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
} else {
|
||||
self.isLoading = false
|
||||
self.errorMessage = "Failed to reset password"
|
||||
self.errorMessage = String(localized: "Failed to reset password")
|
||||
}
|
||||
} catch {
|
||||
self.isLoading = false
|
||||
@@ -193,7 +193,7 @@ class PasswordResetViewModel: ObservableObject {
|
||||
guard !username.isEmpty else {
|
||||
// If we don't have the email (e.g., deep link flow), fall back to manual login
|
||||
self.isLoading = false
|
||||
self.successMessage = "Password reset successfully! You can now log in with your new password."
|
||||
self.successMessage = String(localized: "Password reset successfully! You can now log in with your new password.")
|
||||
self.currentStep = .success
|
||||
return
|
||||
}
|
||||
@@ -220,11 +220,11 @@ class PasswordResetViewModel: ObservableObject {
|
||||
print("Auto-login failed: \(error.message)")
|
||||
#endif
|
||||
self.isLoading = false
|
||||
self.successMessage = "Password reset successfully! You can now log in with your new password."
|
||||
self.successMessage = String(localized: "Password reset successfully! You can now log in with your new password.")
|
||||
self.currentStep = .success
|
||||
} else {
|
||||
self.isLoading = false
|
||||
self.successMessage = "Password reset successfully! You can now log in with your new password."
|
||||
self.successMessage = String(localized: "Password reset successfully! You can now log in with your new password.")
|
||||
self.currentStep = .success
|
||||
}
|
||||
} catch {
|
||||
@@ -233,7 +233,7 @@ class PasswordResetViewModel: ObservableObject {
|
||||
print("Auto-login error: \(error.localizedDescription)")
|
||||
#endif
|
||||
self.isLoading = false
|
||||
self.successMessage = "Password reset successfully! You can now log in with your new password."
|
||||
self.successMessage = String(localized: "Password reset successfully! You can now log in with your new password.")
|
||||
self.currentStep = .success
|
||||
}
|
||||
}
|
||||
@@ -302,11 +302,11 @@ class PasswordResetViewModel: ObservableObject {
|
||||
let normalized = message.lowercased()
|
||||
|
||||
if normalized.contains("expired") {
|
||||
errorMessage = "Reset code has expired. Please request a new one."
|
||||
errorMessage = String(localized: "Reset code has expired. Please request a new one.")
|
||||
} else if normalized.contains("attempts") {
|
||||
errorMessage = "Too many failed attempts. Please request a new reset code."
|
||||
errorMessage = String(localized: "Too many failed attempts. Please request a new reset code.")
|
||||
} else if normalized.contains("invalid") && normalized.contains("token") {
|
||||
errorMessage = "Invalid or expired reset token. Please start over."
|
||||
errorMessage = String(localized: "Invalid or expired reset token. Please start over.")
|
||||
} else {
|
||||
errorMessage = message
|
||||
}
|
||||
|
||||
@@ -90,23 +90,23 @@ struct ResetPasswordView: View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
RequirementRow(
|
||||
isMet: hasMinLength,
|
||||
text: "At least 8 characters"
|
||||
text: String(localized: "At least 8 characters")
|
||||
)
|
||||
RequirementRow(
|
||||
isMet: hasUppercase,
|
||||
text: "Contains an uppercase letter"
|
||||
text: String(localized: "Contains an uppercase letter")
|
||||
)
|
||||
RequirementRow(
|
||||
isMet: hasLowercase,
|
||||
text: "Contains a lowercase letter"
|
||||
text: String(localized: "Contains a lowercase letter")
|
||||
)
|
||||
RequirementRow(
|
||||
isMet: hasNumber,
|
||||
text: "Contains a number"
|
||||
text: String(localized: "Contains a number")
|
||||
)
|
||||
RequirementRow(
|
||||
isMet: passwordsMatch,
|
||||
text: "Passwords match"
|
||||
text: String(localized: "Passwords match")
|
||||
)
|
||||
}
|
||||
.padding(16)
|
||||
@@ -255,7 +255,7 @@ struct ResetPasswordView: View {
|
||||
} else {
|
||||
Image(systemName: "lock.shield.fill")
|
||||
}
|
||||
Text(viewModel.currentStep == .loggingIn ? "Logging in..." : (viewModel.isLoading ? "Resetting..." : "Reset Password"))
|
||||
Text(viewModel.currentStep == .loggingIn ? String(localized: "Logging in...") : (viewModel.isLoading ? String(localized: "Resetting...") : String(localized: "Reset Password")))
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
@@ -317,7 +317,7 @@ struct ResetPasswordView: View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: viewModel.currentStep == .success ? "xmark" : "chevron.left")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
Text(viewModel.currentStep == .success ? "Close" : "Back")
|
||||
Text(viewModel.currentStep == .success ? String(localized: "Close") : String(localized: "Back"))
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
}
|
||||
.foregroundColor(Color.appPrimary)
|
||||
@@ -343,7 +343,7 @@ private struct RequirementRow: View {
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(isMet ? Color.appPrimary : Color.appTextSecondary)
|
||||
|
||||
Text(text)
|
||||
Text(LocalizedStringKey(text))
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(isMet ? Color.appTextPrimary : Color.appTextSecondary)
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ struct VerifyResetCodeView: View {
|
||||
} else {
|
||||
Image(systemName: "checkmark.shield.fill")
|
||||
}
|
||||
Text(viewModel.isLoading ? "Verifying..." : "Verify Code")
|
||||
Text(viewModel.isLoading ? String(localized: "Verifying...") : String(localized: "Verify Code"))
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ struct AnimationTestingCard: View {
|
||||
}
|
||||
|
||||
private var priorityBadge: some View {
|
||||
Text(task.priority.rawValue)
|
||||
Text(task.priority.displayName)
|
||||
.font(.system(size: 10, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
@@ -3,14 +3,27 @@ import SwiftUI
|
||||
// MARK: - Animation Type Enum
|
||||
|
||||
enum TaskAnimationType: String, CaseIterable, Identifiable {
|
||||
// i18n-ignore-begin: rawValue is a stable persisted id (AnimationPreference); UI shows displayName
|
||||
case none = "None"
|
||||
case implode = "Implode"
|
||||
case firework = "Firework"
|
||||
case starburst = "Starburst"
|
||||
case ripple = "Ripple"
|
||||
// i18n-ignore-end
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// Localized name shown in the picker and Profile row.
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .none: return String(localized: "None")
|
||||
case .implode: return String(localized: "Implode")
|
||||
case .firework: return String(localized: "Firework")
|
||||
case .starburst: return String(localized: "Starburst")
|
||||
case .ripple: return String(localized: "Ripple")
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .none: return "minus.circle"
|
||||
@@ -23,11 +36,11 @@ enum TaskAnimationType: String, CaseIterable, Identifiable {
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .none: return "No animation, instant move"
|
||||
case .implode: return "Sucks into center, becomes checkmark"
|
||||
case .firework: return "Explodes into colorful sparks"
|
||||
case .starburst: return "Radiating rays from checkmark"
|
||||
case .ripple: return "Checkmark with expanding rings"
|
||||
case .none: return String(localized: "No animation, instant move")
|
||||
case .implode: return String(localized: "Sucks into center, becomes checkmark")
|
||||
case .firework: return String(localized: "Explodes into colorful sparks")
|
||||
case .starburst: return String(localized: "Radiating rays from checkmark")
|
||||
case .ripple: return String(localized: "Checkmark with expanding rings")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,9 +79,20 @@ struct TestTask: Identifiable, Equatable {
|
||||
var dueDate: String
|
||||
|
||||
enum TestPriority: String {
|
||||
// i18n-ignore-begin: rawValue is an internal id; the badge renders displayName
|
||||
case high = "High"
|
||||
case medium = "Medium"
|
||||
case low = "Low"
|
||||
// i18n-ignore-end
|
||||
|
||||
/// Localized label shown in the preview card's priority badge.
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .high: return String(localized: "High")
|
||||
case .medium: return String(localized: "Medium")
|
||||
case .low: return String(localized: "Low")
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
@@ -79,6 +103,7 @@ struct TestTask: Identifiable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// i18n-ignore-begin: mock sample tasks shown in the completion-animation preview (not real content)
|
||||
static let samples: [TestTask] = [
|
||||
TestTask(
|
||||
id: "1",
|
||||
@@ -105,6 +130,7 @@ struct TestTask: Identifiable, Equatable {
|
||||
dueDate: "Dec 30"
|
||||
)
|
||||
]
|
||||
// i18n-ignore-end
|
||||
}
|
||||
|
||||
struct TestColumn: Identifiable, Equatable {
|
||||
@@ -118,22 +144,22 @@ struct TestColumn: Identifiable, Equatable {
|
||||
[
|
||||
TestColumn(
|
||||
id: "todo",
|
||||
name: "To Do",
|
||||
displayName: "To Do",
|
||||
name: "todo", // i18n-ignore: internal column id
|
||||
displayName: String(localized: "To Do"),
|
||||
color: Color.appAccent,
|
||||
tasks: TestTask.samples
|
||||
),
|
||||
TestColumn(
|
||||
id: "progress",
|
||||
name: "In Progress",
|
||||
displayName: "In Progress",
|
||||
name: "progress", // i18n-ignore: internal column id
|
||||
displayName: String(localized: "In Progress"),
|
||||
color: Color.appPrimary,
|
||||
tasks: []
|
||||
),
|
||||
TestColumn(
|
||||
id: "done",
|
||||
name: "Done",
|
||||
displayName: "Done",
|
||||
name: "done", // i18n-ignore: internal column id
|
||||
displayName: String(localized: "Done"),
|
||||
color: Color.appPrimary.opacity(0.6),
|
||||
tasks: []
|
||||
)
|
||||
@@ -153,7 +179,7 @@ struct AnimationChip: View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: animation.icon)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
Text(animation.rawValue)
|
||||
Text(animation.displayName)
|
||||
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
||||
}
|
||||
.foregroundColor(isSelected ? Color.appTextOnPrimary : Color.appTextPrimary)
|
||||
|
||||
@@ -435,7 +435,7 @@ class NotificationPreferencesViewModelWrapper: ObservableObject {
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
} else {
|
||||
self.errorMessage = "Failed to load notification preferences"
|
||||
self.errorMessage = String(localized: "Failed to load notification preferences")
|
||||
self.isLoading = false
|
||||
}
|
||||
} catch {
|
||||
@@ -491,7 +491,7 @@ class NotificationPreferencesViewModelWrapper: ObservableObject {
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isSaving = false
|
||||
} else {
|
||||
self.errorMessage = "Failed to save notification preferences"
|
||||
self.errorMessage = String(localized: "Failed to save notification preferences")
|
||||
self.isSaving = false
|
||||
}
|
||||
} catch {
|
||||
@@ -551,10 +551,10 @@ class NotificationPreferencesViewModelWrapper: ObservableObject {
|
||||
// Format hour to display string
|
||||
func formatHour(_ hour: Int) -> String {
|
||||
switch hour {
|
||||
case 0: return "12:00 AM"
|
||||
case 1...11: return "\(hour):00 AM"
|
||||
case 12: return "12:00 PM"
|
||||
default: return "\(hour - 12):00 PM"
|
||||
case 0: return String(localized: "12:00 AM")
|
||||
case 1...11: return String(format: String(localized: "%lld:00 AM"), hour)
|
||||
case 12: return String(localized: "12:00 PM")
|
||||
default: return String(format: String(localized: "%lld:00 PM"), hour - 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -637,10 +637,10 @@ struct TimePickerSheet: View {
|
||||
private static let hourOptions: [HourOption] = (0..<24).map { hour in
|
||||
let label: String
|
||||
switch hour {
|
||||
case 0: label = "12:00 AM"
|
||||
case 1...11: label = "\(hour):00 AM"
|
||||
case 12: label = "12:00 PM"
|
||||
default: label = "\(hour - 12):00 PM"
|
||||
case 0: label = String(localized: "12:00 AM")
|
||||
case 1...11: label = String(format: String(localized: "%lld:00 AM"), hour)
|
||||
case 12: label = String(localized: "12:00 PM")
|
||||
default: label = String(format: String(localized: "%lld:00 PM"), hour - 12)
|
||||
}
|
||||
return HourOption(id: hour, label: label)
|
||||
}
|
||||
@@ -672,7 +672,7 @@ struct TimePickerSheet: View {
|
||||
}
|
||||
.pickerStyle(.wheel)
|
||||
.frame(height: 150)
|
||||
.id("hourPicker") // Stable identity to prevent view recycling issues
|
||||
.id("hourPicker") // i18n-ignore: SwiftUI view identity (non-UI)
|
||||
}
|
||||
|
||||
Text("Notifications will be sent at \(formatHour(selectedHour)) in your local timezone")
|
||||
|
||||
@@ -207,7 +207,7 @@ struct ProfileTabView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(animationPreference.selectedAnimation.rawValue)
|
||||
Text(LocalizedStringKey(animationPreference.selectedAnimation.rawValue))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
|
||||
@@ -217,7 +217,7 @@ struct ProfileTabView: View {
|
||||
.a11yDecorative()
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Completion Animation, \(animationPreference.selectedAnimation.rawValue)")
|
||||
.accessibilityLabel(String(format: String(localized: "Completion Animation, %@"), animationPreference.selectedAnimation.displayName))
|
||||
}
|
||||
.sectionBackground()
|
||||
|
||||
@@ -335,9 +335,9 @@ struct ProfileTabView: View {
|
||||
}
|
||||
|
||||
private func sendSupportEmail() {
|
||||
let email = "honeydue@treymail.com"
|
||||
let subject = "honeyDue Support Request"
|
||||
let urlString = "mailto:\(email)?subject=\(subject.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? subject)"
|
||||
let email = "honeydue@treymail.com" // i18n-ignore: support email address (non-UI)
|
||||
let subject = String(localized: "honeyDue Support Request")
|
||||
let urlString = "mailto:\(email)?subject=\(subject.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? subject)" // i18n-ignore: mailto URL construction (non-UI)
|
||||
|
||||
if let url = URL(string: urlString) {
|
||||
UIApplication.shared.open(url)
|
||||
|
||||
@@ -61,7 +61,7 @@ class ProfileViewModel: ObservableObject {
|
||||
// MARK: - Public Methods
|
||||
func loadCurrentUser() {
|
||||
guard tokenStorage.getToken() != nil else {
|
||||
errorMessage = "Not authenticated"
|
||||
errorMessage = String(localized: "Not authenticated")
|
||||
isLoadingUser = false
|
||||
return
|
||||
}
|
||||
@@ -87,7 +87,7 @@ class ProfileViewModel: ObservableObject {
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoadingUser = false
|
||||
} else {
|
||||
self.errorMessage = "Failed to load profile"
|
||||
self.errorMessage = String(localized: "Failed to load profile")
|
||||
self.isLoadingUser = false
|
||||
}
|
||||
} catch {
|
||||
@@ -99,12 +99,12 @@ class ProfileViewModel: ObservableObject {
|
||||
|
||||
func updateProfile() {
|
||||
guard !email.isEmpty else {
|
||||
errorMessage = "Email is required"
|
||||
errorMessage = String(localized: "Email is required")
|
||||
return
|
||||
}
|
||||
|
||||
guard let token = tokenStorage.getToken() else {
|
||||
errorMessage = "Not authenticated"
|
||||
errorMessage = String(localized: "Not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -125,14 +125,14 @@ class ProfileViewModel: ObservableObject {
|
||||
if result is ApiResultSuccess<User> {
|
||||
self.isLoading = false
|
||||
self.errorMessage = nil
|
||||
self.successMessage = "Profile updated successfully"
|
||||
self.successMessage = String(localized: "Profile updated successfully")
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
self.isLoading = false
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.successMessage = nil
|
||||
} else {
|
||||
self.isLoading = false
|
||||
self.errorMessage = "Failed to update profile"
|
||||
self.errorMessage = String(localized: "Failed to update profile")
|
||||
self.successMessage = nil
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -148,7 +148,7 @@ struct ThemeRow: View {
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityLabel(theme.displayName)
|
||||
.accessibilityValue(isSelected ? "Selected" : "")
|
||||
.accessibilityValue(isSelected ? String(localized: "Selected") : "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,19 +54,19 @@ struct NotificationCategories {
|
||||
private static func createTaskActionableCategory() -> UNNotificationCategory {
|
||||
let completeAction = UNNotificationAction(
|
||||
identifier: NotificationActionID.completeTask.rawValue,
|
||||
title: "Complete",
|
||||
title: String(localized: "Complete"),
|
||||
options: []
|
||||
)
|
||||
|
||||
let inProgressAction = UNNotificationAction(
|
||||
identifier: NotificationActionID.markInProgress.rawValue,
|
||||
title: "Start",
|
||||
title: String(localized: "Start"),
|
||||
options: []
|
||||
)
|
||||
|
||||
let cancelAction = UNNotificationAction(
|
||||
identifier: NotificationActionID.cancelTask.rawValue,
|
||||
title: "Cancel",
|
||||
title: String(localized: "Cancel"),
|
||||
options: [.destructive]
|
||||
)
|
||||
|
||||
@@ -83,13 +83,13 @@ struct NotificationCategories {
|
||||
private static func createTaskInProgressCategory() -> UNNotificationCategory {
|
||||
let completeAction = UNNotificationAction(
|
||||
identifier: NotificationActionID.completeTask.rawValue,
|
||||
title: "Complete",
|
||||
title: String(localized: "Complete"),
|
||||
options: []
|
||||
)
|
||||
|
||||
let cancelAction = UNNotificationAction(
|
||||
identifier: NotificationActionID.cancelTask.rawValue,
|
||||
title: "Cancel",
|
||||
title: String(localized: "Cancel"),
|
||||
options: [.destructive]
|
||||
)
|
||||
|
||||
@@ -106,7 +106,7 @@ struct NotificationCategories {
|
||||
private static func createTaskCancelledCategory() -> UNNotificationCategory {
|
||||
let uncancelAction = UNNotificationAction(
|
||||
identifier: NotificationActionID.uncancelTask.rawValue,
|
||||
title: "Restore",
|
||||
title: String(localized: "Restore"),
|
||||
options: []
|
||||
)
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
// MARK: - Token Management
|
||||
|
||||
func didRegisterForRemoteNotifications(withDeviceToken deviceToken: Data) {
|
||||
let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
|
||||
let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() // i18n-ignore: hex byte format spec (non-UI)
|
||||
self.deviceToken = tokenString
|
||||
let redactedToken = "\(tokenString.prefix(8))...\(tokenString.suffix(8))"
|
||||
#if DEBUG
|
||||
@@ -527,7 +527,7 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
NotificationCenter.default.post(
|
||||
name: .navigateToTask,
|
||||
object: nil,
|
||||
userInfo: ["taskId": taskId]
|
||||
userInfo: ["taskId": taskId] // i18n-ignore: NotificationCenter userInfo key (non-UI)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -551,7 +551,7 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
NotificationCenter.default.post(
|
||||
name: .navigateToEditTask,
|
||||
object: nil,
|
||||
userInfo: ["taskId": taskId]
|
||||
userInfo: ["taskId": taskId] // i18n-ignore: NotificationCenter userInfo key (non-UI)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -563,7 +563,7 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
NotificationCenter.default.post(
|
||||
name: .navigateToResidence,
|
||||
object: nil,
|
||||
userInfo: ["residenceId": residenceId]
|
||||
userInfo: ["residenceId": residenceId] // i18n-ignore: NotificationCenter userInfo key (non-UI)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -579,7 +579,7 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
NotificationCenter.default.post(
|
||||
name: .navigateToDocument,
|
||||
object: nil,
|
||||
userInfo: ["documentId": documentId]
|
||||
userInfo: ["documentId": documentId] // i18n-ignore: NotificationCenter userInfo key (non-UI)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -623,7 +623,7 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
let taskId = stringValue(for: "task_id", in: userInfo) ?? "none"
|
||||
let residenceId = stringValue(for: "residence_id", in: userInfo) ?? "none"
|
||||
let documentId = stringValue(for: "document_id", in: userInfo) ?? "none"
|
||||
return "type=\(type), notification_id=\(notificationId), task_id=\(taskId), residence_id=\(residenceId), document_id=\(documentId)"
|
||||
return "type=\(type), notification_id=\(notificationId), task_id=\(taskId), residence_id=\(residenceId), document_id=\(documentId)" // i18n-ignore: redacted debug log summary (non-UI)
|
||||
}
|
||||
|
||||
private func markNotificationAsRead(notificationId: String) async {
|
||||
|
||||
@@ -148,11 +148,11 @@ struct RegisterView: View {
|
||||
.tracking(1.2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
PasswordRequirementRow(isMet: hasMinLength, text: "At least 8 characters")
|
||||
PasswordRequirementRow(isMet: hasUppercase, text: "Contains an uppercase letter")
|
||||
PasswordRequirementRow(isMet: hasLowercase, text: "Contains a lowercase letter")
|
||||
PasswordRequirementRow(isMet: hasDigit, text: "Contains a number")
|
||||
PasswordRequirementRow(isMet: passwordsMatch, text: "Passwords match")
|
||||
PasswordRequirementRow(isMet: hasMinLength, text: String(localized: "At least 8 characters"))
|
||||
PasswordRequirementRow(isMet: hasUppercase, text: String(localized: "Contains an uppercase letter"))
|
||||
PasswordRequirementRow(isMet: hasLowercase, text: String(localized: "Contains a lowercase letter"))
|
||||
PasswordRequirementRow(isMet: hasDigit, text: String(localized: "Contains a number"))
|
||||
PasswordRequirementRow(isMet: passwordsMatch, text: String(localized: "Passwords match"))
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||
@@ -388,7 +388,7 @@ private struct PasswordRequirementRow: View {
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(isMet ? Color.appPrimary : Color.appTextSecondary)
|
||||
|
||||
Text(text)
|
||||
Text(LocalizedStringKey(text))
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(isMet ? Color.appTextPrimary : Color.appTextSecondary)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ class RegisterViewModel: ObservableObject {
|
||||
// MARK: - Public Methods
|
||||
func register() {
|
||||
// Validation using ValidationRules
|
||||
if let error = ValidationRules.validateRequired(username, fieldName: "Username") {
|
||||
if let error = ValidationRules.validateRequired(username, fieldName: String(localized: "Username")) {
|
||||
errorMessage = error.errorDescription
|
||||
return
|
||||
}
|
||||
@@ -92,7 +92,7 @@ class RegisterViewModel: ObservableObject {
|
||||
// Bad request - validation errors
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
case 500...599:
|
||||
self.errorMessage = "Server error. Please try again later."
|
||||
self.errorMessage = String(localized: "Server error. Please try again later.")
|
||||
default:
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
}
|
||||
@@ -101,7 +101,7 @@ class RegisterViewModel: ObservableObject {
|
||||
}
|
||||
self.isLoading = false
|
||||
} else {
|
||||
self.errorMessage = "Failed to create account"
|
||||
self.errorMessage = String(localized: "Failed to create account")
|
||||
self.isLoading = false
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -115,7 +115,7 @@ struct JoinResidenceView: View {
|
||||
} else {
|
||||
Image(systemName: "person.badge.plus")
|
||||
}
|
||||
Text(viewModel.isLoading ? "Joining..." : L10n.Residences.joinButton)
|
||||
Text(viewModel.isLoading ? String(localized: "Joining...") : L10n.Residences.joinButton)
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ struct ManageUsersView: View {
|
||||
)) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(sharingManager.errorMessage ?? "Failed to create share link.")
|
||||
Text(sharingManager.errorMessage ?? String(localized: "Failed to create share link."))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ struct ManageUsersView: View {
|
||||
|
||||
private func loadUsers() {
|
||||
guard TokenStorage.shared.getToken() != nil else {
|
||||
errorMessage = "Not authenticated"
|
||||
errorMessage = String(localized: "Not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ struct ManageUsersView: View {
|
||||
self.errorMessage = ErrorMessageParser.parse(errorResult.message)
|
||||
self.isLoading = false
|
||||
} else {
|
||||
self.errorMessage = "Failed to load users"
|
||||
self.errorMessage = String(localized: "Failed to load users")
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
@@ -246,7 +246,7 @@ struct ManageUsersView: View {
|
||||
self.errorMessage = ErrorMessageParser.parse(errorResult.message)
|
||||
self.isGeneratingCode = false
|
||||
} else {
|
||||
self.errorMessage = "Failed to generate share code"
|
||||
self.errorMessage = String(localized: "Failed to generate share code")
|
||||
self.isGeneratingCode = false
|
||||
}
|
||||
}
|
||||
@@ -273,7 +273,7 @@ struct ManageUsersView: View {
|
||||
} else if let errorResult = ApiResultBridge.error(from: result) {
|
||||
self.errorMessage = ErrorMessageParser.parse(errorResult.message)
|
||||
} else {
|
||||
self.errorMessage = "Failed to remove user"
|
||||
self.errorMessage = String(localized: "Failed to remove user")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -511,7 +511,7 @@ private extension ResidenceDetailView {
|
||||
} else if let errorResult = ApiResultBridge.error(from: result) {
|
||||
self.viewModel.errorMessage = ErrorMessageParser.parse(errorResult.message)
|
||||
} else {
|
||||
self.viewModel.errorMessage = "Failed to delete residence"
|
||||
self.viewModel.errorMessage = String(localized: "Failed to delete residence")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -544,7 +544,7 @@ private extension ResidenceDetailView {
|
||||
self.contractorsError = errorResult.message
|
||||
self.isLoadingContractors = false
|
||||
} else {
|
||||
self.contractorsError = "Failed to load contractors"
|
||||
self.contractorsError = String(localized: "Failed to load contractors")
|
||||
self.isLoadingContractors = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ class ResidenceSharingManager: ObservableObject {
|
||||
do {
|
||||
result = try await APILayer.shared.generateSharePackage(residenceId: residence.id)
|
||||
} catch {
|
||||
errorMessage = "Failed to generate share code: \(error.localizedDescription)"
|
||||
errorMessage = String(format: String(localized: "Failed to generate share code: %@"), error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ class ResidenceSharingManager: ObservableObject {
|
||||
if let error = ApiResultBridge.error(from: result) {
|
||||
errorMessage = ErrorMessageParser.parse(error.message)
|
||||
} else {
|
||||
errorMessage = "Failed to generate share code"
|
||||
errorMessage = String(localized: "Failed to generate share code")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -69,7 +69,7 @@ class ResidenceSharingManager: ObservableObject {
|
||||
#if DEBUG
|
||||
print("ResidenceSharingManager: Failed to encode residence package as UTF-8")
|
||||
#endif
|
||||
errorMessage = "Failed to create share file"
|
||||
errorMessage = String(localized: "Failed to create share file")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ class ResidenceSharingManager: ObservableObject {
|
||||
#if DEBUG
|
||||
print("ResidenceSharingManager: Failed to write .honeydue file: \(error)")
|
||||
#endif
|
||||
errorMessage = "Failed to save share file"
|
||||
errorMessage = String(localized: "Failed to save share file")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,7 @@ class ResidenceSharingManager: ObservableObject {
|
||||
|
||||
// Verify user is authenticated
|
||||
guard TokenStorage.shared.getToken() != nil else {
|
||||
errorMessage = "You must be logged in to join a residence"
|
||||
errorMessage = String(localized: "You must be logged in to join a residence")
|
||||
isImporting = false
|
||||
completion(false)
|
||||
return
|
||||
@@ -137,7 +137,7 @@ class ResidenceSharingManager: ObservableObject {
|
||||
self.isImporting = false
|
||||
completion(false)
|
||||
} else {
|
||||
self.errorMessage = "Unknown error occurred"
|
||||
self.errorMessage = String(localized: "Unknown error occurred")
|
||||
self.isImporting = false
|
||||
completion(false)
|
||||
}
|
||||
@@ -148,7 +148,7 @@ class ResidenceSharingManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errorMessage = "Failed to read residence share file: \(error.localizedDescription)"
|
||||
errorMessage = String(format: String(localized: "Failed to read residence share file: %@"), error.localizedDescription)
|
||||
isImporting = false
|
||||
completion(false)
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ class ResidenceViewModel: ObservableObject {
|
||||
if UITestRuntime.shouldMockAuth {
|
||||
if Self.uiTestMockResidences.isEmpty || forceRefresh {
|
||||
if Self.uiTestMockResidences.isEmpty {
|
||||
Self.uiTestMockResidences = [makeMockResidence(name: "Seed Residence")]
|
||||
Self.uiTestMockResidences = [makeMockResidence(name: "Seed Residence")] // i18n-ignore: UI-test mock data (non-UI)
|
||||
}
|
||||
}
|
||||
myResidences = MyResidencesResponse(residences: Self.uiTestMockResidences)
|
||||
@@ -179,7 +179,7 @@ class ResidenceViewModel: ObservableObject {
|
||||
if UITestRuntime.shouldMockAuth {
|
||||
selectedResidence = Self.uiTestMockResidences.first(where: { $0.id == id })
|
||||
isLoading = false
|
||||
errorMessage = selectedResidence == nil ? "Residence not found" : nil
|
||||
errorMessage = selectedResidence == nil ? String(localized: "Residence not found") : nil
|
||||
return
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ class ResidenceViewModel: ObservableObject {
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
} else {
|
||||
self.errorMessage = "Failed to load residence"
|
||||
self.errorMessage = String(localized: "Failed to load residence")
|
||||
self.isLoading = false
|
||||
}
|
||||
} catch {
|
||||
@@ -286,7 +286,7 @@ class ResidenceViewModel: ObservableObject {
|
||||
self.isLoading = false
|
||||
completion(false)
|
||||
} else {
|
||||
self.errorMessage = "Failed to update residence"
|
||||
self.errorMessage = String(localized: "Failed to update residence")
|
||||
self.isLoading = false
|
||||
completion(false)
|
||||
}
|
||||
@@ -307,13 +307,13 @@ class ResidenceViewModel: ObservableObject {
|
||||
let result = try await APILayer.shared.generateTasksReport(residenceId: residenceId, email: email)
|
||||
|
||||
if let success = result as? ApiResultSuccess<GenerateReportResponse> {
|
||||
self.reportMessage = success.data?.message ?? "Report generated, but no message returned."
|
||||
self.reportMessage = success.data?.message ?? String(localized: "Report generated, but no message returned.")
|
||||
self.isGeneratingReport = false
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
self.reportMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isGeneratingReport = false
|
||||
} else {
|
||||
self.reportMessage = "Failed to generate report"
|
||||
self.reportMessage = String(localized: "Failed to generate report")
|
||||
self.isGeneratingReport = false
|
||||
}
|
||||
} catch {
|
||||
@@ -346,7 +346,7 @@ class ResidenceViewModel: ObservableObject {
|
||||
self.isLoading = false
|
||||
completion(false)
|
||||
} else {
|
||||
self.errorMessage = "Failed to join residence"
|
||||
self.errorMessage = String(localized: "Failed to join residence")
|
||||
self.isLoading = false
|
||||
completion(false)
|
||||
}
|
||||
@@ -367,15 +367,15 @@ class ResidenceViewModel: ObservableObject {
|
||||
) -> ResidenceResponse {
|
||||
let id = Self.uiTestNextResidenceId
|
||||
Self.uiTestNextResidenceId += 1
|
||||
let now = "2026-02-20T00:00:00Z"
|
||||
let now = "2026-02-20T00:00:00Z" // i18n-ignore: ISO timestamp for UI-test mock data (non-UI)
|
||||
return ResidenceResponse(
|
||||
id: Int32(id),
|
||||
ownerId: 1,
|
||||
owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@example.com", firstName: "UI", lastName: "Tester"),
|
||||
owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@example.com", firstName: "UI", lastName: "Tester"), // i18n-ignore: UI-test mock data (non-UI)
|
||||
users: [],
|
||||
name: name,
|
||||
propertyTypeId: 1,
|
||||
propertyType: ResidenceType(id: 1, name: "House"),
|
||||
propertyType: ResidenceType(id: 1, name: "House", displayNameLocalized: ""), // i18n-ignore: UI-test mock model value (non-UI)
|
||||
streetAddress: streetAddress,
|
||||
apartmentUnit: "",
|
||||
city: city,
|
||||
|
||||
@@ -133,7 +133,7 @@ struct ResidencesListView: View {
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .navigateToResidence)) { notification in
|
||||
if let residenceId = notification.userInfo?["residenceId"] as? Int {
|
||||
if let residenceId = notification.userInfo?["residenceId"] as? Int { // i18n-ignore: NotificationCenter userInfo key (non-UI)
|
||||
navigateToResidenceFromPush(residenceId: residenceId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ struct PrimaryButton: View {
|
||||
.cornerRadius(AppRadius.md)
|
||||
}
|
||||
.disabled(isDisabled || isLoading)
|
||||
.accessibilityValue(isLoading ? "Loading" : "")
|
||||
.accessibilityValue(isLoading ? String(localized: "Loading") : "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +297,6 @@ struct OrganicPrimaryButton: View {
|
||||
)
|
||||
}
|
||||
.disabled(isDisabled || isLoading)
|
||||
.accessibilityValue(isLoading ? "Loading" : "")
|
||||
.accessibilityValue(isLoading ? String(localized: "Loading") : "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ extension Color {
|
||||
case "safety", "electrical": return Color.appError
|
||||
case "hvac": return Color.appPrimary
|
||||
case "appliances": return Color.appAccent
|
||||
case "exterior", "lawn & garden": return Color(hex: "#34C759") ?? .green
|
||||
case "exterior", "lawn & garden": return Color(hex: "#34C759") ?? .green // i18n-ignore: category-name match keys for color lookup (non-UI)
|
||||
case "interior": return Color(hex: "#AF52DE") ?? .purple
|
||||
case "general", "seasonal": return Color(hex: "#FF9500") ?? .orange
|
||||
default: return Color.appPrimary
|
||||
|
||||
@@ -46,17 +46,21 @@ extension Date {
|
||||
/// Returns relative description (e.g., "Today", "Tomorrow", "In 3 days", "2 days ago")
|
||||
var relativeDescription: String {
|
||||
if isToday {
|
||||
return "Today"
|
||||
return String(localized: "Today")
|
||||
} else if isTomorrow {
|
||||
return "Tomorrow"
|
||||
return String(localized: "Tomorrow")
|
||||
} else {
|
||||
let days = daysFromToday
|
||||
if days > 0 {
|
||||
return "In \(days) day\(days == 1 ? "" : "s")"
|
||||
return days == 1
|
||||
? String(format: String(localized: "In %lld day"), days)
|
||||
: String(format: String(localized: "In %lld days"), days)
|
||||
} else if days < 0 {
|
||||
return "\(abs(days)) day\(abs(days) == 1 ? "" : "s") ago"
|
||||
return abs(days) == 1
|
||||
? String(format: String(localized: "%lld day ago"), abs(days))
|
||||
: String(format: String(localized: "%lld days ago"), abs(days))
|
||||
} else {
|
||||
return "Today"
|
||||
return String(localized: "Today")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,27 +96,27 @@ class DateFormatters {
|
||||
|
||||
private init() {}
|
||||
|
||||
/// "MMM d, yyyy" - e.g., "Jan 15, 2024"
|
||||
/// "MMM d, yyyy" - e.g., "Jan 15, 2024" (localized for current locale)
|
||||
lazy var mediumDate: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM d, yyyy"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.locale = Locale.current
|
||||
formatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy")
|
||||
return formatter
|
||||
}()
|
||||
|
||||
/// "MMMM d, yyyy" - e.g., "January 15, 2024"
|
||||
/// "MMMM d, yyyy" - e.g., "January 15, 2024" (localized for current locale)
|
||||
lazy var longDate: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMMM d, yyyy"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.locale = Locale.current
|
||||
formatter.setLocalizedDateFormatFromTemplate("MMMM d, yyyy")
|
||||
return formatter
|
||||
}()
|
||||
|
||||
/// "MM/dd/yyyy" - e.g., "01/15/2024"
|
||||
/// "MM/dd/yyyy" - e.g., "01/15/2024" (localized for current locale)
|
||||
lazy var shortDate: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MM/dd/yyyy"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.locale = Locale.current
|
||||
formatter.setLocalizedDateFormatFromTemplate("MM/dd/yyyy")
|
||||
return formatter
|
||||
}()
|
||||
|
||||
@@ -124,19 +128,20 @@ class DateFormatters {
|
||||
return formatter
|
||||
}()
|
||||
|
||||
/// "h:mm a" - e.g., "3:30 PM"
|
||||
/// "h:mm a" - e.g., "3:30 PM" (localized for current locale)
|
||||
lazy var time: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "h:mm a"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.locale = Locale.current
|
||||
formatter.setLocalizedDateFormatFromTemplate("h:mm a")
|
||||
return formatter
|
||||
}()
|
||||
|
||||
/// "MMM d, yyyy 'at' h:mm a" - e.g., "Jan 15, 2024 at 3:30 PM"
|
||||
/// "MMM d, yyyy 'at' h:mm a" - e.g., "Jan 15, 2024 at 3:30 PM" (localized for current locale)
|
||||
lazy var dateTime: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM d, yyyy 'at' h:mm a"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.locale = Locale.current
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .short
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ private enum CachedFormatters {
|
||||
static let currency: NumberFormatter = {
|
||||
let f = NumberFormatter()
|
||||
f.numberStyle = .currency
|
||||
f.currencyCode = "USD"
|
||||
f.locale = Locale.current
|
||||
return f
|
||||
}()
|
||||
|
||||
@@ -47,8 +47,10 @@ extension Double {
|
||||
/// Formats as currency (e.g., "$1,234.56")
|
||||
func toCurrency() -> String {
|
||||
let formatter = CachedFormatters.currency
|
||||
formatter.currencyCode = "USD"
|
||||
return formatter.string(from: NSNumber(value: self)) ?? "$\(self)"
|
||||
formatter.locale = Locale.current
|
||||
formatter.currencyCode = Locale.current.currency?.identifier ?? formatter.currencyCode
|
||||
return formatter.string(from: NSNumber(value: self))
|
||||
?? self.toDecimalString()
|
||||
}
|
||||
|
||||
/// Formats as currency with currency symbol (e.g., "$1,234.56")
|
||||
@@ -63,7 +65,7 @@ extension Double {
|
||||
let formatter = CachedFormatters.decimal
|
||||
formatter.minimumFractionDigits = fractionDigits
|
||||
formatter.maximumFractionDigits = fractionDigits
|
||||
return formatter.string(from: NSNumber(value: self)) ?? String(format: "%.\(fractionDigits)f", self)
|
||||
return formatter.string(from: NSNumber(value: self)) ?? String(format: "%.\(fractionDigits)f", self) // i18n-ignore: numeric format spec (non-UI)
|
||||
}
|
||||
|
||||
/// Formats as percentage (e.g., "45.5%")
|
||||
|
||||
@@ -5,7 +5,7 @@ import SwiftUI
|
||||
final class AnimationPreference: ObservableObject {
|
||||
static let shared = AnimationPreference()
|
||||
|
||||
@AppStorage("selectedTaskAnimation") private var storedValue: String = TaskAnimationType.implode.rawValue
|
||||
@AppStorage("selectedTaskAnimation") private var storedValue: String = TaskAnimationType.implode.rawValue // i18n-ignore: @AppStorage key (non-UI)
|
||||
|
||||
/// The currently selected animation type, persisted across launches.
|
||||
var selectedAnimation: TaskAnimationType {
|
||||
|
||||
@@ -4,6 +4,7 @@ import Foundation
|
||||
// Note: The comprehensive ErrorMessageParser is in Helpers/ErrorMessageParser.swift
|
||||
// This file provides additional helper messages for common error scenarios
|
||||
|
||||
// i18n-ignore-begin: dead code — unreferenced (verified); pending removal
|
||||
struct ErrorMessages {
|
||||
static let networkError = "Network connection error. Please check your internet connection."
|
||||
static let unknownError = "An unexpected error occurred. Please try again."
|
||||
@@ -53,3 +54,4 @@ struct ErrorChecks {
|
||||
message.lowercased().contains("forbidden")
|
||||
}
|
||||
}
|
||||
// i18n-ignore-end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
// i18n-ignore-begin: dead code — unreferenced (verified); pending removal
|
||||
// MARK: - Field Validation Helpers
|
||||
|
||||
struct ValidationHelpers {
|
||||
@@ -230,3 +231,4 @@ enum FormValidationResult {
|
||||
return [:]
|
||||
}
|
||||
}
|
||||
// i18n-ignore-end
|
||||
|
||||
@@ -96,13 +96,13 @@ struct FeatureComparisonView: View {
|
||||
|
||||
// Default features if no data loaded
|
||||
if subscriptionCache.featureBenefits.isEmpty {
|
||||
ComparisonRow(featureName: "Properties", freeText: "1 property", proText: "Unlimited")
|
||||
ComparisonRow(featureName: String(localized: "Properties"), freeText: String(localized: "1 property"), proText: String(localized: "Unlimited"))
|
||||
Divider()
|
||||
ComparisonRow(featureName: "Tasks", freeText: "10 tasks", proText: "Unlimited")
|
||||
ComparisonRow(featureName: String(localized: "Tasks"), freeText: String(localized: "10 tasks"), proText: String(localized: "Unlimited"))
|
||||
Divider()
|
||||
ComparisonRow(featureName: "Contractors", freeText: "Not available", proText: "Unlimited")
|
||||
ComparisonRow(featureName: String(localized: "Contractors"), freeText: String(localized: "Not available"), proText: String(localized: "Unlimited"))
|
||||
Divider()
|
||||
ComparisonRow(featureName: "Documents", freeText: "Not available", proText: "Unlimited")
|
||||
ComparisonRow(featureName: String(localized: "Documents"), freeText: String(localized: "Not available"), proText: String(localized: "Unlimited"))
|
||||
}
|
||||
}
|
||||
.background(Color.appBackgroundSecondary)
|
||||
@@ -217,7 +217,7 @@ struct SubscriptionButton: View {
|
||||
|
||||
var savingsText: String? {
|
||||
if isAnnual {
|
||||
return "Save 17%"
|
||||
return String(format: String(localized: "Save %lld%%"), 17)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ class StoreKitManager: ObservableObject {
|
||||
print("✅ StoreKit: Loaded \(products.count) products")
|
||||
} catch {
|
||||
print("❌ StoreKit: Failed to load products: \(error)")
|
||||
purchaseError = "Failed to load products: \(error.localizedDescription)"
|
||||
purchaseError = String(format: String(localized: "Failed to load products: %@"), error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ class StoreKitManager: ObservableObject {
|
||||
print("✅ StoreKit: Purchase successful for \(product.id)")
|
||||
} catch {
|
||||
print("⚠️ StoreKit: Backend verification failed for \(product.id), transaction NOT finished so it can be retried: \(error)")
|
||||
self.purchaseError = "Purchase successful but verification is pending. It will complete automatically."
|
||||
self.purchaseError = String(localized: "Purchase successful but verification is pending. It will complete automatically.")
|
||||
}
|
||||
|
||||
return transaction
|
||||
@@ -133,7 +133,7 @@ class StoreKitManager: ObservableObject {
|
||||
print("✅ StoreKit: Purchases restored")
|
||||
} catch {
|
||||
print("❌ StoreKit: Failed to restore purchases: \(error)")
|
||||
purchaseError = "Failed to restore purchases: \(error.localizedDescription)"
|
||||
purchaseError = String(format: String(localized: "Failed to restore purchases: %@"), error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,13 +312,13 @@ extension StoreKitManager {
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .verificationFailed:
|
||||
return "Transaction verification failed"
|
||||
return String(localized: "Transaction verification failed")
|
||||
case .noProducts:
|
||||
return "No products available"
|
||||
return String(localized: "No products available")
|
||||
case .purchaseFailed:
|
||||
return "Purchase failed"
|
||||
return String(localized: "Purchase failed")
|
||||
case .backendVerificationFailed(let message):
|
||||
return "Backend verification failed: \(message)"
|
||||
return String(format: String(localized: "Backend verification failed: %@"), message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ final class SubscriptionPurchaseHelper: ObservableObject {
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isProcessing = false
|
||||
errorMessage = "Purchase failed: \(error.localizedDescription)"
|
||||
errorMessage = String(format: String(localized: "Purchase failed: %@"), error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ final class SubscriptionPurchaseHelper: ObservableObject {
|
||||
if !storeKit.purchasedProductIDs.isEmpty {
|
||||
showSuccessAlert = true
|
||||
} else {
|
||||
errorMessage = "No purchases found to restore"
|
||||
errorMessage = String(localized: "No purchases found to restore")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,15 +17,15 @@ struct UpgradeFeatureView: View {
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
triggerData?.title ?? "Upgrade Required"
|
||||
triggerData?.title ?? String(localized: "Upgrade Required")
|
||||
}
|
||||
|
||||
private var message: String {
|
||||
triggerData?.message ?? "This feature is available with a Pro subscription."
|
||||
triggerData?.message ?? String(localized: "This feature is available with a Pro subscription.")
|
||||
}
|
||||
|
||||
private var buttonText: String {
|
||||
triggerData?.buttonText ?? "Upgrade to Pro"
|
||||
triggerData?.buttonText ?? String(localized: "Upgrade to Pro")
|
||||
}
|
||||
|
||||
/// Whether the user is already subscribed from a non-iOS platform
|
||||
@@ -124,10 +124,10 @@ struct UpgradeFeatureView: View {
|
||||
PromoContentView(content: promoContent)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
OrganicUpgradeFeatureRow(icon: "outline", text: "Unlimited properties")
|
||||
OrganicUpgradeFeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks")
|
||||
OrganicUpgradeFeatureRow(icon: "person.2.fill", text: "Contractor management")
|
||||
OrganicUpgradeFeatureRow(icon: "doc.fill", text: "Document & warranty storage")
|
||||
OrganicUpgradeFeatureRow(icon: "outline", text: String(localized: "Unlimited properties"))
|
||||
OrganicUpgradeFeatureRow(icon: "checkmark.circle.fill", text: String(localized: "Unlimited tasks"))
|
||||
OrganicUpgradeFeatureRow(icon: "person.2.fill", text: String(localized: "Contractor management"))
|
||||
OrganicUpgradeFeatureRow(icon: "doc.fill", text: String(localized: "Document & warranty storage"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,7 +266,7 @@ private struct OrganicUpgradeFeatureRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
Text(text)
|
||||
Text(LocalizedStringKey(text))
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
|
||||
@@ -213,12 +213,12 @@ struct UpgradePromptView: View {
|
||||
.padding(.top, OrganicSpacing.comfortable)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text(triggerData?.title ?? "Upgrade to Pro")
|
||||
Text(triggerData?.title ?? String(localized: "Upgrade to Pro"))
|
||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(triggerData?.message ?? "Unlock unlimited access to all features")
|
||||
Text(triggerData?.message ?? String(localized: "Unlock unlimited access to all features"))
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -232,10 +232,10 @@ struct UpgradePromptView: View {
|
||||
PromoContentView(content: promoContent)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
OrganicFeatureRow(icon: "outline", text: "Unlimited properties")
|
||||
OrganicFeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks")
|
||||
OrganicFeatureRow(icon: "person.2.fill", text: "Contractor management")
|
||||
OrganicFeatureRow(icon: "doc.fill", text: "Document & warranty storage")
|
||||
OrganicFeatureRow(icon: "outline", text: String(localized: "Unlimited properties"))
|
||||
OrganicFeatureRow(icon: "checkmark.circle.fill", text: String(localized: "Unlimited tasks"))
|
||||
OrganicFeatureRow(icon: "person.2.fill", text: String(localized: "Contractor management"))
|
||||
OrganicFeatureRow(icon: "doc.fill", text: String(localized: "Document & warranty storage"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -339,7 +339,7 @@ struct UpgradePromptView: View {
|
||||
.background(Color.appBackgroundSecondary.opacity(0.8))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.a11yButton("Close")
|
||||
.a11yButton(String(localized: "Close"))
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showFeatureComparison) {
|
||||
@@ -390,7 +390,7 @@ private struct OrganicFeatureRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
Text(text)
|
||||
Text(LocalizedStringKey(text))
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
@@ -414,7 +414,7 @@ private struct OrganicSubscriptionButton: View {
|
||||
|
||||
var savingsText: String? {
|
||||
if isAnnual {
|
||||
return "Save 17%"
|
||||
return String(format: String(localized: "Save %lld%%"), 17)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -492,7 +492,7 @@ struct SubscriptionProductButton: View {
|
||||
|
||||
var savingsText: String? {
|
||||
if isAnnual {
|
||||
return "Save 17%"
|
||||
return String(format: String(localized: "Save %lld%%"), 17)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ struct OverviewCard: View {
|
||||
StatView(
|
||||
icon: "outline",
|
||||
value: "\(summary.totalResidences)",
|
||||
label: "Properties",
|
||||
label: String(localized: "Properties"),
|
||||
color: Color.appPrimary
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ struct OverviewCard: View {
|
||||
StatView(
|
||||
icon: "list.bullet",
|
||||
value: "\(summary.totalTasks)",
|
||||
label: "Total Tasks",
|
||||
label: String(localized: "Total Tasks"),
|
||||
color: Color.appPrimary
|
||||
)
|
||||
|
||||
@@ -51,7 +51,7 @@ struct OverviewCard: View {
|
||||
StatView(
|
||||
icon: "clock.fill",
|
||||
value: "\(summary.totalPending)",
|
||||
label: "Pending",
|
||||
label: String(localized: "Pending"),
|
||||
color: Color.appAccent
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ struct StatView: View {
|
||||
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text(label)
|
||||
Text(LocalizedStringKey(label))
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -116,20 +116,20 @@ struct PropertyHeaderCard: View {
|
||||
PropertyFeaturePill(
|
||||
icon: "bed.double.fill",
|
||||
value: "\(bedrooms.intValue)",
|
||||
label: "Beds"
|
||||
label: String(localized: "Beds")
|
||||
)
|
||||
|
||||
PropertyFeaturePill(
|
||||
icon: "shower.fill",
|
||||
value: String(format: "%.1f", bathrooms.doubleValue),
|
||||
label: "Baths"
|
||||
label: String(localized: "Baths")
|
||||
)
|
||||
|
||||
if let sqft = residence.squareFootage {
|
||||
PropertyFeaturePill(
|
||||
icon: "square.dashed",
|
||||
value: formatNumber(sqft.intValue),
|
||||
label: "Sq Ft"
|
||||
label: String(localized: "Sq Ft")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ struct PropertyHeaderCard: View {
|
||||
PropertyFeaturePill(
|
||||
icon: "calendar",
|
||||
value: "\(yearBuilt.intValue)",
|
||||
label: "Built"
|
||||
label: String(localized: "Built")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -243,7 +243,7 @@ private struct PropertyFeaturePill: View {
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
}
|
||||
|
||||
Text(label)
|
||||
Text(LocalizedStringKey(label))
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
@@ -299,7 +299,7 @@ private struct PropertyHeaderBackground: View {
|
||||
users: [],
|
||||
name: "Sunset Villa",
|
||||
propertyTypeId: 1,
|
||||
propertyType: ResidenceType(id: 1, name: "House"),
|
||||
propertyType: ResidenceType(id: 1, name: "House", displayNameLocalized: ""),
|
||||
streetAddress: "742 Evergreen Terrace",
|
||||
apartmentUnit: "",
|
||||
city: "San Francisco",
|
||||
|
||||
@@ -104,28 +104,28 @@ struct ResidenceCard: View {
|
||||
// Total Tasks
|
||||
TaskStatItem(
|
||||
value: taskMetrics.totalCount,
|
||||
label: "Tasks",
|
||||
label: String(localized: "Tasks"),
|
||||
color: Color.appPrimary
|
||||
)
|
||||
|
||||
// Overdue
|
||||
TaskStatItem(
|
||||
value: taskMetrics.overdueCount,
|
||||
label: "Overdue",
|
||||
label: String(localized: "Overdue"),
|
||||
color: taskMetrics.overdueCount > 0 ? Color.appError : Color.appTextSecondary
|
||||
)
|
||||
|
||||
// Due Next 7 Days
|
||||
TaskStatItem(
|
||||
value: taskMetrics.upcoming7Days,
|
||||
label: "7 Days",
|
||||
label: String(localized: "7 Days"),
|
||||
color: Color.appAccent
|
||||
)
|
||||
|
||||
// Next 30 Days
|
||||
TaskStatItem(
|
||||
value: taskMetrics.upcoming30Days,
|
||||
label: "30 Days",
|
||||
label: String(localized: "30 Days"),
|
||||
color: Color.appPrimary.opacity(0.7)
|
||||
)
|
||||
}
|
||||
@@ -159,10 +159,10 @@ struct ResidenceCard: View {
|
||||
parts.append(residence.streetAddress)
|
||||
}
|
||||
if taskMetrics.totalCount > 0 {
|
||||
parts.append("\(taskMetrics.totalCount) tasks")
|
||||
parts.append(String(format: String(localized: "%lld tasks"), taskMetrics.totalCount))
|
||||
}
|
||||
if residence.isPrimary {
|
||||
parts.append("Primary property")
|
||||
parts.append(String(localized: "Primary property"))
|
||||
}
|
||||
return parts.joined(separator: ", ")
|
||||
}())
|
||||
@@ -233,7 +233,7 @@ private struct TaskStatItem: View {
|
||||
.font(.system(size: 15, weight: .bold, design: .rounded))
|
||||
.foregroundColor(color)
|
||||
|
||||
Text(label)
|
||||
Text(LocalizedStringKey(label))
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
@@ -289,7 +289,7 @@ private struct CardBackgroundView: View {
|
||||
users: [],
|
||||
name: "Sunset Villa",
|
||||
propertyTypeId: 1,
|
||||
propertyType: ResidenceType(id: 1, name: "House"),
|
||||
propertyType: ResidenceType(id: 1, name: "House", displayNameLocalized: ""),
|
||||
streetAddress: "742 Evergreen Terrace",
|
||||
apartmentUnit: "",
|
||||
city: "San Francisco",
|
||||
@@ -336,7 +336,7 @@ private struct CardBackgroundView: View {
|
||||
users: [],
|
||||
name: "Downtown Loft",
|
||||
propertyTypeId: 2,
|
||||
propertyType: ResidenceType(id: 2, name: "Apartment"),
|
||||
propertyType: ResidenceType(id: 2, name: "Apartment", displayNameLocalized: ""),
|
||||
streetAddress: "100 Market Street, Unit 502",
|
||||
apartmentUnit: "502",
|
||||
city: "San Francisco",
|
||||
|
||||
@@ -107,7 +107,7 @@ struct ShareCodeCard: View {
|
||||
} else {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
Text(shareCode != nil ? "Generate New Code" : "Generate Code")
|
||||
Text(shareCode != nil ? String(localized: "Generate New Code") : String(localized: "Generate Code"))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ struct SummaryCard: View {
|
||||
OrganicStatItem(
|
||||
icon: "outline",
|
||||
value: "\(summary.totalResidences)",
|
||||
label: "Properties",
|
||||
label: String(localized: "Properties"),
|
||||
accentColor: Color.appPrimary
|
||||
)
|
||||
|
||||
@@ -34,7 +34,7 @@ struct SummaryCard: View {
|
||||
OrganicStatItem(
|
||||
icon: "checklist",
|
||||
value: "\(summary.totalTasks)",
|
||||
label: "Total Tasks",
|
||||
label: String(localized: "Total Tasks"),
|
||||
accentColor: Color.appSecondary
|
||||
)
|
||||
}
|
||||
@@ -50,7 +50,7 @@ struct SummaryCard: View {
|
||||
TimelineStatPill(
|
||||
icon: "exclamationmark.circle.fill",
|
||||
value: "\(summary.totalOverdue)",
|
||||
label: "Overdue",
|
||||
label: String(localized: "Overdue"),
|
||||
color: summary.totalOverdue > 0 ? Color.appError : Color.appTextSecondary,
|
||||
isAlert: summary.totalOverdue > 0
|
||||
)
|
||||
@@ -58,14 +58,14 @@ struct SummaryCard: View {
|
||||
TimelineStatPill(
|
||||
icon: "clock.fill",
|
||||
value: "\(summary.tasksDueNextWeek)",
|
||||
label: "Next 7 Days",
|
||||
label: String(localized: "Next 7 Days"),
|
||||
color: Color.appAccent
|
||||
)
|
||||
|
||||
TimelineStatPill(
|
||||
icon: "arrow.forward.circle.fill",
|
||||
value: "\(summary.tasksDueNextMonth)",
|
||||
label: "Next 30 Days",
|
||||
label: String(localized: "Next 30 Days"),
|
||||
color: Color.appPrimary.opacity(0.7)
|
||||
)
|
||||
}
|
||||
@@ -116,7 +116,7 @@ private struct OrganicStatItem: View {
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
// Label
|
||||
Text(label)
|
||||
Text(LocalizedStringKey(label))
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
@@ -146,7 +146,7 @@ private struct TimelineStatPill: View {
|
||||
.foregroundColor(isAlert ? color : Color.appTextPrimary)
|
||||
}
|
||||
|
||||
Text(label)
|
||||
Text(LocalizedStringKey(label))
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ struct PhotoViewerSheet: View {
|
||||
VStack(spacing: 16) {
|
||||
AuthenticatedImage(mediaURL: selectedImage.mediaUrl)
|
||||
.frame(minHeight: 300)
|
||||
.accessibilityLabel("Completion photo\(selectedImage.caption.map { ", \($0)" } ?? "")")
|
||||
.accessibilityLabel(String(format: String(localized: "Completion photo%@"), selectedImage.caption.map { ", \($0)" } ?? ""))
|
||||
|
||||
if let caption = selectedImage.caption {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -82,7 +82,7 @@ struct PhotoViewerSheet: View {
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Photo\(image.caption.map { ", \($0)" } ?? "")")
|
||||
.accessibilityLabel(String(format: String(localized: "Photo%@"), image.caption.map { ", \($0)" } ?? ""))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
@@ -8,7 +8,7 @@ struct PriorityBadge: View {
|
||||
Image(systemName: priorityIcon)
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
|
||||
Text(priority.capitalized)
|
||||
Text(LocalizedStringKey(priority.capitalized))
|
||||
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
|
||||
@@ -28,8 +28,11 @@ struct StatusBadge: View {
|
||||
|
||||
private func formatStatus(_ status: String) -> String {
|
||||
switch status {
|
||||
case "in_progress": return "In Progress"
|
||||
case "cancelled": return "Cancelled"
|
||||
case "in_progress": return String(localized: "In Progress")
|
||||
case "cancelled": return String(localized: "Cancelled")
|
||||
case "completed": return String(localized: "Completed")
|
||||
case "pending": return String(localized: "Pending")
|
||||
case "archived": return String(localized: "Archived")
|
||||
default: return status.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ struct CancelTaskButton: View {
|
||||
if success {
|
||||
onCompletion()
|
||||
} else {
|
||||
onError("Failed to cancel task")
|
||||
onError(String(localized: "Failed to cancel task"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ struct UncancelTaskButton: View {
|
||||
if success {
|
||||
onCompletion()
|
||||
} else {
|
||||
onError("Failed to restore task")
|
||||
onError(String(localized: "Failed to restore task"))
|
||||
}
|
||||
}
|
||||
}) {
|
||||
@@ -105,7 +105,7 @@ struct MarkInProgressButton: View {
|
||||
if success {
|
||||
onCompletion()
|
||||
} else {
|
||||
onError("Failed to mark task in progress")
|
||||
onError(String(localized: "Failed to mark task in progress"))
|
||||
}
|
||||
}
|
||||
}) {
|
||||
@@ -179,7 +179,7 @@ struct ArchiveTaskButton: View {
|
||||
if success {
|
||||
onCompletion()
|
||||
} else {
|
||||
onError("Failed to archive task")
|
||||
onError(String(localized: "Failed to archive task"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,7 +203,7 @@ struct UnarchiveTaskButton: View {
|
||||
if success {
|
||||
onCompletion()
|
||||
} else {
|
||||
onError("Failed to unarchive task")
|
||||
onError(String(localized: "Failed to unarchive task"))
|
||||
}
|
||||
}
|
||||
}) {
|
||||
|
||||
@@ -94,7 +94,7 @@ struct TaskCard: View {
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Completions (\(task.completions.count))")
|
||||
.accessibilityHint("Double tap to \(isCompletionsExpanded ? "collapse" : "expand") completions")
|
||||
.accessibilityHint(isCompletionsExpanded ? String(localized: "Double tap to collapse completions") : String(localized: "Double tap to expand completions"))
|
||||
|
||||
if isCompletionsExpanded {
|
||||
ForEach(task.completions, id: \.id) { completion in
|
||||
@@ -321,12 +321,12 @@ private struct TaskCardBackground: View {
|
||||
title: "Clean Gutters",
|
||||
description: "Remove all debris from gutters",
|
||||
categoryId: 1,
|
||||
category: TaskCategory(id: 1, name: "maintenance", description: "", icon: "", color: "", displayOrder: 0),
|
||||
category: TaskCategory(id: 1, name: "maintenance", displayNameLocalized: "", description: "", icon: "", color: "", displayOrder: 0),
|
||||
priorityId: 2,
|
||||
priority: TaskPriority(id: 2, name: "medium", level: 2, color: "", displayOrder: 0),
|
||||
priority: TaskPriority(id: 2, name: "medium", displayNameLocalized: "", level: 2, color: "", displayOrder: 0),
|
||||
inProgress: false,
|
||||
frequencyId: 1,
|
||||
frequency: TaskFrequency(id: 1, name: "monthly", days: 30, displayOrder: 0),
|
||||
frequency: TaskFrequency(id: 1, name: "monthly", displayNameLocalized: "", days: 30, displayOrder: 0),
|
||||
customIntervalDays: nil,
|
||||
dueDate: "2024-12-15",
|
||||
nextDueDate: nil,
|
||||
|
||||
@@ -126,12 +126,12 @@ struct SwipeHintView: View {
|
||||
title: "Clean Gutters",
|
||||
description: "Remove all debris",
|
||||
categoryId: 1,
|
||||
category: TaskCategory(id: 1, name: "maintenance", description: "", icon: "", color: "", displayOrder: 0),
|
||||
category: TaskCategory(id: 1, name: "maintenance", displayNameLocalized: "", description: "", icon: "", color: "", displayOrder: 0),
|
||||
priorityId: 2,
|
||||
priority: TaskPriority(id: 2, name: "medium", level: 2, color: "", displayOrder: 0),
|
||||
priority: TaskPriority(id: 2, name: "medium", displayNameLocalized: "", level: 2, color: "", displayOrder: 0),
|
||||
inProgress: false,
|
||||
frequencyId: 1,
|
||||
frequency: TaskFrequency(id: 1, name: "monthly", days: 30, displayOrder: 0),
|
||||
frequency: TaskFrequency(id: 1, name: "monthly", displayNameLocalized: "", days: 30, displayOrder: 0),
|
||||
customIntervalDays: nil,
|
||||
dueDate: "2024-12-15",
|
||||
nextDueDate: nil,
|
||||
@@ -168,12 +168,12 @@ struct SwipeHintView: View {
|
||||
title: "Fix Leaky Faucet",
|
||||
description: "Kitchen sink fixed",
|
||||
categoryId: 2,
|
||||
category: TaskCategory(id: 2, name: "plumbing", description: "", icon: "", color: "", displayOrder: 0),
|
||||
category: TaskCategory(id: 2, name: "plumbing", displayNameLocalized: "", description: "", icon: "", color: "", displayOrder: 0),
|
||||
priorityId: 3,
|
||||
priority: TaskPriority(id: 3, name: "high", level: 3, color: "", displayOrder: 0),
|
||||
priority: TaskPriority(id: 3, name: "high", displayNameLocalized: "", level: 3, color: "", displayOrder: 0),
|
||||
inProgress: false,
|
||||
frequencyId: 6,
|
||||
frequency: TaskFrequency(id: 6, name: "once", days: nil, displayOrder: 0),
|
||||
frequency: TaskFrequency(id: 6, name: "once", displayNameLocalized: "", days: nil, displayOrder: 0),
|
||||
customIntervalDays: nil,
|
||||
dueDate: "2024-11-01",
|
||||
nextDueDate: nil,
|
||||
|
||||
@@ -90,7 +90,7 @@ struct AllTasksView: View {
|
||||
}
|
||||
} message: {
|
||||
if let task = selectedTaskForArchive {
|
||||
Text(L10n.Tasks.archiveConfirm.replacingOccurrences(of: "this task", with: "\"\(task.title)\""))
|
||||
Text(L10n.Tasks.archiveConfirm.replacingOccurrences(of: "this task", with: "\"\(task.title)\"")) // i18n-ignore: "this task" is a match token substituted out of an already-localized string (non-UI)
|
||||
}
|
||||
}
|
||||
.alert(L10n.Tasks.cancelTask, isPresented: $showCancelConfirmation) {
|
||||
@@ -125,7 +125,7 @@ struct AllTasksView: View {
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { notification in
|
||||
if let userInfo = notification.userInfo,
|
||||
let taskId = userInfo["taskId"] as? Int {
|
||||
let taskId = userInfo["taskId"] as? Int { // i18n-ignore: NotificationCenter userInfo key (non-UI)
|
||||
pendingTaskId = Int32(taskId)
|
||||
if let response = tasksResponse {
|
||||
navigateToTaskInKanban(taskId: Int32(taskId), response: response)
|
||||
@@ -134,7 +134,7 @@ struct AllTasksView: View {
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { notification in
|
||||
if let userInfo = notification.userInfo,
|
||||
let taskId = userInfo["taskId"] as? Int {
|
||||
let taskId = userInfo["taskId"] as? Int { // i18n-ignore: NotificationCenter userInfo key (non-UI)
|
||||
pendingTaskId = Int32(taskId)
|
||||
if let response = tasksResponse {
|
||||
navigateToTaskInKanban(taskId: Int32(taskId), response: response)
|
||||
|
||||
@@ -334,7 +334,7 @@ struct CompleteTaskView: View {
|
||||
|
||||
private func handleComplete() {
|
||||
guard TokenStorage.shared.getToken() != nil else {
|
||||
errorMessage = "Not authenticated"
|
||||
errorMessage = String(localized: "Not authenticated")
|
||||
showError = true
|
||||
return
|
||||
}
|
||||
@@ -376,11 +376,11 @@ struct CompleteTaskView: View {
|
||||
guard let data = ImageDownsampler.downsample(uiImage: uiImage, profile: .completion) else {
|
||||
return nil
|
||||
}
|
||||
return (data, "completion_\(UUID().uuidString).jpg")
|
||||
return (data, "completion_\(UUID().uuidString).jpg") // i18n-ignore: generated upload filename (non-UI)
|
||||
}
|
||||
guard payloads.count == selectedImages.count else {
|
||||
await MainActor.run {
|
||||
errorMessage = "One or more photos couldn't be processed."
|
||||
errorMessage = String(localized: "One or more photos couldn't be processed.")
|
||||
showError = true
|
||||
isSubmitting = false
|
||||
}
|
||||
@@ -391,7 +391,7 @@ struct CompleteTaskView: View {
|
||||
// them in parallel under a server-enforced concurrency cap of 10.
|
||||
guard let uploader = PresignedUploader() else {
|
||||
await MainActor.run {
|
||||
errorMessage = "Not authenticated"
|
||||
errorMessage = String(localized: "Not authenticated")
|
||||
showError = true
|
||||
isSubmitting = false
|
||||
}
|
||||
@@ -537,7 +537,7 @@ struct ContractorPickerView: View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "wrench.and.screwdriver")
|
||||
.font(.caption2)
|
||||
Text(firstSpecialty.name)
|
||||
Text(firstSpecialty.displayName)
|
||||
.font(.caption2)
|
||||
}
|
||||
.foregroundColor(Color.appTextSecondary.opacity(0.7))
|
||||
|
||||
@@ -206,7 +206,7 @@ struct TaskFormView: View {
|
||||
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?)
|
||||
Text(category.displayName).tag(category as TaskCategory?)
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Task.categoryPicker)
|
||||
@@ -428,36 +428,36 @@ struct TaskFormView: View {
|
||||
var isValid = true
|
||||
|
||||
if title.isEmpty {
|
||||
titleError = "Title is required"
|
||||
titleError = String(localized: "Title is required")
|
||||
isValid = false
|
||||
} else {
|
||||
titleError = ""
|
||||
}
|
||||
|
||||
if needsResidenceSelection && selectedResidence == nil {
|
||||
residenceError = "Property is required"
|
||||
residenceError = String(localized: "Property is required")
|
||||
isValid = false
|
||||
} else {
|
||||
residenceError = ""
|
||||
}
|
||||
|
||||
if selectedCategory == nil {
|
||||
viewModel.errorMessage = "Please select a category"
|
||||
viewModel.errorMessage = String(localized: "Please select a category")
|
||||
isValid = false
|
||||
}
|
||||
|
||||
if selectedFrequency == nil {
|
||||
viewModel.errorMessage = "Please select a frequency"
|
||||
viewModel.errorMessage = String(localized: "Please select a frequency")
|
||||
isValid = false
|
||||
}
|
||||
|
||||
if selectedPriority == nil {
|
||||
viewModel.errorMessage = "Please select a priority"
|
||||
viewModel.errorMessage = String(localized: "Please select a priority")
|
||||
isValid = false
|
||||
}
|
||||
|
||||
if !intervalDays.isEmpty, Int32(intervalDays) == nil {
|
||||
viewModel.errorMessage = "Custom interval must be a valid number"
|
||||
viewModel.errorMessage = String(localized: "Custom interval must be a valid number")
|
||||
isValid = false
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ struct TaskTemplatesBrowserView: View {
|
||||
}
|
||||
.standardFormStyle()
|
||||
.background(WarmGradientBackground())
|
||||
.searchable(text: $searchText, prompt: "Search templates...")
|
||||
.searchable(text: $searchText, prompt: String(localized: "Search templates..."))
|
||||
.accessibilityHint("Search task templates by name")
|
||||
.navigationTitle("Task Templates")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -77,7 +77,7 @@ struct TaskTemplatesBrowserView: View {
|
||||
taskRow(template)
|
||||
}
|
||||
} header: {
|
||||
Text("\(filteredTemplates.count) \(filteredTemplates.count == 1 ? "result" : "results")")
|
||||
Text("\(filteredTemplates.count) results")
|
||||
}
|
||||
.listRowBackground(Color.appBackgroundSecondary)
|
||||
}
|
||||
|
||||
@@ -376,7 +376,7 @@ class TaskViewModel: ObservableObject {
|
||||
self.tasksError = error.message
|
||||
self.isLoadingTasks = false
|
||||
} else {
|
||||
self.tasksError = "Failed to load tasks"
|
||||
self.tasksError = String(localized: "Failed to load tasks")
|
||||
self.isLoadingTasks = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ struct VerifyEmailView: View {
|
||||
} else {
|
||||
Image(systemName: "checkmark.shield.fill")
|
||||
}
|
||||
Text(viewModel.isLoading ? "Verifying..." : L10n.Auth.verifyEmailButton)
|
||||
Text(viewModel.isLoading ? String(localized: "Verifying...") : L10n.Auth.verifyEmailButton)
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class VerifyEmailViewModel: ObservableObject {
|
||||
/// Called on screen appear and on "Resend code".
|
||||
func sendCode(silent: Bool = false) {
|
||||
guard let email = dataManager.currentUser?.email, !email.isEmpty else {
|
||||
errorMessage = "We couldn't determine your email. Please sign in again."
|
||||
errorMessage = String(localized: "We couldn't determine your email. Please sign in again.")
|
||||
return
|
||||
}
|
||||
if !silent { isLoading = true }
|
||||
@@ -58,7 +58,7 @@ class VerifyEmailViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
guard let token = tokenStorage.getToken() else {
|
||||
errorMessage = "Not authenticated"
|
||||
errorMessage = String(localized: "Not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -78,14 +78,14 @@ class VerifyEmailViewModel: ObservableObject {
|
||||
self.isLoading = false
|
||||
print("VerifyEmailViewModel: isVerified is now \(self.isVerified)")
|
||||
} else {
|
||||
self.errorMessage = "Verification failed"
|
||||
self.errorMessage = String(localized: "Verification failed")
|
||||
self.isLoading = false
|
||||
}
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
} else {
|
||||
self.errorMessage = "Failed to verify email"
|
||||
self.errorMessage = String(localized: "Failed to verify email")
|
||||
self.isLoading = false
|
||||
}
|
||||
} catch {
|
||||
|
||||
+13
-13
@@ -145,7 +145,7 @@ struct iOSApp: App {
|
||||
contractorSharingManager.resetImportState()
|
||||
}
|
||||
} message: {
|
||||
Text("\(contractorSharingManager.importedContractorName ?? "Contractor") has been added to your contacts.")
|
||||
Text(String(format: String(localized: "%@ has been added to your contacts."), contractorSharingManager.importedContractorName ?? String(localized: "Contractor")))
|
||||
}
|
||||
// Contractor import error dialog
|
||||
.alert("Import Failed", isPresented: .init(
|
||||
@@ -156,7 +156,7 @@ struct iOSApp: App {
|
||||
contractorSharingManager.resetImportState()
|
||||
}
|
||||
} message: {
|
||||
Text(contractorSharingManager.importError ?? "An error occurred while importing the contractor.")
|
||||
Text(contractorSharingManager.importError ?? String(localized: "An error occurred while importing the contractor."))
|
||||
}
|
||||
// Residence import success dialog
|
||||
.alert("Joined Residence", isPresented: $residenceSharingManager.importSuccess) {
|
||||
@@ -164,7 +164,7 @@ struct iOSApp: App {
|
||||
residenceSharingManager.resetState()
|
||||
}
|
||||
} message: {
|
||||
Text("You now have access to \(residenceSharingManager.importedResidenceName ?? "the residence").")
|
||||
Text("You now have access to \(residenceSharingManager.importedResidenceName ?? String(localized: "the residence")).")
|
||||
}
|
||||
// Residence import error dialog
|
||||
.alert("Join Failed", isPresented: .init(
|
||||
@@ -175,7 +175,7 @@ struct iOSApp: App {
|
||||
residenceSharingManager.resetState()
|
||||
}
|
||||
} message: {
|
||||
Text(residenceSharingManager.errorMessage ?? "An error occurred while joining the residence.")
|
||||
Text(residenceSharingManager.errorMessage ?? String(localized: "An error occurred while joining the residence."))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,18 +185,18 @@ struct iOSApp: App {
|
||||
private var importConfirmationTitle: String {
|
||||
switch pendingImportType {
|
||||
case .contractor:
|
||||
return "Import Contractor"
|
||||
return String(localized: "Import Contractor")
|
||||
case .residence:
|
||||
return "Join Residence"
|
||||
return String(localized: "Join Residence")
|
||||
}
|
||||
}
|
||||
|
||||
private var importConfirmationMessage: String {
|
||||
switch pendingImportType {
|
||||
case .contractor:
|
||||
return "Would you like to import this contractor to your contacts?"
|
||||
return String(localized: "Would you like to import this contractor to your contacts?")
|
||||
case .residence:
|
||||
return "Would you like to join this shared residence?"
|
||||
return String(localized: "Would you like to join this shared residence?")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@ struct iOSApp: App {
|
||||
|
||||
// Check if user is authenticated
|
||||
guard TokenStorage.shared.getToken() != nil else {
|
||||
contractorSharingManager.importError = "You must be logged in to import"
|
||||
contractorSharingManager.importError = String(localized: "You must be logged in to import")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ struct iOSApp: App {
|
||||
if accessing {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
contractorSharingManager.importError = "The file appears to be corrupted and could not be read."
|
||||
contractorSharingManager.importError = String(localized: "The file appears to be corrupted and could not be read.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -298,17 +298,17 @@ struct iOSApp: App {
|
||||
case "task":
|
||||
if let idString = components.queryItems?.first(where: { $0.name == "id" })?.value,
|
||||
let id = Int(idString) {
|
||||
NotificationCenter.default.post(name: .navigateToTask, object: nil, userInfo: ["taskId": id])
|
||||
NotificationCenter.default.post(name: .navigateToTask, object: nil, userInfo: ["taskId": id]) // i18n-ignore: NotificationCenter userInfo key (non-UI)
|
||||
}
|
||||
case "residence":
|
||||
if let idString = components.queryItems?.first(where: { $0.name == "id" })?.value,
|
||||
let id = Int(idString) {
|
||||
NotificationCenter.default.post(name: .navigateToResidence, object: nil, userInfo: ["residenceId": id])
|
||||
NotificationCenter.default.post(name: .navigateToResidence, object: nil, userInfo: ["residenceId": id]) // i18n-ignore: NotificationCenter userInfo key (non-UI)
|
||||
}
|
||||
case "document":
|
||||
if let idString = components.queryItems?.first(where: { $0.name == "id" })?.value,
|
||||
let id = Int(idString) {
|
||||
NotificationCenter.default.post(name: .navigateToDocument, object: nil, userInfo: ["documentId": id])
|
||||
NotificationCenter.default.post(name: .navigateToDocument, object: nil, userInfo: ["documentId": id]) // i18n-ignore: NotificationCenter userInfo key (non-UI)
|
||||
}
|
||||
default:
|
||||
#if DEBUG
|
||||
|
||||
Reference in New Issue
Block a user