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