@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.
- package/.claude/settings.local.json +111 -1
- package/CLAUDE.md +19 -0
- package/compose/build.gradle.kts +1 -1
- package/compose/compose.podspec +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +5 -5
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +4 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +66 -19
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +15 -3
- package/gradle.properties +1 -1
- package/ios/Application/ApplicationEnvironment.swift +14 -6
- package/ios/Application/Components.swift +14 -14
- package/ios/Application/HeaderRight.swift +45 -79
- package/ios/Application/LanguageSource.swift +93 -0
- package/ios/Application/Localize.swift +73 -4
- package/ios/Application/LocalizeModifier.swift +30 -0
- package/ios/Application/Navigation/NavigationContainer.swift +28 -18
- package/ios/Application/Navigation/Navigator.swift +121 -53
- package/ios/Application/Navigation/NavigatorManager.swift +49 -0
- package/ios/Application/Navigation/component/Header.swift +1 -1
- package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +11 -0
- package/ios/Application/Navigation/component/NavigationFooter.swift +90 -0
- package/ios/Application/Navigation/component/NavigationHeaderRight.swift +196 -0
- package/ios/Application/StackScreen.swift +19 -28
- package/ios/Badge/Badge.swift +2 -1
- package/ios/Badge/BadgeRibbon.swift +6 -2
- package/ios/Button/Button.swift +54 -80
- package/ios/Chip/Chip.swift +6 -5
- package/ios/Icon/Icon.swift +13 -4
- package/ios/Input/ErrorView.swift +2 -1
- package/ios/Input/Input.swift +9 -2
- package/ios/Input/InputPhoneNumber.swift +2 -1
- package/ios/Input/InputSearch.swift +3 -2
- package/ios/Input/InputTextArea.swift +6 -1
- package/ios/InputMoney/InputMoney.swift +5 -2
- package/ios/InputOTP/InputOTP.swift +2 -1
- package/ios/Layout/GridContext.swift +30 -0
- package/ios/Layout/Item.swift +42 -0
- package/ios/Layout/Section.swift +134 -0
- package/ios/Popup/PopupNotify.swift +2 -1
- package/ios/ProgressInfo/ProgressInfo.swift +4 -3
- package/ios/Rating/Rating.swift +2 -1
- package/ios/Stepper/Stepper.swift +2 -1
- package/ios/SuggestAction/SuggestAction.swift +3 -2
- package/ios/Tag/Tag.swift +2 -1
- package/ios/Template/TrustBanner/TrustBanner.swift +4 -2
- package/ios/Title/Title.swift +3 -2
- package/ios/Tooltip/Tooltip.swift +2 -1
- package/ios/Typography/FontScale.swift +103 -0
- package/ios/Typography/FontScaleStore.swift +52 -13
- package/ios/Typography/Text.swift +12 -29
- package/ios/Typography/Typography.swift +66 -86
- package/ios/native-kits.podspec +2 -1
- package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/AvatarDemo.swift +87 -73
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/BadgeDemo.swift +55 -249
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ButtonDemo.swift +90 -82
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CarouselDemo.swift +74 -104
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CheckboxDemo.swift +47 -50
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ChipDemo.swift +63 -7
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CollapseDemo.swift +90 -81
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DateTimePickerDemo.swift +73 -97
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DemoScreen.swift +420 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DividerDemo.swift +46 -4
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +54 -57
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IconButtonDemo.swift +80 -45
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IconDemo.swift +81 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ImageDemo.swift +49 -62
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InformationDemo.swift +66 -116
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputDemo.swift +104 -98
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputDropDownDemo.swift +76 -118
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputMoneyDemo.swift +73 -86
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputOTPDemo.swift +69 -54
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputPhoneNumberDemo.swift +74 -54
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputSearchDemo.swift +73 -20
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputTextAreaDemo.swift +68 -38
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/LoaderDemo.swift +55 -52
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/LocalizeDemo.swift +51 -4
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +14 -2
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PaginationDemo.swift +47 -51
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PlaygroundData.swift +76 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +67 -93
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +34 -43
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ProgressInfoDemo.swift +83 -71
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/RadioDemo.swift +55 -23
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/RatingDemo.swift +58 -46
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SectionDemo.swift +83 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SkeletonDemo.swift +41 -12
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SliderDemo.swift +67 -127
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SnackBarDemo.swift +54 -110
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/StepperDemo.swift +69 -79
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/StepsDemo.swift +78 -58
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SuggestActionDemo.swift +76 -65
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SwipeDemo.swift +82 -127
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SwitchDemo.swift +44 -19
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TabViewDemo.swift +92 -168
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TagDemo.swift +79 -27
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ThemeDemo.swift +0 -1
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TitleDemo.swift +87 -92
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TooltipDemo.swift +94 -163
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TypographyDemo.swift +70 -70
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/UploaderDemo.swift +68 -114
- package/ios-demo/Sources/MoMoUIKitsDemo/SwiftUIDemoBrowserView.swift +3 -1
- 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
|
-
@
|
|
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
|
-
|
|
154
|
-
|
|
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:
|
|
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
|
|
141
|
+
"useSystemTools": headerRight?.useSystemTools,
|
|
187
142
|
"tools": headerRight?.tools.map { $0.toMap() } ?? [],
|
|
188
|
-
"context":
|
|
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
|
-
|
|
195
|
-
|
|
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
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
.
|
|
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
|
-
|
|
73
|
-
.
|
|
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
|
-
|
|
82
|
-
.
|
|
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
|
-
//
|
|
91
|
-
//
|
|
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
|
-
|
|
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
|
-
}
|