설명 없음

AppLocalization.swift 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  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. var localeIdentifier: String {
  19. switch self {
  20. case .english:
  21. return "en"
  22. case .german:
  23. return "de"
  24. case .swedish:
  25. return "sv"
  26. case .frenchCanada:
  27. return "fr-CA"
  28. case .french:
  29. return "fr"
  30. case .arabic:
  31. return "ar"
  32. case .chineseSimplified:
  33. return "zh-Hans"
  34. case .chineseTraditional:
  35. return "zh-Hant"
  36. }
  37. }
  38. static var systemLanguage: AppLanguage {
  39. let preferred = Locale.preferredLanguages.first ?? "en"
  40. let lower = preferred.lowercased()
  41. if lower.hasPrefix("zh-hant")
  42. || lower.hasPrefix("zh-tw")
  43. || lower.hasPrefix("zh-hk")
  44. || lower.hasPrefix("zh-mo")
  45. || lower.contains("-hant") {
  46. return .chineseTraditional
  47. }
  48. if lower.hasPrefix("zh") {
  49. return .chineseSimplified
  50. }
  51. if lower.hasPrefix("de") {
  52. return .german
  53. }
  54. if lower.hasPrefix("sv") {
  55. return .swedish
  56. }
  57. if lower.hasPrefix("en") {
  58. return .english
  59. }
  60. if lower.hasPrefix("fr-ca") {
  61. return .frenchCanada
  62. }
  63. if lower.hasPrefix("fr") {
  64. return .french
  65. }
  66. if lower.hasPrefix("ar") {
  67. return .arabic
  68. }
  69. for language in AppLanguage.allCases where preferred.hasPrefix(language.localeIdentifier) {
  70. return language
  71. }
  72. return .english
  73. }
  74. /// Settings language picker labels — fixed English names, not localized.
  75. var localizedDisplayName: String {
  76. switch self {
  77. case .english:
  78. return "English"
  79. case .german:
  80. return "German"
  81. case .swedish:
  82. return "Swedish"
  83. case .frenchCanada:
  84. return "French (Canada)"
  85. case .french:
  86. return "French"
  87. case .arabic:
  88. return "Arabic"
  89. case .chineseSimplified:
  90. return "Chinese (Simplified)"
  91. case .chineseTraditional:
  92. return "Chinese (Traditional)"
  93. }
  94. }
  95. }
  96. func appLocalized(_ key: String, language: AppLanguage) -> String {
  97. guard let path = Bundle.main.path(forResource: language.localeIdentifier, ofType: "lproj"),
  98. let bundle = Bundle(path: path) else {
  99. return key
  100. }
  101. return bundle.localizedString(forKey: key, value: key, table: nil)
  102. }
  103. func currentAppLanguage() -> AppLanguage {
  104. AppLanguageManager.resolvedLanguage
  105. }
  106. /// Resolves copy for the user’s currently selected language.
  107. func L(_ key: String) -> String {
  108. appLocalized(key, language: currentAppLanguage())
  109. }
  110. /// Localized CV template title. Built-in templates use English keys in `Localizable.strings`;
  111. /// AI-generated titles fall back to per-word translation when no exact key exists.
  112. func localizedTemplateName(_ nameKey: String) -> String {
  113. let language = currentAppLanguage()
  114. let trimmed = nameKey.trimmingCharacters(in: .whitespacesAndNewlines)
  115. guard !trimmed.isEmpty else { return nameKey }
  116. let exact = appLocalized(trimmed, language: language)
  117. if exact != trimmed { return exact }
  118. guard language != .english else { return trimmed }
  119. return translateTemplateNameByTokens(trimmed, language: language)
  120. }
  121. private func translateTemplateNameByTokens(_ name: String, language: AppLanguage) -> String {
  122. let tokens = name.split(separator: " ").map(String.init)
  123. guard !tokens.isEmpty else { return name }
  124. let translated = tokens.map { token -> String in
  125. let perWord = appLocalized(token, language: language)
  126. if perWord != token { return perWord }
  127. return templateNameTokenTranslation(token, language: language) ?? token
  128. }
  129. switch language {
  130. case .chineseSimplified, .chineseTraditional:
  131. return translated.joined()
  132. case .arabic, .english, .french, .frenchCanada, .german, .swedish:
  133. return translated.joined(separator: " ")
  134. }
  135. }
  136. /// Vocabulary for AI-invented template titles (e.g. “Creative Cascade”) when no full-string key exists.
  137. private func templateNameTokenTranslation(_ token: String, language: AppLanguage) -> String? {
  138. let key = token.trimmingCharacters(in: .whitespacesAndNewlines)
  139. guard !key.isEmpty else { return nil }
  140. switch language {
  141. case .chineseTraditional:
  142. return TemplateNameTokenLexicon.zhHant[key] ?? TemplateNameTokenLexicon.zhHant[key.capitalized]
  143. case .chineseSimplified:
  144. return TemplateNameTokenLexicon.zhHans[key] ?? TemplateNameTokenLexicon.zhHans[key.capitalized]
  145. case .arabic:
  146. return TemplateNameTokenLexicon.ar[key] ?? TemplateNameTokenLexicon.ar[key.capitalized]
  147. case .french, .frenchCanada, .german, .swedish, .english:
  148. return nil
  149. }
  150. }
  151. private enum TemplateNameTokenLexicon {
  152. static let zhHant: [String: String] = [
  153. "AI": "人工智慧", "UI": "介面", "UX": "體驗", "ATS": "ATS",
  154. "Airy": "通透", "Atlas": "地圖", "Axis": "軸線", "Bloom": "綻放",
  155. "Blue": "藍", "Bold": "大膽", "Briefing": "簡報", "Cascade": "層疊",
  156. "Chairman": "主席", "Charter": "憲章", "Circuit": "電路", "Clear": "清晰",
  157. "Conduit": "管道", "Core": "核心", "Corporate": "企業", "Craft": "工藝",
  158. "Creative": "創意", "Design": "設計", "Docket": "待辦", "Dynamo": "動力",
  159. "Echo": "回音", "Edge": "邊緣", "Ember": "餘燼", "Estate": "莊園",
  160. "Executive": "高階", "Facet": "刻面", "Flow": "流動", "Flux": "流變",
  161. "Forge": "鍛造", "Frame": "框架", "Grid": "網格", "Harbor": "港灣",
  162. "Horizon": "地平線", "Impact": "影響", "Kite": "風箏", "Lattice": "格柵",
  163. "Ledger": "帳本", "Lens": "鏡頭", "Linea": "線條", "Marigold": "金盞花",
  164. "Mesh": "網狀", "Minimal": "簡約", "Modern": "現代", "Mono": "單色",
  165. "Monarch": "君主", "Nova": "新星", "North": "北方", "Ocean": "海洋",
  166. "Path": "路徑", "Peak": "峰", "Pixel": "像素", "Pinstripe": "細條紋",
  167. "Prime": "首要", "Prism": "稜鏡", "Professional": "專業", "Pulse": "脈動",
  168. "Pure": "純淨", "Quorum": "法定人數", "Regent": "攝政", "River": "河",
  169. "Sculptor": "雕刻", "Shift": "轉換", "Slate": "石板", "Spark": "火花",
  170. "Sterling": "純正", "Stone": "石", "Studio": "工作室", "Summit": "頂峰",
  171. "Swiss": "瑞士", "Swift": "迅捷", "Tabular": "表格", "Vale": "谷",
  172. "Vertex": "頂點", "Wave": "波浪", "White": "白"
  173. ]
  174. static let zhHans: [String: String] = [
  175. "AI": "人工智能", "UI": "界面", "UX": "体验", "ATS": "ATS",
  176. "Airy": "通透", "Atlas": "地图", "Axis": "轴线", "Bloom": "绽放",
  177. "Blue": "蓝", "Bold": "大胆", "Briefing": "简报", "Cascade": "层叠",
  178. "Chairman": "主席", "Charter": "宪章", "Circuit": "电路", "Clear": "清晰",
  179. "Conduit": "管道", "Core": "核心", "Corporate": "企业", "Craft": "工艺",
  180. "Creative": "创意", "Design": "设计", "Docket": "待办", "Dynamo": "动力",
  181. "Echo": "回音", "Edge": "边缘", "Ember": "余烬", "Estate": "庄园",
  182. "Executive": "高管", "Facet": "刻面", "Flow": "流动", "Flux": "流变",
  183. "Forge": "锻造", "Frame": "框架", "Grid": "网格", "Harbor": "港湾",
  184. "Horizon": "地平线", "Impact": "影响", "Kite": "风筝", "Lattice": "格栅",
  185. "Ledger": "账本", "Lens": "镜头", "Linea": "线条", "Marigold": "金盏花",
  186. "Mesh": "网状", "Minimal": "简约", "Modern": "现代", "Mono": "单色",
  187. "Monarch": "君主", "Nova": "新星", "North": "北方", "Ocean": "海洋",
  188. "Path": "路径", "Peak": "峰", "Pixel": "像素", "Pinstripe": "细条纹",
  189. "Prime": "首要", "Prism": "棱镜", "Professional": "专业", "Pulse": "脉动",
  190. "Pure": "纯净", "Quorum": "法定人数", "Regent": "摄政", "River": "河",
  191. "Sculptor": "雕刻", "Shift": "转换", "Slate": "石板", "Spark": "火花",
  192. "Sterling": "纯正", "Stone": "石", "Studio": "工作室", "Summit": "顶峰",
  193. "Swiss": "瑞士", "Swift": "迅捷", "Tabular": "表格", "Vale": "谷",
  194. "Vertex": "顶点", "Wave": "波浪", "White": "白"
  195. ]
  196. static let ar: [String: String] = [
  197. "Creative": "إبداعي", "Cascade": "تتالي", "Design": "تصميم", "Dynamo": "ديناميكي",
  198. "Modern": "عصري", "Professional": "احترافي", "Executive": "تنفيذي", "Minimal": "بسيط",
  199. "Ocean": "محيط", "Blue": "أزرق", "Summit": "قمة", "Horizon": "أفق", "Harbor": "ميناء",
  200. "Studio": "استوديو", "Craft": "حرفة", "Sculptor": "نحات", "UI": "واجهة"
  201. ]
  202. }
  203. @MainActor
  204. final class AppLanguageManager {
  205. static let shared = AppLanguageManager()
  206. static let didChangeNotification = Notification.Name("AppLanguageManager.didChange")
  207. private static let legacyPreferredLanguageKey = "com.appforindeed.preferredLanguage"
  208. /// Settings override for this run only; cleared on launch so UI follows macOS each time.
  209. nonisolated(unsafe) private static var sessionOverride: AppLanguage?
  210. nonisolated static var resolvedLanguage: AppLanguage {
  211. sessionOverride ?? AppLanguage.systemLanguage
  212. }
  213. var current: AppLanguage {
  214. Self.resolvedLanguage
  215. }
  216. func applyStoredPreferenceOnLaunch() {
  217. // UI copy uses `appLocalized`; do not drive layout from `AppleLanguages` (Arabic forces RTL and breaks CV templates).
  218. Self.sessionOverride = nil
  219. UserDefaults.standard.removeObject(forKey: "AppleLanguages")
  220. UserDefaults.standard.removeObject(forKey: Self.legacyPreferredLanguageKey)
  221. }
  222. func setLanguage(_ language: AppLanguage, notify: Bool = true) {
  223. Self.sessionOverride = language
  224. UserDefaults.standard.removeObject(forKey: "AppleLanguages")
  225. if notify {
  226. NotificationCenter.default.post(name: Self.didChangeNotification, object: self)
  227. }
  228. }
  229. func setLanguage(code: String, notify: Bool = true) {
  230. guard let language = AppLanguage.allCases.first(where: { $0.localeIdentifier == code }) else { return }
  231. setLanguage(language, notify: notify)
  232. }
  233. }