// // 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 swedish case frenchCanada case french case arabic case chineseSimplified case chineseTraditional var localeIdentifier: String { switch self { case .english: return "en" case .german: return "de" 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" } } 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("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 } for language in AppLanguage.allCases where preferred.hasPrefix(language.localeIdentifier) { return language } return .english } /// Settings language picker labels — fixed English names, not localized. var localizedDisplayName: String { switch self { case .english: return "English" case .german: return "German" case .swedish: return "Swedish" case .frenchCanada: return "French (Canada)" case .french: return "French" case .arabic: return "Arabic" case .chineseSimplified: return "Chinese (Simplified)" case .chineseTraditional: return "Chinese (Traditional)" } } } 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, .english, .french, .frenchCanada, .german, .swedish: 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) } }