AppLocalization.swift 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. //
  2. // AppLocalization.swift
  3. // App for Indeed
  4. //
  5. // English-first localization using Localizable.strings (same pattern as the LinkedIn app).
  6. // Add more locales by creating `<locale>.lproj/Localizable.strings` and extending AppLanguage.
  7. //
  8. import Foundation
  9. enum AppLanguage: CaseIterable {
  10. case english
  11. case german
  12. case swedish
  13. case frenchCanada
  14. case french
  15. case arabic
  16. case chineseSimplified
  17. case chineseTraditional
  18. case korean
  19. case portugueseBrazil
  20. case russian
  21. case spanish
  22. case spanishMexico
  23. case vietnamese
  24. var localeIdentifier: String {
  25. switch self {
  26. case .english:
  27. return "en"
  28. case .german:
  29. return "de"
  30. case .swedish:
  31. return "sv"
  32. case .frenchCanada:
  33. return "fr-CA"
  34. case .french:
  35. return "fr"
  36. case .arabic:
  37. return "ar"
  38. case .chineseSimplified:
  39. return "zh-Hans"
  40. case .chineseTraditional:
  41. return "zh-Hant"
  42. case .korean:
  43. return "ko"
  44. case .portugueseBrazil:
  45. return "pt-BR"
  46. case .russian:
  47. return "ru"
  48. case .spanish:
  49. return "es"
  50. case .spanishMexico:
  51. return "es-MX"
  52. case .vietnamese:
  53. return "vi"
  54. }
  55. }
  56. static var systemLanguage: AppLanguage {
  57. let preferred = Locale.preferredLanguages.first ?? "en"
  58. let lower = preferred.lowercased()
  59. if lower.hasPrefix("zh-hant")
  60. || lower.hasPrefix("zh-tw")
  61. || lower.hasPrefix("zh-hk")
  62. || lower.hasPrefix("zh-mo")
  63. || lower.contains("-hant") {
  64. return .chineseTraditional
  65. }
  66. if lower.hasPrefix("zh") {
  67. return .chineseSimplified
  68. }
  69. if lower.hasPrefix("de") {
  70. return .german
  71. }
  72. if lower.hasPrefix("sv") {
  73. return .swedish
  74. }
  75. if lower.hasPrefix("en") {
  76. return .english
  77. }
  78. if lower.hasPrefix("fr-ca") {
  79. return .frenchCanada
  80. }
  81. if lower.hasPrefix("fr") {
  82. return .french
  83. }
  84. if lower.hasPrefix("ar") {
  85. return .arabic
  86. }
  87. if lower.hasPrefix("ko") {
  88. return .korean
  89. }
  90. if lower.hasPrefix("pt") {
  91. return .portugueseBrazil
  92. }
  93. if lower.hasPrefix("ru") {
  94. return .russian
  95. }
  96. if lower.hasPrefix("es-mx") {
  97. return .spanishMexico
  98. }
  99. if lower.hasPrefix("es") {
  100. return .spanish
  101. }
  102. if lower.hasPrefix("vi") {
  103. return .vietnamese
  104. }
  105. for language in AppLanguage.allCases where preferred.hasPrefix(language.localeIdentifier) {
  106. return language
  107. }
  108. return .english
  109. }
  110. /// Settings language picker labels in each language's native form (autonym).
  111. var localizedDisplayName: String {
  112. let locale = Locale(identifier: localeIdentifier)
  113. guard let name = locale.localizedString(forIdentifier: localeIdentifier) else {
  114. return localeIdentifier
  115. }
  116. return name.capitalized(with: locale)
  117. }
  118. }
  119. func appLocalized(_ key: String, language: AppLanguage) -> String {
  120. guard let path = Bundle.main.path(forResource: language.localeIdentifier, ofType: "lproj"),
  121. let bundle = Bundle(path: path) else {
  122. return key
  123. }
  124. let value = bundle.localizedString(forKey: key, value: key, table: nil)
  125. return value.isEmpty ? key : value
  126. }
  127. func currentAppLanguage() -> AppLanguage {
  128. AppLanguageManager.resolvedLanguage
  129. }
  130. /// Resolves copy for the user’s currently selected language.
  131. func L(_ key: String) -> String {
  132. appLocalized(key, language: currentAppLanguage())
  133. }
  134. /// Localized CV template title. Built-in templates use English keys in `Localizable.strings`;
  135. /// AI-generated titles fall back to per-word translation when no exact key exists.
  136. func localizedTemplateName(_ nameKey: String) -> String {
  137. let language = currentAppLanguage()
  138. let trimmed = nameKey.trimmingCharacters(in: .whitespacesAndNewlines)
  139. guard !trimmed.isEmpty else { return nameKey }
  140. let exact = appLocalized(trimmed, language: language)
  141. if !exact.isEmpty, exact != trimmed { return exact }
  142. guard language != .english else { return trimmed }
  143. let expandedPhrase = tokenizeTemplateName(trimmed).joined(separator: " ")
  144. if expandedPhrase != trimmed {
  145. let phrase = appLocalized(expandedPhrase, language: language)
  146. if !phrase.isEmpty, phrase != expandedPhrase { return phrase }
  147. }
  148. let tokenized = translateTemplateNameByTokens(trimmed, language: language)
  149. return tokenized.isEmpty ? trimmed : tokenized
  150. }
  151. /// Splits space-separated, camelCase, and punctuation-joined AI template titles into words.
  152. private func tokenizeTemplateName(_ name: String) -> [String] {
  153. var s = name.trimmingCharacters(in: .whitespacesAndNewlines)
  154. guard !s.isEmpty else { return [] }
  155. for separator in ["&", "·", "-", "_", "/"] {
  156. s = s.replacingOccurrences(of: separator, with: " ")
  157. }
  158. var expanded = ""
  159. let chars = Array(s)
  160. for index in chars.indices {
  161. let char = chars[index]
  162. if index > 0 {
  163. let prev = chars[index - 1]
  164. if char.isUppercase, prev.isLowercase {
  165. expanded.append(" ")
  166. } else if char.isUppercase, prev.isUppercase, index + 1 < chars.count, chars[index + 1].isLowercase {
  167. expanded.append(" ")
  168. }
  169. }
  170. expanded.append(char)
  171. }
  172. return expanded
  173. .split(whereSeparator: \.isWhitespace)
  174. .map(String.init)
  175. .filter { !$0.isEmpty }
  176. }
  177. private func translateTemplateNameByTokens(_ name: String, language: AppLanguage) -> String {
  178. let tokens = tokenizeTemplateName(name)
  179. guard !tokens.isEmpty else { return name }
  180. let translated = tokens.map { translateTemplateNameToken($0, language: language) }
  181. switch language {
  182. case .chineseSimplified, .chineseTraditional:
  183. return translated.joined()
  184. case .arabic, .english, .french, .frenchCanada, .german, .korean, .portugueseBrazil, .russian, .spanish, .spanishMexico, .swedish, .vietnamese:
  185. return translated.joined(separator: " ")
  186. }
  187. }
  188. private func translateTemplateNameToken(_ token: String, language: AppLanguage) -> String {
  189. let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
  190. guard !trimmed.isEmpty else { return token }
  191. let candidates = [
  192. trimmed,
  193. trimmed.capitalized,
  194. trimmed.prefix(1).uppercased() + trimmed.dropFirst().lowercased()
  195. ]
  196. for candidate in candidates where !candidate.isEmpty {
  197. let localized = appLocalized(candidate, language: language)
  198. if !localized.isEmpty, localized != candidate { return localized }
  199. if let lex = TemplateNameTokenLexicon.lookup(candidate, language: language) {
  200. return lex
  201. }
  202. }
  203. return trimmed.prefix(1).uppercased() + trimmed.dropFirst().lowercased()
  204. }
  205. @MainActor
  206. final class AppLanguageManager {
  207. static let shared = AppLanguageManager()
  208. static let didChangeNotification = Notification.Name("AppLanguageManager.didChange")
  209. private static let legacyPreferredLanguageKey = "com.appforindeed.preferredLanguage"
  210. /// Settings override for this run only; cleared on launch so UI follows macOS each time.
  211. nonisolated(unsafe) private static var sessionOverride: AppLanguage?
  212. nonisolated static var resolvedLanguage: AppLanguage {
  213. sessionOverride ?? AppLanguage.systemLanguage
  214. }
  215. var current: AppLanguage {
  216. Self.resolvedLanguage
  217. }
  218. func applyStoredPreferenceOnLaunch() {
  219. // UI copy uses `appLocalized`; do not drive layout from `AppleLanguages` (Arabic forces RTL and breaks CV templates).
  220. Self.sessionOverride = nil
  221. UserDefaults.standard.removeObject(forKey: "AppleLanguages")
  222. UserDefaults.standard.removeObject(forKey: Self.legacyPreferredLanguageKey)
  223. }
  224. func setLanguage(_ language: AppLanguage, notify: Bool = true) {
  225. Self.sessionOverride = language
  226. UserDefaults.standard.removeObject(forKey: "AppleLanguages")
  227. if notify {
  228. NotificationCenter.default.post(name: Self.didChangeNotification, object: self)
  229. }
  230. }
  231. func setLanguage(code: String, notify: Bool = true) {
  232. guard let language = AppLanguage.allCases.first(where: { $0.localeIdentifier == code }) else { return }
  233. setLanguage(language, notify: notify)
  234. }
  235. }