AppLanguageManager.swift 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. import AppKit
  2. import Foundation
  3. extension Notification.Name {
  4. static let appLanguageDidChange = Notification.Name("appLanguageDidChange")
  5. }
  6. enum AppLanguageManager {
  7. private static let defaultsKey = "settings.appLanguageCode"
  8. /// UI languages shown in Settings → Language (order preserved).
  9. private static let supportedLanguageCodes = ["en", "fr", "fr-CA", "es-ES", "es-MX", "de", "it", "nl", "pl", "sv", "nb", "fi", "el", "he", "ar", "ko", "pt-BR", "ru", "uk", "vi", "zh-Hans", "zh-Hant"]
  10. /// `nil` follows the system language; otherwise uses the selected `.lproj` code.
  11. static var selectedLanguageCode: String? {
  12. get {
  13. guard UserDefaults.standard.object(forKey: defaultsKey) != nil else { return nil }
  14. let value = UserDefaults.standard.string(forKey: defaultsKey) ?? ""
  15. return value.isEmpty ? nil : value
  16. }
  17. set {
  18. if let code = newValue, code.isEmpty == false {
  19. UserDefaults.standard.set(code, forKey: defaultsKey)
  20. } else {
  21. UserDefaults.standard.removeObject(forKey: defaultsKey)
  22. }
  23. }
  24. }
  25. static var availableLanguageCodes: [String] {
  26. supportedLanguageCodes.filter { code in
  27. Bundle.main.path(forResource: code, ofType: "lproj") != nil
  28. }
  29. }
  30. /// Language code shown in Settings → Language when no explicit choice is stored.
  31. static var pickerSelectedLanguageCode: String {
  32. if let code = selectedLanguageCode {
  33. return code
  34. }
  35. return bestMatchingAvailableLanguageCode() ?? availableLanguageCodes.first ?? "en"
  36. }
  37. private static func bestMatchingAvailableLanguageCode() -> String? {
  38. let available = availableLanguageCodes
  39. guard available.isEmpty == false else { return nil }
  40. func match(_ identifier: String) -> String? {
  41. let normalized = identifier.replacingOccurrences(of: "_", with: "-")
  42. if available.contains(normalized) { return normalized }
  43. let base = normalized.split(separator: "-").first.map(String.init) ?? normalized
  44. if available.contains(base) { return base }
  45. return available.first { code in
  46. code.split(separator: "-").first.map(String.init) == base
  47. }
  48. }
  49. for preferred in Locale.preferredLanguages {
  50. if let code = match(preferred) { return code }
  51. }
  52. return match(Locale.current.identifier)
  53. }
  54. static var localizationBundle: Bundle {
  55. guard let code = selectedLanguageCode,
  56. let path = Bundle.main.path(forResource: code, ofType: "lproj"),
  57. let bundle = Bundle(path: path) else {
  58. return .main
  59. }
  60. return bundle
  61. }
  62. static var formattingLocale: Locale {
  63. if let code = selectedLanguageCode {
  64. return Locale(identifier: code)
  65. }
  66. return .current
  67. }
  68. /// User-facing dates/times respect Settings → Language (not only the system locale).
  69. static func dateFormatter(
  70. dateFormat: String? = nil,
  71. dateStyle: DateFormatter.Style = .none,
  72. timeStyle: DateFormatter.Style = .none
  73. ) -> DateFormatter {
  74. let formatter = DateFormatter()
  75. formatter.locale = formattingLocale
  76. formatter.timeZone = TimeZone.current
  77. if let dateFormat {
  78. formatter.dateFormat = dateFormat
  79. } else {
  80. formatter.dateStyle = dateStyle
  81. formatter.timeStyle = timeStyle
  82. }
  83. return formatter
  84. }
  85. static func localizedDateString(
  86. from date: Date,
  87. dateStyle: DateFormatter.Style,
  88. timeStyle: DateFormatter.Style
  89. ) -> String {
  90. dateFormatter(dateStyle: dateStyle, timeStyle: timeStyle).string(from: date)
  91. }
  92. /// Localized product name (splash, share sheet, etc.).
  93. static var localizedAppDisplayName: String {
  94. "AI Companion for Meet".localized
  95. }
  96. static func displayName(for languageCode: String) -> String {
  97. switch languageCode {
  98. case "en":
  99. return "English"
  100. case "fr":
  101. return "Français"
  102. case "fr-CA":
  103. return "Français (Canada)"
  104. case "es-ES":
  105. return "Español (España)"
  106. case "es-MX":
  107. return "Español (México)"
  108. case "de":
  109. return "Deutsch"
  110. case "it":
  111. return "Italiano"
  112. case "nl":
  113. return "Nederlands"
  114. case "pl":
  115. return "Polski"
  116. case "sv":
  117. return "Svenska"
  118. case "nb":
  119. return "Norsk"
  120. case "fi":
  121. return "Suomi"
  122. case "el":
  123. return "Ελληνικά"
  124. case "he":
  125. return "עברית"
  126. case "ar":
  127. return "العربية"
  128. case "ko":
  129. return "한국어"
  130. case "pt-BR":
  131. return "Português (Brasil)"
  132. case "ru":
  133. return "Русский"
  134. case "uk":
  135. return "Українська"
  136. case "vi":
  137. return "Tiếng Việt"
  138. case "zh-Hans":
  139. return "简体中文"
  140. case "zh-Hant":
  141. return "繁體中文"
  142. default:
  143. return Locale.current.localizedString(forIdentifier: languageCode) ?? languageCode
  144. }
  145. }
  146. /// Locale identifier used for layout direction (selected language or system default).
  147. static var activeLanguageIdentifier: String {
  148. if let code = selectedLanguageCode {
  149. return code
  150. }
  151. return Locale.preferredLanguages.first ?? Locale.current.identifier
  152. }
  153. static var isRightToLeft: Bool {
  154. let identifier = activeLanguageIdentifier
  155. let locale = Locale(identifier: identifier)
  156. let languageCode = locale.languageCode
  157. ?? identifier.split(whereSeparator: { $0 == "-" || $0 == "_" }).first.map(String.init)
  158. ?? identifier
  159. if languageCode == "ar" || languageCode == "he" {
  160. return true
  161. }
  162. return Locale.characterDirection(forLanguage: languageCode) == .rightToLeft
  163. }
  164. static var userInterfaceLayoutDirection: NSUserInterfaceLayoutDirection {
  165. isRightToLeft ? .rightToLeft : .leftToRight
  166. }
  167. static var baseWritingDirection: NSWritingDirection {
  168. isRightToLeft ? .rightToLeft : .leftToRight
  169. }
  170. static var textAlignment: NSTextAlignment {
  171. isRightToLeft ? .right : .left
  172. }
  173. /// Alignment for labels pinned to the trailing edge (e.g. numeric counters).
  174. static var trailingEdgeTextAlignment: NSTextAlignment {
  175. isRightToLeft ? .left : .right
  176. }
  177. static var disclosureChevron: String {
  178. isRightToLeft ? "‹" : "›"
  179. }
  180. static func applyGlobalLayoutDirection() {
  181. for window in NSApp.windows {
  182. if let contentView = window.contentView {
  183. applyLayoutDirection(to: contentView)
  184. }
  185. }
  186. }
  187. static func configureTextFieldForLayout(_ field: NSTextField, alignment: NSTextAlignment? = nil) {
  188. field.alignment = alignment ?? textAlignment
  189. field.baseWritingDirection = baseWritingDirection
  190. field.userInterfaceLayoutDirection = userInterfaceLayoutDirection
  191. }
  192. static func configureContainerForLayout(_ view: NSView) {
  193. view.userInterfaceLayoutDirection = userInterfaceLayoutDirection
  194. }
  195. static func applyLayoutDirection(to view: NSView) {
  196. view.userInterfaceLayoutDirection = userInterfaceLayoutDirection
  197. for subview in view.subviews {
  198. applyLayoutDirection(to: subview)
  199. }
  200. }
  201. static func setLanguage(code: String?) {
  202. let normalized = code?.trimmingCharacters(in: .whitespacesAndNewlines)
  203. let cleaned = (normalized?.isEmpty == false) ? normalized : nil
  204. guard cleaned != selectedLanguageCode else { return }
  205. selectedLanguageCode = cleaned
  206. applyGlobalLayoutDirection()
  207. NotificationCenter.default.post(name: .appLanguageDidChange, object: nil)
  208. }
  209. }