@momo-kits/native-kits 0.162.30 → 0.162.31
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.md +19 -0
- package/ios/Application/ApplicationEnvironment.swift +14 -6
- 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/NavigationFooter.swift +90 -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 +41 -61
- 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
package/CLAUDE.md
CHANGED
|
@@ -53,6 +53,25 @@ Publishing is automated (`publish.sh` + GitLab CI on `[ci build]` / `main`). Do
|
|
|
53
53
|
- Access in a `@Composable`: `LocalLocalize.current.translate("errorCode")`. Language is host-controlled, never read from the OS locale. `AppLanguage` is still provided (derived from `Localize.currentLanguage`) for older components that read it.
|
|
54
54
|
- Some older components still do ad-hoc `AppLanguage.current` + `Map` lookups; new/changed code should use `LocalLocalize`.
|
|
55
55
|
|
|
56
|
+
### SwiftUI (`ios/Application/Localize.swift`)
|
|
57
|
+
|
|
58
|
+
The SwiftUI port differs from Compose in one important way: **it follows the host app language by itself.**
|
|
59
|
+
|
|
60
|
+
- `Localize.init` takes `languageSource: LanguageSource? = UserDefaultsLanguageSource()`. The default source KVO-observes `UserDefaults.standard["language"]` — the key the MoMo host writes from all three of its writers (the `RNResource` RN bridge, `JSONLocalization`, and the KMP `LocalizationProvider`) — so a language switch anywhere in the app republishes to every consumer. Pass `languageSource: nil` to opt out. The host key literal lives only in `UserDefaultsLanguageSource.defaultKey`.
|
|
61
|
+
- Use `Localize.shared` unless a screen genuinely needs an isolated dictionary; it is also the `\.localize` environment default. Note `addTranslations` on it merges into a process-wide dictionary.
|
|
62
|
+
- **Never set `\.localize` directly.** `EnvironmentValues.localize` stores a `LocalizeBox` snapshot, so consumers invalidate only when the injecting view re-runs its body. Always inject via `.momoLocalize(_:)` (`ios/Application/LocalizeModifier.swift`), which holds the object as `@ObservedObject` — that is what makes the snapshot refresh. `NavigationContainer` already does this; any bare `UIHostingController` root must do it too.
|
|
63
|
+
- Read it with `@Environment(\.localize)`, not `@EnvironmentObject` — the environment default never traps, `@EnvironmentObject` fatal-errors when un-injected.
|
|
64
|
+
- `Localize.normalize(_:)` is the single place that copes with the legacy JSON-quoted values (`"\"vi\""`) the host persists; anything that is not `en` resolves to `vi`.
|
|
65
|
+
|
|
66
|
+
## Font scale (`scaleSize`)
|
|
67
|
+
|
|
68
|
+
One formula, both platforms: the device-width scale (`width / 375`, capped at `+5pt`) and the OS Dynamic Type scale (capped at `1.5×`) are computed independently and the **larger wins**. When `useOSScaleRate` is false neither applies — the size is just multiplied by `userScaleRate`. Max font scale is fixed at 1.5; the old per-call/per-scope override is deprecated and ignored on both sides.
|
|
69
|
+
|
|
70
|
+
- **Compose** — `const/Typography.kt` (`scaleSize` overloads for `Float`/`Dp`/`TextUnit`/`TextStyle`). The `TextUnit` overload divides by `density.fontScale` so Compose's own `sp` scaling isn't applied twice. Inputs: `LocalContext.current` (`MiniAppContext.userScaleRate` / `useOSScaleRate`, per-field) falling back to the `KitFontScale` global. On iOS, `platform/OsFontScale.ios.kt` observes `UIContentSizeCategoryDidChangeNotification` and overrides `LocalDensity` — Compose MP otherwise reads `fontScale` once at `ComposeUIViewController` creation. Wired at `NavigationContainer`.
|
|
71
|
+
- **SwiftUI** — `ios/Typography/FontScale.swift`. `FontScale` is an immutable snapshot with `callAsFunction`, so a view reads `@Environment(\.momoFontScale) private var fontScale` and calls `fontScale(16)`. `FontScaleStore.shared` is the `ObservableObject` holding both inputs and the OS factor (it observes `UIContentSizeCategory.didChangeNotification`); the host pushes its preference with `update(userScaleRate:useOSScaleRate:)`.
|
|
72
|
+
- **Never set `\.momoFontScale` directly** — same trap as `\.localize`. Inject via `.momoFontScale(_:context:)`, which holds the store as `@ObservedObject`; that is what re-publishes the snapshot. `NavigationContainer` does it (passing `MiniAppContext` so the per-mini-app override applies); a bare `UIHostingController` root must too. The environment default reads the live store, so an un-injected subtree still gets correct values — just not live ones.
|
|
73
|
+
- The global `scaleSize(_:)` and `Font.appFont(size:)` remain for call sites that can't read the environment (UIKit bridges, free functions). They are **not** reactive. `Font.header_default_bold` & friends are computed `static var`s for the same reason — as stored `static let`s they froze the scale at first access. `UIViewRepresentable`s should read `context.environment.momoFontScale` instead, and re-apply the font in `updateUIView`, not only in `makeUIView`.
|
|
74
|
+
|
|
56
75
|
## Code conventions
|
|
57
76
|
|
|
58
77
|
- **Tokens, not literals:** use `Colors`, `Typography`, `Spacing`, `Radius` objects and `AppTheme.current` for colors/styles. Per-component sizing is bundled in enums (e.g. `Button.Size(val value: ButtonSpecs)`).
|
|
@@ -17,8 +17,12 @@ public class MiniAppContext {
|
|
|
17
17
|
public var toolkitConfig: [String: Any] = [:]
|
|
18
18
|
public var providerId: String = "momo"
|
|
19
19
|
public var permissions: [[String: Any]] = []
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// Per-mini-app font-scale override; nil falls back to FontScaleStore. Mirrors Compose's
|
|
21
|
+
// MiniAppContext.userScaleRate / useOSScaleRate, which win per field over KitFontScale.
|
|
22
|
+
public var userScaleRate: CGFloat? = nil
|
|
23
|
+
public var useOSScaleRate: Bool? = nil
|
|
24
|
+
|
|
25
|
+
public init(appId: String, appCode: String, appName: Any? = nil, appIcon: String, description: Any? = nil, support: [String: Any] = [:], toolkitConfig: [String: Any] = [:], providerId: String = "momo", permissions: [[String: Any]] = [], userScaleRate: CGFloat? = nil, useOSScaleRate: Bool? = nil) {
|
|
22
26
|
self.appId = appId
|
|
23
27
|
self.appCode = appCode
|
|
24
28
|
self.appName = appName
|
|
@@ -28,6 +32,8 @@ public class MiniAppContext {
|
|
|
28
32
|
self.toolkitConfig = toolkitConfig
|
|
29
33
|
self.providerId = providerId
|
|
30
34
|
self.permissions = permissions
|
|
35
|
+
self.userScaleRate = userScaleRate
|
|
36
|
+
self.useOSScaleRate = useOSScaleRate
|
|
31
37
|
}
|
|
32
38
|
}
|
|
33
39
|
|
|
@@ -38,10 +44,12 @@ public class KitConfig {
|
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
public class ApplicationEnvironment: ObservableObject {
|
|
41
|
-
|
|
42
|
-
let
|
|
43
|
-
let
|
|
44
|
-
let
|
|
47
|
+
// AI-GENERATED START: expose the host bridge to modules outside MoMoUIKits (e.g. the demo pod)
|
|
48
|
+
public let applicationContext: MiniAppContext?
|
|
49
|
+
public let composeApi: KitComposeApi?
|
|
50
|
+
public let config: KitConfig?
|
|
51
|
+
public let maxApi: MaxApi?
|
|
52
|
+
// AI-GENERATED END: expose the host bridge to modules outside MoMoUIKits (e.g. the demo pod)
|
|
45
53
|
|
|
46
54
|
public init(applicationContext: MiniAppContext? = nil, composeApi: KitComposeApi? = nil, config: KitConfig? = nil, maxApi: MaxApi? = nil) {
|
|
47
55
|
self.applicationContext = applicationContext
|
|
@@ -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
|
-
}
|
|
@@ -89,7 +89,11 @@ public struct OverplayItem: Identifiable {
|
|
|
89
89
|
|
|
90
90
|
@available(iOS 16.0, *)
|
|
91
91
|
public final class Navigator: ObservableObject {
|
|
92
|
-
@Published public var path: [DynamicScreenRoute] = []
|
|
92
|
+
@Published public var path: [DynamicScreenRoute] = [] {
|
|
93
|
+
// Disposal hangs off the path so every route that leaves the stack — pop,
|
|
94
|
+
// replace, reset — is released the same way, after its exit transition.
|
|
95
|
+
didSet { disposeScreens(droppedFrom: oldValue) }
|
|
96
|
+
}
|
|
93
97
|
@Published public var presented: DynamicDialogRoute?
|
|
94
98
|
@Published public var overplay: OverplayItem?
|
|
95
99
|
|
|
@@ -147,8 +151,7 @@ public final class Navigator: ObservableObject {
|
|
|
147
151
|
|
|
148
152
|
public func replace<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
|
|
149
153
|
guard !path.isEmpty else { return }
|
|
150
|
-
|
|
151
|
-
registry.unregister(id: removed.id)
|
|
154
|
+
path.removeLast()
|
|
152
155
|
push(content, options: options)
|
|
153
156
|
}
|
|
154
157
|
|
|
@@ -158,14 +161,12 @@ public final class Navigator: ObservableObject {
|
|
|
158
161
|
options: NavigationOptions? = nil
|
|
159
162
|
) {
|
|
160
163
|
guard !path.isEmpty else { return }
|
|
161
|
-
|
|
162
|
-
registry.unregister(id: removed.id)
|
|
164
|
+
path.removeLast()
|
|
163
165
|
push(screenName: screenName, content, options: options)
|
|
164
166
|
}
|
|
165
167
|
|
|
166
168
|
public func reset<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
|
|
167
|
-
|
|
168
|
-
registry.unregister(id: rootRoute.id)
|
|
169
|
+
let previousRoot = rootRoute.id
|
|
169
170
|
path.removeAll()
|
|
170
171
|
let route = registry.register(
|
|
171
172
|
screenName: options?.screenName ?? "",
|
|
@@ -173,6 +174,7 @@ public final class Navigator: ObservableObject {
|
|
|
173
174
|
options: options
|
|
174
175
|
)
|
|
175
176
|
rootRoute = route
|
|
177
|
+
disposeScreens([previousRoot])
|
|
176
178
|
}
|
|
177
179
|
|
|
178
180
|
public func reset<V: View>(
|
|
@@ -180,8 +182,7 @@ public final class Navigator: ObservableObject {
|
|
|
180
182
|
@ViewBuilder _ content: @escaping () -> V,
|
|
181
183
|
options: NavigationOptions? = nil
|
|
182
184
|
) {
|
|
183
|
-
|
|
184
|
-
registry.unregister(id: rootRoute.id)
|
|
185
|
+
let previousRoot = rootRoute.id
|
|
185
186
|
path.removeAll()
|
|
186
187
|
let route = registry.register(
|
|
187
188
|
screenName: screenName,
|
|
@@ -189,26 +190,50 @@ public final class Navigator: ObservableObject {
|
|
|
189
190
|
options: options
|
|
190
191
|
)
|
|
191
192
|
rootRoute = route
|
|
193
|
+
disposeScreens([previousRoot])
|
|
192
194
|
}
|
|
193
195
|
|
|
194
196
|
public func pop(_ count: Int = 1, callback: (() -> Void)? = nil) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
197
|
+
popStep(remaining: count, callback: callback)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/// One `pop` iteration. Compose runs the loop in a coroutine and suspends over the
|
|
201
|
+
/// overplay's exit animation before the next step; the same wait is needed here, or
|
|
202
|
+
/// a `pop(2)` (prevent-back: close the popup, then leave) would spend both steps on
|
|
203
|
+
/// the overplay that is still animating out and never pop the screen.
|
|
204
|
+
private func popStep(remaining: Int, callback: (() -> Void)?) {
|
|
205
|
+
guard remaining > 0 else {
|
|
206
|
+
callback?()
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if overplay?.type == .snackBar {
|
|
211
|
+
// Mirrors Kotlin: a snackbar doesn't block back-navigation, so popping
|
|
212
|
+
// while one is showing pops the screen underneath and clears the
|
|
213
|
+
// snackbar immediately, without playing its dismiss animation.
|
|
214
|
+
overplayDismissHandler = nil
|
|
215
|
+
overplay = nil
|
|
216
|
+
guard popScreen() else {
|
|
217
|
+
callback?()
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
popStep(remaining: remaining - 1, callback: callback)
|
|
221
|
+
} else if overplay != nil {
|
|
222
|
+
hideOverplay()
|
|
223
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + Self.overplayDismissDelay) { [weak self] in
|
|
224
|
+
guard let self else { return }
|
|
225
|
+
// The component reports back through `finishOverplayDismiss` once its exit
|
|
226
|
+
// animation ends; clear it here if it never did, so the step is not wasted.
|
|
227
|
+
if self.overplay != nil { self.finishOverplayDismiss() }
|
|
228
|
+
self.popStep(remaining: remaining - 1, callback: callback)
|
|
208
229
|
}
|
|
209
|
-
|
|
230
|
+
} else {
|
|
231
|
+
guard popScreen() else {
|
|
232
|
+
callback?()
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
popStep(remaining: remaining - 1, callback: callback)
|
|
210
236
|
}
|
|
211
|
-
callback?()
|
|
212
237
|
}
|
|
213
238
|
|
|
214
239
|
/// Pops the topmost presented screen or stack entry. Returns `false` (and requests
|
|
@@ -218,11 +243,10 @@ public final class Navigator: ObservableObject {
|
|
|
218
243
|
if presented != nil {
|
|
219
244
|
let dismissedId = presented?.id
|
|
220
245
|
presented = nil
|
|
221
|
-
if let id = dismissedId {
|
|
246
|
+
if let id = dismissedId { disposeScreens([id]) }
|
|
222
247
|
return true
|
|
223
248
|
} else if !path.isEmpty {
|
|
224
|
-
|
|
225
|
-
registry.unregister(id: removed.id)
|
|
249
|
+
path.removeLast()
|
|
226
250
|
return true
|
|
227
251
|
} else {
|
|
228
252
|
composeApi?.requestCallback(funcName: "dismiss", params: nil, onResponse: nil)
|
|
@@ -231,42 +255,54 @@ public final class Navigator: ObservableObject {
|
|
|
231
255
|
}
|
|
232
256
|
|
|
233
257
|
public func popToRoot() {
|
|
234
|
-
for route in path { registry.unregister(id: route.id) }
|
|
235
258
|
path.removeAll()
|
|
236
259
|
}
|
|
237
260
|
|
|
261
|
+
/// The entry a back action applies to — mirrors Compose's `currentScreen()`
|
|
262
|
+
/// (`navController.currentBackStackEntry`): a presented screen sits on top of
|
|
263
|
+
/// the stack, otherwise it is the last pushed screen, otherwise the root.
|
|
264
|
+
private func currentScreen() -> DynamicScreen? {
|
|
265
|
+
registry.getScreen(id: presented?.id ?? path.last?.id ?? rootRoute.id)
|
|
266
|
+
}
|
|
267
|
+
|
|
238
268
|
public func onBackSafe(callback: (() -> Void)? = nil) {
|
|
239
|
-
let latest =
|
|
269
|
+
let latest = currentScreen()
|
|
240
270
|
if let onBack = latest?.options?.onBackHandler {
|
|
241
271
|
onBack()
|
|
242
272
|
return
|
|
243
273
|
}
|
|
244
274
|
if let preventBack = latest?.options?.headerBackProps?.preventBack {
|
|
245
275
|
showModal {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
276
|
+
PopupNotify(
|
|
277
|
+
props: PopupNotifyProps(
|
|
278
|
+
image: preventBack.image,
|
|
279
|
+
title: preventBack.title,
|
|
280
|
+
description: preventBack.description,
|
|
281
|
+
error: preventBack.error,
|
|
282
|
+
primary: PopupAction(
|
|
283
|
+
title: preventBack.primary?.title ?? "",
|
|
284
|
+
onPress: { [weak self] in
|
|
285
|
+
preventBack.primary?.onPress?()
|
|
286
|
+
// Close the popup, then leave the screen (matches Compose's pop(2)).
|
|
287
|
+
self?.pop(2, callback: callback)
|
|
288
|
+
}
|
|
289
|
+
),
|
|
290
|
+
secondary: preventBack.secondary.map { secondary in
|
|
291
|
+
PopupAction(
|
|
292
|
+
title: secondary.title,
|
|
293
|
+
onPress: { [weak self] in
|
|
294
|
+
secondary.onPress?()
|
|
295
|
+
self?.hideOverplay()
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
},
|
|
299
|
+
buttonDirection: preventBack.buttonDirection,
|
|
300
|
+
onIconClose: { [weak self] in
|
|
301
|
+
preventBack.onIconClose?()
|
|
302
|
+
self?.hideOverplay()
|
|
303
|
+
},
|
|
304
|
+
isShowCloseIcon: preventBack.isShowCloseIcon
|
|
305
|
+
)
|
|
270
306
|
)
|
|
271
307
|
}
|
|
272
308
|
return
|
|
@@ -382,4 +418,36 @@ public final class Navigator: ObservableObject {
|
|
|
382
418
|
public func hideSnackBar() {
|
|
383
419
|
if overplay?.type == .snackBar { hideOverplay() }
|
|
384
420
|
}
|
|
421
|
+
|
|
422
|
+
// MARK: Screen disposal
|
|
423
|
+
|
|
424
|
+
/// Matches the `delay(300)` Compose waits in `dismissScreen` before unregistering,
|
|
425
|
+
/// with a little headroom for the pop transition.
|
|
426
|
+
private static let screenDisposeDelay: TimeInterval = 0.35
|
|
427
|
+
|
|
428
|
+
/// Longest overplay exit animation (the bottom sheet's), so a queued pop step runs
|
|
429
|
+
/// after the component has taken itself down.
|
|
430
|
+
private static let overplayDismissDelay: TimeInterval = 0.3
|
|
431
|
+
|
|
432
|
+
private func disposeScreens(droppedFrom previous: [DynamicScreenRoute]) {
|
|
433
|
+
let live = Set(path.map(\.id))
|
|
434
|
+
disposeScreens(previous.map(\.id).filter { !live.contains($0) })
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/// Screens are released only once the pop transition has played out: the outgoing
|
|
438
|
+
/// view is still on screen while it animates and the registry is what renders it —
|
|
439
|
+
/// dropping it right away blanks the screen mid-transition. Liveness is re-checked
|
|
440
|
+
/// when the timer fires, so a route pushed back before then keeps its screen.
|
|
441
|
+
private func disposeScreens(_ ids: [Int]) {
|
|
442
|
+
guard !ids.isEmpty else { return }
|
|
443
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + Self.screenDisposeDelay) { [weak self] in
|
|
444
|
+
guard let self else { return }
|
|
445
|
+
var live = Set(self.path.map(\.id))
|
|
446
|
+
live.insert(self.rootRoute.id)
|
|
447
|
+
if let presented = self.presented { live.insert(presented.id) }
|
|
448
|
+
for id in ids where !live.contains(id) {
|
|
449
|
+
self.registry.unregister(id: id)
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
385
453
|
}
|