1*af2ec015STomasz Sapeta // Copyright 2021-present 650 Industries. All rights reserved.
2*af2ec015STomasz Sapeta 
3*af2ec015STomasz Sapeta import Foundation
4*af2ec015STomasz Sapeta import ABI49_0_0ExpoModulesCore
5*af2ec015STomasz Sapeta 
6*af2ec015STomasz Sapeta let LOCALE_SETTINGS_CHANGED = "onLocaleSettingsChanged"
7*af2ec015STomasz Sapeta let CALENDAR_SETTINGS_CHANGED = "onCalendarSettingsChanged"
8*af2ec015STomasz Sapeta 
9*af2ec015STomasz Sapeta public class LocalizationModule: Module {
definitionnull10*af2ec015STomasz Sapeta   public func definition() -> ModuleDefinition {
11*af2ec015STomasz Sapeta     Name("ExpoLocalization")
12*af2ec015STomasz Sapeta 
13*af2ec015STomasz Sapeta     Constants {
14*af2ec015STomasz Sapeta       return Self.getCurrentLocalization()
15*af2ec015STomasz Sapeta     }
16*af2ec015STomasz Sapeta     AsyncFunction("getLocalizationAsync") {
17*af2ec015STomasz Sapeta       return Self.getCurrentLocalization()
18*af2ec015STomasz Sapeta     }
19*af2ec015STomasz Sapeta     Function("getLocales") {
20*af2ec015STomasz Sapeta       return Self.getLocales()
21*af2ec015STomasz Sapeta     }
22*af2ec015STomasz Sapeta     Function("getCalendars") {
23*af2ec015STomasz Sapeta       return Self.getCalendars()
24*af2ec015STomasz Sapeta     }
25*af2ec015STomasz Sapeta     OnCreate {
26*af2ec015STomasz Sapeta       if let enableRTL = Bundle.main.object(forInfoDictionaryKey: "ExpoLocalization_supportsRTL") as? Bool {
27*af2ec015STomasz Sapeta         self.setSupportsRTL(enableRTL)
28*af2ec015STomasz Sapeta       }
29*af2ec015STomasz Sapeta     }
30*af2ec015STomasz Sapeta 
31*af2ec015STomasz Sapeta     Events(LOCALE_SETTINGS_CHANGED, CALENDAR_SETTINGS_CHANGED)
32*af2ec015STomasz Sapeta 
33*af2ec015STomasz Sapeta     OnStartObserving {
34*af2ec015STomasz Sapeta       NotificationCenter.default.addObserver(
35*af2ec015STomasz Sapeta         self,
36*af2ec015STomasz Sapeta         selector: #selector(LocalizationModule.localeChanged),
37*af2ec015STomasz Sapeta         name: NSLocale.currentLocaleDidChangeNotification, // swiftlint:disable:this legacy_objc_type
38*af2ec015STomasz Sapeta         object: nil
39*af2ec015STomasz Sapeta       )
40*af2ec015STomasz Sapeta     }
41*af2ec015STomasz Sapeta 
42*af2ec015STomasz Sapeta     OnStopObserving {
43*af2ec015STomasz Sapeta       NotificationCenter.default.removeObserver(
44*af2ec015STomasz Sapeta         self,
45*af2ec015STomasz Sapeta         name: NSLocale.currentLocaleDidChangeNotification, // swiftlint:disable:this legacy_objc_type
46*af2ec015STomasz Sapeta         object: nil
47*af2ec015STomasz Sapeta       )
48*af2ec015STomasz Sapeta     }
49*af2ec015STomasz Sapeta   }
50*af2ec015STomasz Sapeta 
isRTLPreferredForCurrentLocalenull51*af2ec015STomasz Sapeta   func isRTLPreferredForCurrentLocale() -> Bool {
52*af2ec015STomasz Sapeta     // swiftlint:disable:next legacy_objc_type
53*af2ec015STomasz Sapeta     return NSLocale.characterDirection(forLanguage: NSLocale.preferredLanguages.first ?? "en-US") == NSLocale.LanguageDirection.rightToLeft
54*af2ec015STomasz Sapeta   }
55*af2ec015STomasz Sapeta 
setSupportsRTLnull56*af2ec015STomasz Sapeta   func setSupportsRTL(_ supportsRTL: Bool) {
57*af2ec015STomasz Sapeta     // These keys are used by ABI49_0_0React Native here: https://github.com/facebook/react-native/blob/main/ABI49_0_0React/Modules/ABI49_0_0RCTI18nUtil.m
58*af2ec015STomasz Sapeta     // We set them before ABI49_0_0React loads to ensure it gets rendered correctly the first time the app is opened.
59*af2ec015STomasz Sapeta     // On iOS we need to set both forceRTL and allowRTL so apps don't have to include localization strings.
60*af2ec015STomasz Sapeta     UserDefaults.standard.set(supportsRTL, forKey: "ABI49_0_0RCTI18nUtil_allowRTL")
61*af2ec015STomasz Sapeta     UserDefaults.standard.set(supportsRTL ? isRTLPreferredForCurrentLocale() : false, forKey: "ABI49_0_0RCTI18nUtil_forceRTL")
62*af2ec015STomasz Sapeta     UserDefaults.standard.synchronize()
63*af2ec015STomasz Sapeta   }
64*af2ec015STomasz Sapeta 
65*af2ec015STomasz Sapeta   // If the application isn't manually localized for the device language then the
66*af2ec015STomasz Sapeta   // native `Locale.current` will fallback on using English US
67*af2ec015STomasz Sapeta   // [cite](https://stackoverflow.com/questions/48136456/locale-current-reporting-wrong-language-on-device).
68*af2ec015STomasz Sapeta   // This method will attempt to return the locale that the device is using regardless of the app,
69*af2ec015STomasz Sapeta   // providing better parity across platforms.
getLocalenull70*af2ec015STomasz Sapeta   static func getLocale() -> Locale {
71*af2ec015STomasz Sapeta     guard let preferredIdentifier = Locale.preferredLanguages.first else {
72*af2ec015STomasz Sapeta       return Locale.current
73*af2ec015STomasz Sapeta     }
74*af2ec015STomasz Sapeta     return Locale(identifier: preferredIdentifier)
75*af2ec015STomasz Sapeta   }
76*af2ec015STomasz Sapeta   /**
77*af2ec015STomasz Sapeta    Maps ios unique identifiers to [BCP 47 calendar types]
78*af2ec015STomasz Sapeta    (https://github.com/unicode-org/cldr/blob/main/common/bcp47/calendar.xml)
79*af2ec015STomasz Sapeta    */
getUnicodeCalendarIdentifiernull80*af2ec015STomasz Sapeta   static func getUnicodeCalendarIdentifier(calendar: Calendar) -> String {
81*af2ec015STomasz Sapeta     switch calendar.identifier {
82*af2ec015STomasz Sapeta     case .buddhist:
83*af2ec015STomasz Sapeta       return "buddhist"
84*af2ec015STomasz Sapeta     case .chinese:
85*af2ec015STomasz Sapeta       return "chinese"
86*af2ec015STomasz Sapeta     case .coptic:
87*af2ec015STomasz Sapeta       return "coptic"
88*af2ec015STomasz Sapeta     case .ethiopicAmeteAlem:
89*af2ec015STomasz Sapeta       return "ethioaa"
90*af2ec015STomasz Sapeta     case .ethiopicAmeteMihret:
91*af2ec015STomasz Sapeta       return "ethiopic"
92*af2ec015STomasz Sapeta     case .gregorian:
93*af2ec015STomasz Sapeta       return "gregory"
94*af2ec015STomasz Sapeta     case .hebrew:
95*af2ec015STomasz Sapeta       return "hebrew"
96*af2ec015STomasz Sapeta     case .indian:
97*af2ec015STomasz Sapeta       return "indian"
98*af2ec015STomasz Sapeta     case .islamic:
99*af2ec015STomasz Sapeta       return "islamic"
100*af2ec015STomasz Sapeta     case .islamicCivil:
101*af2ec015STomasz Sapeta       return "islamic-civil"
102*af2ec015STomasz Sapeta     case .islamicTabular:
103*af2ec015STomasz Sapeta       return "islamic-tbla"
104*af2ec015STomasz Sapeta     case .islamicUmmAlQura:
105*af2ec015STomasz Sapeta       return "islamic-umalqura"
106*af2ec015STomasz Sapeta     case .japanese:
107*af2ec015STomasz Sapeta       return "japanese"
108*af2ec015STomasz Sapeta     case .persian:
109*af2ec015STomasz Sapeta       return "persian"
110*af2ec015STomasz Sapeta     case .republicOfChina:
111*af2ec015STomasz Sapeta       return "roc"
112*af2ec015STomasz Sapeta     case .iso8601:
113*af2ec015STomasz Sapeta       return "iso8601"
114*af2ec015STomasz Sapeta     }
115*af2ec015STomasz Sapeta   }
116*af2ec015STomasz Sapeta 
getMeasurementSystemForLocalenull117*af2ec015STomasz Sapeta   static func getMeasurementSystemForLocale(_ locale: Locale) -> String {
118*af2ec015STomasz Sapeta     if #available(iOS 16, *) {
119*af2ec015STomasz Sapeta       let measurementSystems = [
120*af2ec015STomasz Sapeta         Locale.MeasurementSystem.us: "us",
121*af2ec015STomasz Sapeta         Locale.MeasurementSystem.uk: "uk",
122*af2ec015STomasz Sapeta         Locale.MeasurementSystem.metric: "metric"
123*af2ec015STomasz Sapeta       ]
124*af2ec015STomasz Sapeta       return measurementSystems[locale.measurementSystem] ?? "metric"
125*af2ec015STomasz Sapeta     }
126*af2ec015STomasz Sapeta     return locale.usesMetricSystem ? "metric" : "us"
127*af2ec015STomasz Sapeta   }
128*af2ec015STomasz Sapeta 
getLocalesnull129*af2ec015STomasz Sapeta   static func getLocales() -> [[String: Any?]] {
130*af2ec015STomasz Sapeta     let userSettingsLocale = Locale.current
131*af2ec015STomasz Sapeta 
132*af2ec015STomasz Sapeta     return (Locale.preferredLanguages.isEmpty ? [Locale.current.identifier] : Locale.preferredLanguages)
133*af2ec015STomasz Sapeta       .map { languageTag -> [String: Any?] in
134*af2ec015STomasz Sapeta         let languageLocale = Locale.init(identifier: languageTag)
135*af2ec015STomasz Sapeta 
136*af2ec015STomasz Sapeta         return [
137*af2ec015STomasz Sapeta           "languageTag": languageTag,
138*af2ec015STomasz Sapeta           "languageCode": languageLocale.languageCode,
139*af2ec015STomasz Sapeta           "regionCode": languageLocale.regionCode,
140*af2ec015STomasz Sapeta           "textDirection": Locale.characterDirection(forLanguage: languageTag) == .rightToLeft ? "rtl" : "ltr",
141*af2ec015STomasz Sapeta           "decimalSeparator": userSettingsLocale.decimalSeparator,
142*af2ec015STomasz Sapeta           "digitGroupingSeparator": userSettingsLocale.groupingSeparator,
143*af2ec015STomasz Sapeta           "measurementSystem": getMeasurementSystemForLocale(userSettingsLocale),
144*af2ec015STomasz Sapeta           "currencyCode": languageLocale.currencyCode,
145*af2ec015STomasz Sapeta           "currencySymbol": languageLocale.currencySymbol
146*af2ec015STomasz Sapeta         ]
147*af2ec015STomasz Sapeta       }
148*af2ec015STomasz Sapeta   }
149*af2ec015STomasz Sapeta 
150*af2ec015STomasz Sapeta   @objc
localeChangednull151*af2ec015STomasz Sapeta   private func localeChanged() {
152*af2ec015STomasz Sapeta     // we send both events since on iOS it means both calendar and locale needs an update
153*af2ec015STomasz Sapeta     sendEvent(LOCALE_SETTINGS_CHANGED)
154*af2ec015STomasz Sapeta     sendEvent(CALENDAR_SETTINGS_CHANGED)
155*af2ec015STomasz Sapeta   }
156*af2ec015STomasz Sapeta 
157*af2ec015STomasz Sapeta   // https://stackoverflow.com/a/28183182
uses24HourClocknull158*af2ec015STomasz Sapeta   static func uses24HourClock() -> Bool {
159*af2ec015STomasz Sapeta     let dateFormat = DateFormatter.dateFormat(fromTemplate: "j", options: 0, locale: Locale.current)!
160*af2ec015STomasz Sapeta 
161*af2ec015STomasz Sapeta     return dateFormat.firstIndex(of: "a") == nil
162*af2ec015STomasz Sapeta   }
163*af2ec015STomasz Sapeta 
getCalendarsnull164*af2ec015STomasz Sapeta   static func getCalendars() -> [[String: Any?]] {
165*af2ec015STomasz Sapeta     var calendar = Locale.current.calendar
166*af2ec015STomasz Sapeta     return [
167*af2ec015STomasz Sapeta       [
168*af2ec015STomasz Sapeta         "calendar": getUnicodeCalendarIdentifier(calendar: calendar),
169*af2ec015STomasz Sapeta         "timeZone": "\(calendar.timeZone.identifier)",
170*af2ec015STomasz Sapeta         "uses24hourClock": uses24HourClock(),
171*af2ec015STomasz Sapeta         "firstWeekday": calendar.firstWeekday
172*af2ec015STomasz Sapeta       ]
173*af2ec015STomasz Sapeta     ]
174*af2ec015STomasz Sapeta   }
175*af2ec015STomasz Sapeta 
getCurrentLocalizationnull176*af2ec015STomasz Sapeta   static func getCurrentLocalization() -> [String: Any?] {
177*af2ec015STomasz Sapeta     let locale = getLocale()
178*af2ec015STomasz Sapeta     let languageCode = locale.languageCode ?? "en"
179*af2ec015STomasz Sapeta     var languageIds = Locale.preferredLanguages
180*af2ec015STomasz Sapeta 
181*af2ec015STomasz Sapeta     if languageIds.isEmpty {
182*af2ec015STomasz Sapeta       languageIds.append("en-US")
183*af2ec015STomasz Sapeta     }
184*af2ec015STomasz Sapeta     return [
185*af2ec015STomasz Sapeta       "currency": locale.currencyCode ?? "USD",
186*af2ec015STomasz Sapeta       "decimalSeparator": locale.decimalSeparator ?? ".",
187*af2ec015STomasz Sapeta       "digitGroupingSeparator": locale.groupingSeparator ?? ",",
188*af2ec015STomasz Sapeta       "isoCurrencyCodes": Locale.isoCurrencyCodes,
189*af2ec015STomasz Sapeta       "isMetric": locale.usesMetricSystem,
190*af2ec015STomasz Sapeta       "isRTL": Locale.characterDirection(forLanguage: languageCode) == .rightToLeft,
191*af2ec015STomasz Sapeta       "locale": languageIds.first,
192*af2ec015STomasz Sapeta       "locales": languageIds,
193*af2ec015STomasz Sapeta       "region": locale.regionCode ?? "US",
194*af2ec015STomasz Sapeta       "timezone": TimeZone.current.identifier
195*af2ec015STomasz Sapeta     ]
196*af2ec015STomasz Sapeta   }
197*af2ec015STomasz Sapeta }
198