@momo-kits/native-kits 0.163.1-sp.1-debug → 0.163.1-sp.11-debug

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/.claude/settings.local.json +111 -1
  2. package/CLAUDE.md +19 -0
  3. package/compose/build.gradle.kts +1 -1
  4. package/compose/compose.podspec +1 -1
  5. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +5 -5
  6. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +4 -2
  7. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +66 -19
  8. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +15 -3
  9. package/gradle.properties +1 -1
  10. package/ios/Application/ApplicationEnvironment.swift +14 -6
  11. package/ios/Application/Components.swift +14 -14
  12. package/ios/Application/HeaderRight.swift +45 -79
  13. package/ios/Application/LanguageSource.swift +93 -0
  14. package/ios/Application/Localize.swift +73 -4
  15. package/ios/Application/LocalizeModifier.swift +30 -0
  16. package/ios/Application/Navigation/NavigationContainer.swift +28 -18
  17. package/ios/Application/Navigation/Navigator.swift +121 -53
  18. package/ios/Application/Navigation/NavigatorManager.swift +49 -0
  19. package/ios/Application/Navigation/component/Header.swift +1 -1
  20. package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +11 -0
  21. package/ios/Application/Navigation/component/NavigationFooter.swift +90 -0
  22. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +196 -0
  23. package/ios/Application/StackScreen.swift +19 -28
  24. package/ios/Badge/Badge.swift +2 -1
  25. package/ios/Badge/BadgeRibbon.swift +6 -2
  26. package/ios/Button/Button.swift +54 -80
  27. package/ios/Chip/Chip.swift +6 -5
  28. package/ios/Icon/Icon.swift +13 -4
  29. package/ios/Input/ErrorView.swift +2 -1
  30. package/ios/Input/Input.swift +9 -2
  31. package/ios/Input/InputPhoneNumber.swift +2 -1
  32. package/ios/Input/InputSearch.swift +3 -2
  33. package/ios/Input/InputTextArea.swift +6 -1
  34. package/ios/InputMoney/InputMoney.swift +5 -2
  35. package/ios/InputOTP/InputOTP.swift +2 -1
  36. package/ios/Layout/GridContext.swift +30 -0
  37. package/ios/Layout/Item.swift +42 -0
  38. package/ios/Layout/Section.swift +134 -0
  39. package/ios/Popup/PopupNotify.swift +2 -1
  40. package/ios/ProgressInfo/ProgressInfo.swift +4 -3
  41. package/ios/Rating/Rating.swift +2 -1
  42. package/ios/Stepper/Stepper.swift +2 -1
  43. package/ios/SuggestAction/SuggestAction.swift +3 -2
  44. package/ios/Tag/Tag.swift +2 -1
  45. package/ios/Template/TrustBanner/TrustBanner.swift +4 -2
  46. package/ios/Title/Title.swift +3 -2
  47. package/ios/Tooltip/Tooltip.swift +2 -1
  48. package/ios/Typography/FontScale.swift +103 -0
  49. package/ios/Typography/FontScaleStore.swift +52 -13
  50. package/ios/Typography/Text.swift +12 -29
  51. package/ios/Typography/Typography.swift +66 -86
  52. package/ios/native-kits.podspec +2 -1
  53. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  54. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/AvatarDemo.swift +87 -73
  55. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/BadgeDemo.swift +55 -249
  56. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ButtonDemo.swift +90 -82
  57. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CarouselDemo.swift +74 -104
  58. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CheckboxDemo.swift +47 -50
  59. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ChipDemo.swift +63 -7
  60. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CollapseDemo.swift +90 -81
  61. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DateTimePickerDemo.swift +73 -97
  62. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DemoScreen.swift +420 -0
  63. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DividerDemo.swift +46 -4
  64. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +54 -57
  65. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IconButtonDemo.swift +80 -45
  66. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IconDemo.swift +81 -0
  67. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ImageDemo.swift +49 -62
  68. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InformationDemo.swift +66 -116
  69. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputDemo.swift +104 -98
  70. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputDropDownDemo.swift +76 -118
  71. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputMoneyDemo.swift +73 -86
  72. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputOTPDemo.swift +69 -54
  73. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputPhoneNumberDemo.swift +74 -54
  74. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputSearchDemo.swift +73 -20
  75. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputTextAreaDemo.swift +68 -38
  76. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/LoaderDemo.swift +55 -52
  77. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/LocalizeDemo.swift +51 -4
  78. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +14 -2
  79. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PaginationDemo.swift +47 -51
  80. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PlaygroundData.swift +76 -0
  81. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +67 -93
  82. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +34 -43
  83. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ProgressInfoDemo.swift +83 -71
  84. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/RadioDemo.swift +55 -23
  85. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/RatingDemo.swift +58 -46
  86. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SectionDemo.swift +83 -0
  87. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SkeletonDemo.swift +41 -12
  88. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SliderDemo.swift +67 -127
  89. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SnackBarDemo.swift +54 -110
  90. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/StepperDemo.swift +69 -79
  91. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/StepsDemo.swift +78 -58
  92. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SuggestActionDemo.swift +76 -65
  93. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SwipeDemo.swift +82 -127
  94. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SwitchDemo.swift +44 -19
  95. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TabViewDemo.swift +92 -168
  96. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TagDemo.swift +79 -27
  97. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ThemeDemo.swift +0 -1
  98. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TitleDemo.swift +87 -92
  99. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TooltipDemo.swift +94 -163
  100. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TypographyDemo.swift +70 -70
  101. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/UploaderDemo.swift +68 -114
  102. package/ios-demo/Sources/MoMoUIKitsDemo/SwiftUIDemoBrowserView.swift +3 -1
  103. package/package.json +2 -2
@@ -101,98 +101,69 @@ struct ToolkitHeaderRight: View {
101
101
  var tintColor: Color?
102
102
  @State private var isFavorite: Bool = false
103
103
  @State private var isLoading: Bool = false
104
- @Environment(\.applicationEnvironment) private var environment
105
-
106
- private func apiValue(_ value: Any?) -> Any {
107
- value ?? NSNull()
108
- }
109
-
110
- private var contextMap: [String: Any] {
111
- let context = environment.applicationContext
112
- return [
113
- "appId": apiValue(context?.appId),
114
- "code": apiValue(context?.appCode),
115
- "name": apiValue(context?.appName),
116
- "icon": apiValue(context?.appIcon),
117
- "description": apiValue(context?.description),
118
- "support": apiValue(context?.support),
119
- "toolkitConfig": apiValue(context?.toolkitConfig),
120
- "providerId": apiValue(context?.providerId),
121
- "permissions": apiValue(context?.permissions)
122
- ]
123
- }
124
-
125
- private var shouldShowShortcut: Bool {
126
- headerRight?.useShortcut == true && !(headerRight?.useMore == true && environment.applicationContext == nil)
127
- }
128
-
129
- private func parseResponse(_ response: String) -> Any? {
130
- guard let jsonData = response.data(using: .utf8),
131
- let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else {
132
- return nil
133
- }
134
- return json["response"]
135
- }
104
+ @EnvironmentObject private var environment: ApplicationEnvironment
136
105
 
137
106
  // MARK: - Actions
138
- private func loadFavoriteState() {
139
- environment.composeApi?.request(
140
- funcName: "isFavoriteApp",
141
- params: ["code": apiValue(environment.applicationContext?.appCode)]
142
- ) { response in
143
- if let isFavorite = parseResponse(response) as? Bool {
144
- self.isFavorite = isFavorite
145
- }
146
- }
147
- }
148
-
149
107
  private func onPressShortcut() {
150
108
  environment.composeApi?.request(
151
109
  funcName: "onToolAction",
152
- params: [
153
- "item": ["key": "onFavorite"],
154
- "context": contextMap
155
- ]
156
- ) { response in
157
- if let result = parseResponse(response) as? [String: Any],
158
- result["success"] as? Bool == true {
159
- isFavorite.toggle()
160
- }
110
+ params: ["item": ["key": "onFavorite"]]
111
+ ) { _ in
112
+ isFavorite.toggle()
161
113
  }
162
114
  }
163
115
 
164
116
  private func onPressHelpCenter() {
117
+ let context = environment.applicationContext
118
+ let paramMap: [String: Any] = [
119
+ "appId": context?.appId,
120
+ "code": context?.appCode,
121
+ "name": context?.appName,
122
+ "icon": context?.appIcon,
123
+ "description": context?.description
124
+ ]
165
125
  environment.composeApi?.request(
166
126
  funcName: "showHelpCenter",
167
- params: [
168
- "appId": apiValue(environment.applicationContext?.appId),
169
- "code": apiValue(environment.applicationContext?.appCode),
170
- "name": apiValue(environment.applicationContext?.appName),
171
- "icon": apiValue(environment.applicationContext?.appIcon),
172
- "description": apiValue(environment.applicationContext?.description)
173
- ]
127
+ params: paramMap
174
128
  ) { _ in }
175
129
  }
176
130
 
177
131
  private func onPressClose() {
178
132
  environment.composeApi?.request(
179
133
  funcName: "dismissAll",
180
- params: nil
134
+ params: ""
181
135
  ) { _ in }
182
136
  }
183
137
 
184
138
  private func onPressMore() {
139
+ let context = environment.applicationContext
185
140
  let params: [String: Any] = [
186
- "useSystemTools": headerRight?.useSystemTools ?? true,
141
+ "useSystemTools": headerRight?.useSystemTools,
187
142
  "tools": headerRight?.tools.map { $0.toMap() } ?? [],
188
- "context": contextMap
143
+ "context": [
144
+ "appId": context?.appId,
145
+ "code": context?.appCode,
146
+ "name": context?.appName,
147
+ "icon": context?.appIcon,
148
+ "description": context?.description,
149
+ "support": context?.support,
150
+ "toolkitConfig": context?.toolkitConfig,
151
+ "providerId": context?.providerId,
152
+ "permissions": context?.permissions
153
+ ]
189
154
  ]
190
155
  environment.composeApi?.request(
191
156
  funcName: "showTools",
192
157
  params: params
193
158
  ) { response in
194
- if let toolResponse = parseResponse(response) as? String {
195
- headerRight?.toolCallback?(toolResponse)
159
+ do {
160
+ if let jsonData = response.data(using: .utf8),
161
+ let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
162
+ let toolResponse = json["response"] as? String {
163
+ headerRight?.toolCallback?(toolResponse)
164
+ }
165
+ } catch {
166
+ print("Error parsing response:", error)
196
167
  }
197
168
  }
198
169
  }
@@ -224,7 +195,7 @@ struct ToolkitHeaderRight: View {
224
195
  } ?? false
225
196
 
226
197
  HStack(alignment: .center, spacing: 0) {
227
- if shouldShowShortcut {
198
+ if headerRight?.useShortcut == true {
228
199
  NavigationButton(
229
200
  disabled: isLoading,
230
201
  icon: navButtonConfig.icon,
@@ -235,17 +206,15 @@ struct ToolkitHeaderRight: View {
235
206
  }
236
207
 
237
208
  HStack(alignment: .center, spacing: 0) {
238
- if environment.applicationContext != nil {
239
- Icon(source: "help_center", size: 20, color: tintColor ?? Colors.black17)
240
- .padding(4)
241
- .onTapGesture {
242
- onPressHelpCenter()
243
- }
244
-
245
- Rectangle()
246
- .fill(tintColor ?? Colors.black20)
247
- .frame(width: 0.5, height: 12)
248
- }
209
+ Icon(source: "help_center", size: 20, color: tintColor ?? Colors.black17)
210
+ .padding(4)
211
+ .onTapGesture {
212
+ onPressHelpCenter()
213
+ }
214
+
215
+ Rectangle()
216
+ .fill(tintColor ?? Colors.black20)
217
+ .frame(width: 0.5, height: 12)
249
218
 
250
219
  Icon(source: "16_basic_home", size: 20, color: tintColor ?? Colors.black17)
251
220
  .padding(4)
@@ -262,9 +231,6 @@ struct ToolkitHeaderRight: View {
262
231
  )
263
232
  .padding(.leading, Spacing.S)
264
233
  }
265
- .onAppear {
266
- loadFavoriteState()
267
- }
268
234
  }
269
235
  }
270
236
 
@@ -0,0 +1,93 @@
1
+ // File: ios/Application/LanguageSource.swift
2
+ // Created At: 2026-07-29 10:00:00 +07:00
3
+ // Created By: AI
4
+ // AI Agent: Claude Code
5
+ // Model: claude-opus-5[1m]
6
+
7
+ // AI-GENERATED START: observe the host app's active language so Localize can follow it
8
+ import Foundation
9
+
10
+ /// Read-only view of the host app's active language, plus change delivery.
11
+ /// Values handed to `onChange` are already normalized to `Localize.VI` / `Localize.EN`.
12
+ public protocol LanguageSource: AnyObject {
13
+ var currentLanguage: String { get }
14
+ /// `onChange` may be invoked on any thread; `Localize` dedupes and hops to main.
15
+ func startObserving(_ onChange: @escaping (String) -> Void)
16
+ func stopObserving()
17
+ }
18
+
19
+ /// Reads the language the MoMo host app persists in `UserDefaults`. All three host writers
20
+ /// (the `RNResource` RN bridge, `JSONLocalization`, and the KMP `LocalizationProvider`) write the
21
+ /// same key on `UserDefaults.standard` in-process, so KVO on it sees every one of them.
22
+ public final class UserDefaultsLanguageSource: NSObject, LanguageSource {
23
+ public static let defaultKey = "language"
24
+
25
+ private let defaults: UserDefaults
26
+ private let key: String
27
+ private var onChange: ((String) -> Void)?
28
+ private var isObserving = false
29
+ private var kvoContext = 0
30
+
31
+ /// - Note: `key` must not contain `.` — `forKeyPath:` would read it as a key path.
32
+ public init(
33
+ defaults: UserDefaults = .standard,
34
+ key: String = UserDefaultsLanguageSource.defaultKey
35
+ ) {
36
+ self.defaults = defaults
37
+ self.key = key
38
+ super.init()
39
+ }
40
+
41
+ public var currentLanguage: String {
42
+ Localize.normalize(defaults.string(forKey: key))
43
+ }
44
+
45
+ public func startObserving(_ onChange: @escaping (String) -> Void) {
46
+ guard !isObserving else { return }
47
+ isObserving = true
48
+ self.onChange = onChange
49
+ defaults.addObserver(self, forKeyPath: key, options: [.new], context: &kvoContext)
50
+ // Safety net for writes KVO can miss (e.g. `removeObject`). Double delivery is free:
51
+ // `Localize` dedupes before touching `@Published`.
52
+ NotificationCenter.default.addObserver(
53
+ self,
54
+ selector: #selector(emit),
55
+ name: UserDefaults.didChangeNotification,
56
+ object: defaults
57
+ )
58
+ }
59
+
60
+ public func stopObserving() {
61
+ guard isObserving else { return }
62
+ isObserving = false
63
+ defaults.removeObserver(self, forKeyPath: key, context: &kvoContext)
64
+ NotificationCenter.default.removeObserver(
65
+ self,
66
+ name: UserDefaults.didChangeNotification,
67
+ object: defaults
68
+ )
69
+ onChange = nil
70
+ }
71
+
72
+ deinit {
73
+ stopObserving()
74
+ }
75
+
76
+ public override func observeValue(
77
+ forKeyPath keyPath: String?,
78
+ of object: Any?,
79
+ change: [NSKeyValueChangeKey: Any]?,
80
+ context: UnsafeMutableRawPointer?
81
+ ) {
82
+ guard context == &kvoContext else {
83
+ super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
84
+ return
85
+ }
86
+ emit()
87
+ }
88
+
89
+ @objc private func emit() {
90
+ onChange?(currentLanguage)
91
+ }
92
+ }
93
+ // AI-GENERATED END: observe the host app's active language so Localize can follow it
@@ -28,16 +28,69 @@ public final class Localize: ObservableObject {
28
28
  public static let VI = "vi"
29
29
  public static let EN = "en"
30
30
 
31
+ // AI-GENERATED START: follow the host app language and publish dictionary changes
32
+ /// App-wide instance bound to the host language; the environment default uses it too,
33
+ /// so every screen agrees on one language.
34
+ public static let shared = Localize()
35
+ // AI-GENERATED END: follow the host app language and publish dictionary changes
36
+
31
37
  private var assets: LocalizationObject
32
38
  @Published public private(set) var currentLanguage: String = Localize.VI
33
39
 
34
- public init(_ translations: LocalizationObject = LocalizationObject()) {
40
+ // AI-GENERATED START: follow the host app language and publish dictionary changes
41
+ /// Bumped by `addTranslations` — `assets` is not published, so without this the dictionary
42
+ /// can change without any consumer re-rendering.
43
+ @Published public private(set) var revision: Int = 0
44
+
45
+ private let languageSource: LanguageSource?
46
+
47
+ /// `NavigationContainer` uses this to skip its legacy one-shot `MaxApi` pull.
48
+ var observesAppLanguage: Bool { languageSource != nil }
49
+
50
+ /// Pass `languageSource: nil` to opt out and keep the old "starts at vi" behavior.
51
+ public init(
52
+ _ translations: LocalizationObject = LocalizationObject(),
53
+ languageSource: LanguageSource? = UserDefaultsLanguageSource()
54
+ ) {
35
55
  self.assets = Localize.merge(base: Localize.defaultLanguage, override: translations)
56
+ self.languageSource = languageSource
57
+ guard let languageSource else { return }
58
+ // Seed synchronously so the first frame is already correct (no vi -> en flash).
59
+ currentLanguage = languageSource.currentLanguage
60
+ languageSource.startObserving { [weak self] language in self?.apply(language) }
61
+ }
62
+
63
+ deinit {
64
+ languageSource?.stopObserving()
65
+ }
66
+
67
+ /// Host values are sometimes legacy JSON-quoted (`"\"vi\""`); anything that is not `en` is `vi`.
68
+ public static func normalize(_ raw: String?) -> String {
69
+ let cleaned = (raw ?? "")
70
+ .replacingOccurrences(of: "\"", with: "")
71
+ .trimmingCharacters(in: .whitespacesAndNewlines)
72
+ .lowercased()
73
+ return cleaned == Localize.EN ? Localize.EN : Localize.VI
74
+ }
75
+
76
+ private func apply(_ language: String) {
77
+ // KVO and didChangeNotification both fire for one write; publish once, on main.
78
+ onMain {
79
+ guard self.currentLanguage != language else { return }
80
+ self.currentLanguage = language
81
+ }
82
+ }
83
+
84
+ /// Synchronous when already on main — an async hop would defer the switch by a frame.
85
+ private func onMain(_ work: @escaping () -> Void) {
86
+ if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) }
36
87
  }
37
88
 
89
+ /// A local override: the next host language change supersedes it.
38
90
  public func changeLanguage(_ language: String?) {
39
- currentLanguage = (language == Localize.EN) ? Localize.EN : Localize.VI
91
+ apply(Localize.normalize(language))
40
92
  }
93
+ // AI-GENERATED END: follow the host app language and publish dictionary changes
41
94
 
42
95
  public func translate(_ key: String) -> String {
43
96
  let dictionary = currentLanguage == Localize.EN ? assets.en : assets.vi
@@ -58,6 +111,9 @@ public final class Localize: ObservableObject {
58
111
  /// Existing keys win (mirrors Compose: `merge(translations, assets)`).
59
112
  public func addTranslations(_ translations: LocalizationObject) {
60
113
  assets = Localize.merge(base: translations, override: assets)
114
+ // AI-GENERATED START: publish dictionary changes so consumers re-render
115
+ onMain { self.revision &+= 1 }
116
+ // AI-GENERATED END: publish dictionary changes so consumers re-render
61
117
  }
62
118
 
63
119
  private static func merge(base: LocalizationObject, override: LocalizationObject) -> LocalizationObject {
@@ -283,14 +339,22 @@ public final class Localize: ObservableObject {
283
339
  private struct LocalizeBox: Equatable {
284
340
  let localize: Localize
285
341
  let language: String
342
+ // AI-GENERATED START: invalidate consumers when the dictionary changes, not just the language
343
+ let revision: Int
344
+ // AI-GENERATED END: invalidate consumers when the dictionary changes, not just the language
286
345
 
287
346
  init(_ localize: Localize) {
288
347
  self.localize = localize
289
348
  self.language = localize.currentLanguage
349
+ // AI-GENERATED START: invalidate consumers when the dictionary changes, not just the language
350
+ self.revision = localize.revision
351
+ // AI-GENERATED END: invalidate consumers when the dictionary changes, not just the language
290
352
  }
291
353
 
292
354
  static func == (lhs: LocalizeBox, rhs: LocalizeBox) -> Bool {
293
- lhs.localize === rhs.localize && lhs.language == rhs.language
355
+ // AI-GENERATED START: invalidate consumers when the dictionary changes, not just the language
356
+ lhs.localize === rhs.localize && lhs.language == rhs.language && lhs.revision == rhs.revision
357
+ // AI-GENERATED END: invalidate consumers when the dictionary changes, not just the language
294
358
  }
295
359
  }
296
360
 
@@ -298,7 +362,12 @@ private struct LocalizeKey: EnvironmentKey {
298
362
  /// A default-constructed `Localize` already carries the full kit dictionary, so a
299
363
  /// consumer that never injects one renders untranslated-but-correct text instead of
300
364
  /// trapping the way `@EnvironmentObject` does.
301
- static let defaultValue = LocalizeBox(Localize())
365
+ // AI-GENERATED START: default to the app-language-observing shared instance
366
+ /// `defaultValue` is a `static let`, so this box is never rebuilt: without a provider the
367
+ /// language is right on first render but does not live-update. Wrap the root in
368
+ /// `.momoLocalize()` (or use `NavigationContainer`) to get updates.
369
+ static let defaultValue = LocalizeBox(Localize.shared)
370
+ // AI-GENERATED END: default to the app-language-observing shared instance
302
371
  }
303
372
 
304
373
  public extension EnvironmentValues {
@@ -0,0 +1,30 @@
1
+ // File: ios/Application/LocalizeModifier.swift
2
+ // Created At: 2026-07-29 10:00:00 +07:00
3
+ // Created By: AI
4
+ // AI Agent: Claude Code
5
+ // Model: claude-opus-5[1m]
6
+
7
+ // AI-GENERATED START: inject a language-observing Localize into any SwiftUI subtree
8
+ import SwiftUI
9
+
10
+ public extension View {
11
+ /// Injects an app-language-observing `Localize` and re-renders this subtree on change.
12
+ /// Use at every `UIHostingController` root that is not inside a `NavigationContainer`.
13
+ func momoLocalize(_ localize: Localize = .shared) -> some View {
14
+ modifier(MomoLocalizeModifier(localize: localize))
15
+ }
16
+ }
17
+
18
+ /// `@ObservedObject` is load-bearing: `@Environment(\.localize)` only invalidates when the boxed
19
+ /// value changes, which requires *this* body to re-run on every publish. A plain helper that just
20
+ /// set `\.localize` would inject a snapshot that never updates.
21
+ private struct MomoLocalizeModifier: ViewModifier {
22
+ @ObservedObject var localize: Localize
23
+
24
+ func body(content: Content) -> some View {
25
+ content
26
+ .environmentObject(localize)
27
+ .environment(\.localize, localize)
28
+ }
29
+ }
30
+ // AI-GENERATED END: inject a language-observing Localize into any SwiftUI subtree
@@ -46,7 +46,9 @@ public struct NavigationContainer<Initial: View>: View {
46
46
  maxApi: maxApi
47
47
  )
48
48
  )
49
- _localize = StateObject(wrappedValue: localize ?? Localize())
49
+ // AI-GENERATED START: default to the app-language-observing shared Localize
50
+ _localize = StateObject(wrappedValue: localize ?? Localize.shared)
51
+ // AI-GENERATED END: default to the app-language-observing shared Localize
50
52
  self.composeApi = composeApi
51
53
  self.maxApi = maxApi
52
54
  self.setNavigator = setNavigator
@@ -62,15 +64,19 @@ public struct NavigationContainer<Initial: View>: View {
62
64
  .environmentObject(navigator)
63
65
  .environmentObject(applicationEnvironment)
64
66
  .environment(\.applicationEnvironment, applicationEnvironment)
65
- .environmentObject(localize)
66
- .environment(\.localize, localize)
67
+ // AI-GENERATED START: route Localize + font-scale injection through the observing modifiers
68
+ .momoLocalize(localize)
69
+ .momoFontScale(context: applicationEnvironment.applicationContext)
70
+ // AI-GENERATED END: route Localize + font-scale injection through the observing modifiers
67
71
  .fullScreenCover(item: $navigator.presented) { route in
68
72
  screenView(forDialog: route)
69
73
  .environmentObject(navigator)
70
74
  .environmentObject(applicationEnvironment)
71
75
  .environment(\.applicationEnvironment, applicationEnvironment)
72
- .environmentObject(localize)
73
- .environment(\.localize, localize)
76
+ // AI-GENERATED START: route Localize + font-scale injection through the observing modifiers
77
+ .momoLocalize(localize)
78
+ .momoFontScale(context: applicationEnvironment.applicationContext)
79
+ // AI-GENERATED END: route Localize + font-scale injection through the observing modifiers
74
80
  }
75
81
  .overlay {
76
82
  if let item = navigator.overplay {
@@ -78,8 +84,10 @@ public struct NavigationContainer<Initial: View>: View {
78
84
  .environmentObject(navigator)
79
85
  .environmentObject(applicationEnvironment)
80
86
  .environment(\.applicationEnvironment, applicationEnvironment)
81
- .environmentObject(localize)
82
- .environment(\.localize, localize)
87
+ // AI-GENERATED START: route Localize + font-scale injection through the observing modifiers
88
+ .momoLocalize(localize)
89
+ .momoFontScale(context: applicationEnvironment.applicationContext)
90
+ // AI-GENERATED END: route Localize + font-scale injection through the observing modifiers
83
91
  }
84
92
  }
85
93
  .onAppear {
@@ -87,13 +95,21 @@ public struct NavigationContainer<Initial: View>: View {
87
95
  navigator.composeApi = composeApi
88
96
  }
89
97
  setNavigator?(navigator)
90
- // Mirrors Compose's LaunchedEffect(maxApi, resolvedLocalize): host-reported
91
- // language wins over the default "vi" only when it resolves to English.
98
+ // Register with the host swipe-back bridge (mirrors ComposeAppContainer's
99
+ // ComposeNavigatorManager.push). Idempotent, so a re-appear won't double-count.
100
+ NavigatorManager.shared.push(navigator)
101
+ // AI-GENERATED START: demote the MaxApi language pull to a seed of last resort
102
+ // Only a seed: a Localize with a language source already tracks the host in both
103
+ // directions, so asking maxApi would just race it. `changeLanguage` normalizes the
104
+ // raw response and hops to main itself.
105
+ guard !localize.observesAppLanguage else { return }
92
106
  maxApi?.getLanguage { data in
93
- if parseLanguage(data) == Localize.EN {
94
- localize.changeLanguage(Localize.EN)
95
- }
107
+ localize.changeLanguage(data?["response"] as? String)
96
108
  }
109
+ // AI-GENERATED END: demote the MaxApi language pull to a seed of last resort
110
+ }
111
+ .onDisappear {
112
+ NavigatorManager.shared.remove(navigator)
97
113
  }
98
114
  }
99
115
 
@@ -195,9 +211,3 @@ private func safeAreaBottomInset() -> CGFloat {
195
211
  .compactMap { ($0 as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom }
196
212
  .first ?? 0
197
213
  }
198
-
199
- private func parseLanguage(_ raw: [String: Any?]?) -> String? {
200
- guard let response = (raw?["response"] as? String)?.trimmingCharacters(in: .whitespaces).lowercased(),
201
- !response.isEmpty else { return nil }
202
- return response.hasPrefix(Localize.EN) ? Localize.EN : Localize.VI
203
- }