// // AppLocalization.swift // App for Indeed // // English-first localization using Localizable.strings (same pattern as the LinkedIn app). // Add more locales by creating `.lproj/Localizable.strings` and extending AppLanguage. // import Foundation enum AppLanguage: CaseIterable { case english case german case dutch case finnish case greek case swedish case frenchCanada case french case arabic case chineseSimplified case chineseTraditional case korean case portugueseBrazil case russian case spanish case spanishMexico case vietnamese var localeIdentifier: String { switch self { case .english: return "en" case .german: return "de" case .dutch: return "nl" case .finnish: return "fi" case .greek: return "el" case .swedish: return "sv" case .frenchCanada: return "fr-CA" case .french: return "fr" case .arabic: return "ar" case .chineseSimplified: return "zh-Hans" case .chineseTraditional: return "zh-Hant" case .korean: return "ko" case .portugueseBrazil: return "pt-BR" case .russian: return "ru" case .spanish: return "es" case .spanishMexico: return "es-MX" case .vietnamese: return "vi" } } static var systemLanguage: AppLanguage { let preferred = Locale.preferredLanguages.first ?? "en" let lower = preferred.lowercased() if lower.hasPrefix("zh-hant") || lower.hasPrefix("zh-tw") || lower.hasPrefix("zh-hk") || lower.hasPrefix("zh-mo") || lower.contains("-hant") { return .chineseTraditional } if lower.hasPrefix("zh") { return .chineseSimplified } if lower.hasPrefix("de") { return .german } if lower.hasPrefix("nl") { return .dutch } if lower.hasPrefix("fi") { return .finnish } if lower.hasPrefix("el") { return .greek } if lower.hasPrefix("sv") { return .swedish } if lower.hasPrefix("en") { return .english } if lower.hasPrefix("fr-ca") { return .frenchCanada } if lower.hasPrefix("fr") { return .french } if lower.hasPrefix("ar") { return .arabic } if lower.hasPrefix("ko") { return .korean } if lower.hasPrefix("pt") { return .portugueseBrazil } if lower.hasPrefix("ru") { return .russian } if lower.hasPrefix("es-mx") { return .spanishMexico } if lower.hasPrefix("es") { return .spanish } if lower.hasPrefix("vi") { return .vietnamese } for language in AppLanguage.allCases where preferred.hasPrefix(language.localeIdentifier) { return language } return .english } /// Settings language picker labels in each language's native form (autonym). var localizedDisplayName: String { let locale = Locale(identifier: localeIdentifier) guard let name = locale.localizedString(forIdentifier: localeIdentifier) else { return localeIdentifier } return name.capitalized(with: locale) } } func appLocalized(_ key: String, language: AppLanguage) -> String { guard let path = Bundle.main.path(forResource: language.localeIdentifier, ofType: "lproj"), let bundle = Bundle(path: path) else { return key } let value = bundle.localizedString(forKey: key, value: key, table: nil) return value.isEmpty ? key : value } func currentAppLanguage() -> AppLanguage { AppLanguageManager.resolvedLanguage } /// Resolves copy for the user’s currently selected language. func L(_ key: String) -> String { appLocalized(key, language: currentAppLanguage()) } /// Localized CV template title. Built-in templates use English keys in `Localizable.strings`; /// AI-generated titles fall back to per-word translation when no exact key exists. func localizedTemplateName(_ nameKey: String) -> String { let language = currentAppLanguage() let trimmed = nameKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nameKey } let exact = appLocalized(trimmed, language: language) if !exact.isEmpty, exact != trimmed { return exact } guard language != .english else { return trimmed } let expandedPhrase = tokenizeTemplateName(trimmed).joined(separator: " ") if expandedPhrase != trimmed { let phrase = appLocalized(expandedPhrase, language: language) if !phrase.isEmpty, phrase != expandedPhrase { return phrase } } let tokenized = translateTemplateNameByTokens(trimmed, language: language) return tokenized.isEmpty ? trimmed : tokenized } /// Splits space-separated, camelCase, and punctuation-joined AI template titles into words. private func tokenizeTemplateName(_ name: String) -> [String] { var s = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !s.isEmpty else { return [] } for separator in ["&", "·", "-", "_", "/"] { s = s.replacingOccurrences(of: separator, with: " ") } var expanded = "" let chars = Array(s) for index in chars.indices { let char = chars[index] if index > 0 { let prev = chars[index - 1] if char.isUppercase, prev.isLowercase { expanded.append(" ") } else if char.isUppercase, prev.isUppercase, index + 1 < chars.count, chars[index + 1].isLowercase { expanded.append(" ") } } expanded.append(char) } return expanded .split(whereSeparator: \.isWhitespace) .map(String.init) .filter { !$0.isEmpty } } private func translateTemplateNameByTokens(_ name: String, language: AppLanguage) -> String { let tokens = tokenizeTemplateName(name) guard !tokens.isEmpty else { return name } let translated = tokens.map { translateTemplateNameToken($0, language: language) } switch language { case .chineseSimplified, .chineseTraditional: return translated.joined() case .arabic, .dutch, .english, .finnish, .french, .frenchCanada, .german, .greek, .korean, .portugueseBrazil, .russian, .spanish, .spanishMexico, .swedish, .vietnamese: return translated.joined(separator: " ") } } private func translateTemplateNameToken(_ token: String, language: AppLanguage) -> String { let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return token } let candidates = [ trimmed, trimmed.capitalized, trimmed.prefix(1).uppercased() + trimmed.dropFirst().lowercased() ] for candidate in candidates where !candidate.isEmpty { let localized = appLocalized(candidate, language: language) if !localized.isEmpty, localized != candidate { return localized } if let lex = TemplateNameTokenLexicon.lookup(candidate, language: language) { return lex } } return trimmed.prefix(1).uppercased() + trimmed.dropFirst().lowercased() } @MainActor final class AppLanguageManager { static let shared = AppLanguageManager() static let didChangeNotification = Notification.Name("AppLanguageManager.didChange") private static let legacyPreferredLanguageKey = "com.appforindeed.preferredLanguage" /// Settings override for this run only; cleared on launch so UI follows macOS each time. nonisolated(unsafe) private static var sessionOverride: AppLanguage? nonisolated static var resolvedLanguage: AppLanguage { sessionOverride ?? AppLanguage.systemLanguage } var current: AppLanguage { Self.resolvedLanguage } func applyStoredPreferenceOnLaunch() { // UI copy uses `appLocalized`; do not drive layout from `AppleLanguages` (Arabic forces RTL and breaks CV templates). Self.sessionOverride = nil UserDefaults.standard.removeObject(forKey: "AppleLanguages") UserDefaults.standard.removeObject(forKey: Self.legacyPreferredLanguageKey) } func setLanguage(_ language: AppLanguage, notify: Bool = true) { Self.sessionOverride = language UserDefaults.standard.removeObject(forKey: "AppleLanguages") if notify { NotificationCenter.default.post(name: Self.didChangeNotification, object: self) } } func setLanguage(code: String, notify: Bool = true) { guard let language = AppLanguage.allCases.first(where: { $0.localeIdentifier == code }) else { return } setLanguage(language, notify: notify) } }