@momo-kits/native-kits 0.168.1-beta.5-debug → 0.168.1-beta.6-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 (33) hide show
  1. package/CLAUDE.md +6 -4
  2. package/compose/build.gradle.kts +1 -1
  3. package/compose/compose.podspec +1 -1
  4. package/compose/src/androidMain/kotlin/vn/momo/kits/const/HostFont.android.kt +4 -3
  5. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +0 -14
  6. package/compose/src/commonMain/kotlin/vn/momo/kits/application/DesignSystemKits.kt +62 -0
  7. package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +2 -1
  8. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +18 -14
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +2 -2
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +2 -2
  11. package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +3 -1
  12. package/gradle.properties +1 -1
  13. package/ios/Application/ApplicationEnvironment.swift +7 -6
  14. package/ios/Application/DesignSystemKits.swift +100 -0
  15. package/ios/Application/DesignSystemKitsPayload.swift +68 -0
  16. package/ios/Badge/BadgeRibbon.swift +8 -1
  17. package/ios/Input/Input.swift +13 -3
  18. package/ios/Input/InputPhoneNumber.swift +1 -3
  19. package/ios/Input/InputSearch.swift +1 -2
  20. package/ios/Input/InputTextArea.swift +5 -3
  21. package/ios/InputMoney/InputMoney.swift +6 -7
  22. package/ios/Stepper/Stepper.swift +1 -2
  23. package/ios/Template/TrustBanner/TrustBanner.swift +4 -2
  24. package/ios/Typography/FontScale.swift +14 -11
  25. package/ios/Typography/Text.swift +86 -12
  26. package/ios/Typography/Typography.swift +2 -2
  27. package/ios/native-kits.podspec +1 -1
  28. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  29. package/package.json +1 -1
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitDesignSystem.kt +0 -18
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitFontScale.kt +0 -22
  32. package/ios/Typography/FontScaleStore.swift +0 -58
  33. package/ios/Typography/KitDesignSystem.swift +0 -17
package/CLAUDE.md CHANGED
@@ -48,7 +48,9 @@ Publishing is automated (`publish.sh` + GitLab CI on `[ci build]` / `main`). Do
48
48
 
49
49
  - **Entry point:** wrap content in `NavigationContainer(...)` from `navigation/`. It builds the `Navigator`, takes a host-supplied `Localize` (falls back to an empty one), and provides the CompositionLocals below. The legacy `Screen` / `ApplicationContainer` / `ComposeApi` stack has been removed on both sides — don't reintroduce it.
50
50
  - **Configuration flows via CompositionLocals**, not params. Public ones: `AppTheme`, `AppStatusBar`, `AppNavigationBar` (`const/Theme.kt`); `ApplicationContext`, `AppConfig`, `AppLanguage`, `LocalLocalize` (`application/`); `LocalNavigator`, `LocalMaxApi` (`navigation/`). Read with `X.current`.
51
- - **Design-system config** (`trustBanner`, `headerBar`, `headerGradient`): the host seeds `KitDesignSystemConfig.update(...)` once (same pattern as `KitFontScale`); `NavigationContainer` provides it as `LocalDesignSystemConfig` (param `designSystemConfig` → parent → global) and applies `headerBar` as the header image when the theme has none.
51
+ - **`DesignSystemKits`** (`application/DesignSystemKits.kt`) is the single app-wide holder for the host-owned design-system inputs: font scale (`userScaleRate` / `useOSScaleRate`), whitelist (`isWhiteList`) and the remote `DesignSystemConfig`, bundled as `DesignSystemKitsConfigs`. The host calls `DesignSystemKits.init(source)` once at startup with its `DesignSystemKitsSource` (sync `getConfigs()` seed + `configsFlow`); `init` is idempotent. Without a source, call `setConfigs(...)` directly. Read with `getConfigs()` (snapshot state, recomposes); non-Compose observers use `configsFlow`.
52
+ - **SwiftUI twin:** `ios/Application/DesignSystemKits.swift` mirrors it field for field (`DesignSystemKitsConfigs` with `designSystemConfig: KitConfig?`, `setConfigs` / `getConfigs`), plus the OS Dynamic Type factor (`osFontScale`). There is no `init(source:)` (Swift reserves `init`): the host observes its own bridge and pushes. A host that can't share Swift types (the KMP bridge) calls `DesignSystemKits.shared.setConfigs(json:)` (`ios/Application/DesignSystemKitsPayload.swift`); the JSON field names are a contract with momo-app's serializer (`HostDesignSystemKits.observeConfigsJson`, payload classes in `DesignSystemData.kt`) — change both together. Invalid JSON is ignored. The SwiftUI kit can't link the Kotlin `kits` framework (a second Kotlin/Native runtime would hold a second, never-initialized singleton), so the render-time logic — `FontScale` formula, Dynamic Type observation — is intentionally duplicated in Swift; only values flow across.
53
+ - **Design-system config** (`trustBanner`, `headerBar`, `headerGradient`): `NavigationContainer` provides it as `LocalDesignSystemConfig` (param `designSystemConfig` → parent → `DesignSystemKits`) and applies `headerBar` as the header image when the theme has none.
52
54
  - **Platform code** goes through `expect`/`actual` in `platform/` — never reference Android/iOS APIs from `commonMain` directly.
53
55
  - **Native bridge:** `IMaxApi` (`vn.momo.maxapi`, exposed as `LocalMaxApi`) is the host-app bridge (dismiss, tracking, device info, language).
54
56
  - Navigation uses a runtime `DynamicScreenRegistry`; overlays (modal/bottom-sheet/snackbar) go through `Navigator` + `OverplayComponentRegistry`.
@@ -104,9 +106,9 @@ The SwiftUI port differs from Compose in one important way: **it follows the hos
104
106
 
105
107
  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.
106
108
 
107
- - **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`.
108
- - **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:)`.
109
- - **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.
109
+ - **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 `DesignSystemKits.getConfigs()`. On iOS, `platform/OsFontScale.ios.kt` observes `UIContentSizeCategoryDidChangeNotification` and overrides `LocalDensity` — Compose MP otherwise reads `fontScale` once at `ComposeUIViewController` creation. Wired at `NavigationContainer`.
110
+ - **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)`. `DesignSystemKits.shared` is the `ObservableObject` holding the host inputs and the OS factor (it observes `UIContentSizeCategory.didChangeNotification`); the host pushes them with `setConfigs(_:)`.
111
+ - **Never set `\.momoFontScale` directly** — same trap as `\.localize`. Inject via `.momoFontScale(_:context:)`, which holds `DesignSystemKits` as `@ObservedObject`; that is what re-publishes the snapshot. It also injects `\.designSystemKitsConfigs`, which `MomoText` (whitelist font) and `TrustBanner` (fallback after `ApplicationEnvironment.config`) read so a host push re-renders them. `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.
110
112
  - 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`.
111
113
 
112
114
  ## Code conventions
@@ -40,7 +40,7 @@ kotlin {
40
40
  }
41
41
 
42
42
  cocoapods {
43
- version = "0.168.1-beta.5-debug"
43
+ version = "0.168.1-beta.6-debug"
44
44
  summary = "IOS Shared module"
45
45
  homepage = "https://momo.vn"
46
46
  ios.deploymentTarget = "15.0"
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |spec|
2
2
  spec.name = 'compose'
3
- spec.version = '0.167.1-duo.15-debug'
3
+ spec.version = '0.167.1-beta.22-debug'
4
4
  spec.homepage = 'https://momo.vn'
5
5
  spec.source = { :http=> ''}
6
6
  spec.authors = ''
@@ -5,15 +5,16 @@ import androidx.compose.runtime.remember
5
5
  import androidx.compose.ui.platform.LocalContext
6
6
  import androidx.compose.ui.text.font.Font
7
7
  import androidx.compose.ui.text.font.FontFamily
8
+ import androidx.compose.ui.text.font.FontWeight
8
9
 
9
10
  @Composable
10
- internal actual fun hostFontFamily(fileName: String): FontFamily? {
11
+ internal actual fun hostFontFamily(fileName: String, weight: FontWeight): FontFamily? {
11
12
  val assets = LocalContext.current.applicationContext.assets
12
- return remember(fileName) {
13
+ return remember(fileName, weight) {
13
14
  val path = "fonts/$fileName"
14
15
  // AssetManager.open ném IOException nếu thiếu file; Font(path, assetManager) thì lỗi
15
16
  // muộn lúc render nên phải kiểm tra tồn tại trước.
16
17
  val exists = runCatching { assets.open(path).close() }.isSuccess
17
- if (exists) FontFamily(Font(path = path, assetManager = assets)) else null
18
+ if (exists) FontFamily(Font(path = path, assetManager = assets, weight = weight)) else null
18
19
  }
19
20
  }
@@ -1,9 +1,6 @@
1
1
  package vn.momo.kits.application
2
2
 
3
3
  import androidx.compose.runtime.Immutable
4
- import androidx.compose.runtime.getValue
5
- import androidx.compose.runtime.mutableStateOf
6
- import androidx.compose.runtime.setValue
7
4
  import androidx.compose.runtime.staticCompositionLocalOf
8
5
  import vn.momo.kits.components.TrustBannerData
9
6
 
@@ -19,17 +16,6 @@ data class DesignSystemConfig(
19
16
  val headerGradient: String? = null,
20
17
  )
21
18
 
22
- // App-wide holder seeded by the host (same pattern as KitFontScale), so direct NavigationContainer
23
- // callers get the config without passing it. Snapshot state: a config fetched after first frame recomposes.
24
- object KitDesignSystemConfig {
25
- var config by mutableStateOf<DesignSystemConfig?>(null)
26
- private set
27
-
28
- fun update(config: DesignSystemConfig?) {
29
- this.config = config
30
- }
31
- }
32
-
33
19
  @Immutable
34
20
  data class MiniAppContext(
35
21
  val appIcon: String = "",
@@ -0,0 +1,62 @@
1
+ package vn.momo.kits.application
2
+
3
+ import androidx.compose.runtime.Immutable
4
+ import androidx.compose.runtime.getValue
5
+ import androidx.compose.runtime.mutableStateOf
6
+ import androidx.compose.runtime.setValue
7
+ import kotlinx.coroutines.CoroutineScope
8
+ import kotlinx.coroutines.Dispatchers
9
+ import kotlinx.coroutines.Job
10
+ import kotlinx.coroutines.SupervisorJob
11
+ import kotlinx.coroutines.flow.Flow
12
+ import kotlinx.coroutines.flow.MutableStateFlow
13
+ import kotlinx.coroutines.flow.StateFlow
14
+ import kotlinx.coroutines.flow.asStateFlow
15
+ import kotlinx.coroutines.launch
16
+
17
+ @Immutable
18
+ data class DesignSystemKitsConfigs(
19
+ val userScaleRate: Float = 1f,
20
+ val useOSScaleRate: Boolean = true,
21
+ val isWhiteList: Boolean = false,
22
+ val designSystemConfig: DesignSystemConfig? = null,
23
+ )
24
+
25
+ // The kit is sandboxed from the host's storage/config, so the host implements this and hands it to init().
26
+ interface DesignSystemKitsSource {
27
+ // Sync read: seeds the kit before the first frame, so there is no default-then-real flicker.
28
+ fun getConfigs(): DesignSystemKitsConfigs
29
+
30
+ val configsFlow: Flow<DesignSystemKitsConfigs>
31
+ }
32
+
33
+ // App-wide design-system inputs (font scale, whitelist, remote config). scaleSize(), defaultTheme and
34
+ // NavigationContainer fall back to these when no MiniAppContext / LocalDesignSystemConfig is provided.
35
+ object DesignSystemKits {
36
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
37
+ private var observeJob: Job? = null
38
+
39
+ // Snapshot state so Compose readers recompose (including non-composable getters like defaultTheme);
40
+ // the StateFlow is for non-Compose observers, e.g. the host's SwiftUI bridge, where Compose's
41
+ // global snapshot apply loop may never have started.
42
+ private var currentConfigs by mutableStateOf(DesignSystemKitsConfigs())
43
+ private val mutableConfigsFlow = MutableStateFlow(currentConfigs)
44
+ val configsFlow: StateFlow<DesignSystemKitsConfigs> = mutableConfigsFlow.asStateFlow()
45
+
46
+ // Idempotent: a repeated call (e.g. Activity re-creation) replaces the previous observer.
47
+ fun init(source: DesignSystemKitsSource) {
48
+ observeJob?.cancel()
49
+ setConfigs(source.getConfigs())
50
+ observeJob = scope.launch {
51
+ source.configsFlow.collect { setConfigs(it) }
52
+ }
53
+ }
54
+
55
+ // While init() is observing a source, its next emission overrides a value set here.
56
+ fun setConfigs(configs: DesignSystemKitsConfigs) {
57
+ currentConfigs = configs
58
+ mutableConfigsFlow.value = configs
59
+ }
60
+
61
+ fun getConfigs(): DesignSystemKitsConfigs = currentConfigs
62
+ }
@@ -2,6 +2,7 @@ package vn.momo.kits.const
2
2
 
3
3
  import androidx.compose.runtime.Composable
4
4
  import androidx.compose.ui.text.font.FontFamily
5
+ import androidx.compose.ui.text.font.FontWeight
5
6
 
6
7
  /**
7
8
  * Nạp font từ tài nguyên native của app host thay vì đóng gói bản copy riêng trong SDK.
@@ -13,4 +14,4 @@ import androidx.compose.ui.text.font.FontFamily
13
14
  * Trả về `null` nếu host không có font đó — caller tự fallback [FontFamily.Default].
14
15
  */
15
16
  @Composable
16
- internal expect fun hostFontFamily(fileName: String): FontFamily?
17
+ internal expect fun hostFontFamily(fileName: String, weight: FontWeight): FontFamily?
@@ -8,6 +8,7 @@ import androidx.compose.ui.text.font.FontFamily
8
8
  import androidx.compose.ui.text.font.FontWeight
9
9
  import androidx.compose.ui.text.style.TextDecoration
10
10
  import androidx.compose.ui.unit.*
11
+ import vn.momo.kits.application.DesignSystemKits
11
12
  import vn.momo.kits.application.LocalContext
12
13
  import vn.momo.kits.platform.getScreenDimensions
13
14
  import kotlin.math.max
@@ -20,10 +21,9 @@ const val MAX_DEVICE_SCALE = 5
20
21
  @Composable
21
22
  fun scaleSize(size: Float): Float {
22
23
  val context = LocalContext.current
23
- // Host-pushed context wins; otherwise fall back to the app-wide KitFontScale holder.
24
- val useOsScale = context?.useOSScaleRate ?: KitFontScale.useOSScaleRate
24
+ val useOsScale = context?.useOSScaleRate ?: DesignSystemKits.getConfigs().useOSScaleRate
25
25
  if (!useOsScale) {
26
- return size * (context?.userScaleRate ?: KitFontScale.userScaleRate)
26
+ return size * (context?.userScaleRate ?: DesignSystemKits.getConfigs().userScaleRate)
27
27
  }
28
28
  val scaleSizeMaxRate: Float = MAX_FONT_SCALE
29
29
  // Short side, not width: landscape must not inflate every font.
@@ -84,12 +84,6 @@ fun scaleSize(textStyle: TextStyle): TextStyle {
84
84
  }
85
85
 
86
86
 
87
- /**
88
- * Tên file font trong tài nguyên native của app host (xem [hostFontFamily]).
89
- *
90
- * SDK không đóng gói font riêng nữa: app host đã bundle sẵn toàn bộ font từ `font-source`
91
- * cho cả React Native lẫn native, nên bản copy trong `composeResources` chỉ làm phình app.
92
- */
93
87
  private const val LEGACY_DEFAULT_FONT_FILE = "SFProText-Regular.ttf"
94
88
  private const val NEW_DEFAULT_FONT_FILE = "MomoTrustSans-Regular.ttf"
95
89
 
@@ -124,17 +118,27 @@ private val hostFontFiles: Map<String, String> = mapOf(
124
118
  private const val NEW_DEFAULT_FONT_FAMILY = "MomoTrustSans"
125
119
  private const val LEGACY_DEFAULT_FONT_FAMILY = "SFProText"
126
120
 
121
+ // Families that ship one file per weight; the others map every weight to a single file.
122
+ private val weightedFontFamilies = setOf(NEW_DEFAULT_FONT_FAMILY, LEGACY_DEFAULT_FONT_FAMILY)
123
+
127
124
  internal val defaultFontFamily: String
128
- get() = if (KitDesignSystem.isWhiteList) NEW_DEFAULT_FONT_FAMILY else LEGACY_DEFAULT_FONT_FAMILY
125
+ get() = if (DesignSystemKits.getConfigs().isWhiteList) NEW_DEFAULT_FONT_FAMILY else LEGACY_DEFAULT_FONT_FAMILY
129
126
 
130
127
  @Composable
131
128
  fun getFontFamily(fontFamily: String, fontWeight: FontWeight? = FontWeight.Normal): FontFamily {
132
- val isWhiteList = KitDesignSystem.isWhiteList
129
+ val isWhiteList = DesignSystemKits.getConfigs().isWhiteList
133
130
  val defaultFile = if (isWhiteList) NEW_DEFAULT_FONT_FILE else LEGACY_DEFAULT_FONT_FILE
134
131
  val key = "$fontFamily-${fontWeight?.weight}"
135
- val fileName = hostFontFiles[key] ?: defaultFile
136
- return hostFontFamily(fileName)
137
- ?: hostFontFamily(defaultFile)
132
+ val mappedFile = hostFontFiles[key]
133
+ val fileName = mappedFile ?: defaultFile
134
+ // Declaring the file's real weight keeps Compose from synthesizing bold on top of a bold file.
135
+ val fileWeight = if (mappedFile != null && fontFamily in weightedFontFamilies) {
136
+ fontWeight ?: FontWeight.Normal
137
+ } else {
138
+ FontWeight.Normal
139
+ }
140
+ return hostFontFamily(fileName, fileWeight)
141
+ ?: hostFontFamily(defaultFile, FontWeight.Normal)
138
142
  ?: FontFamily.Default
139
143
  }
140
144
 
@@ -20,7 +20,7 @@ import androidx.navigation.toRoute
20
20
  import kotlinx.coroutines.delay
21
21
  import kotlinx.coroutines.flow.collectLatest
22
22
  import vn.momo.kits.application.DesignSystemConfig
23
- import vn.momo.kits.application.KitDesignSystemConfig
23
+ import vn.momo.kits.application.DesignSystemKits
24
24
  import vn.momo.kits.application.LocalContext
25
25
  import vn.momo.kits.application.LocalDesignSystemConfig
26
26
  import vn.momo.kits.application.LocalLocalize
@@ -66,7 +66,7 @@ fun NavigationContainer(
66
66
  val stackTransition = remember { StackTransitionCoordinator() }
67
67
  val theme = remember { mutableStateOf(initialTheme) }
68
68
  val resolvedDesignSystemConfig =
69
- designSystemConfig ?: LocalDesignSystemConfig.current ?: KitDesignSystemConfig.config
69
+ designSystemConfig ?: LocalDesignSystemConfig.current ?: DesignSystemKits.getConfigs().designSystemConfig
70
70
  val headerBar = resolvedDesignSystemConfig?.headerBar
71
71
  // RN parity: the theme's own header image wins; config headerBar is only the fallback.
72
72
  val resolvedTheme = remember(theme.value, headerBar) {
@@ -8,13 +8,13 @@ import androidx.compose.ui.text.style.TextAlign
8
8
  import androidx.compose.ui.text.style.TextOverflow
9
9
  import androidx.compose.ui.unit.sp
10
10
  import androidx.compose.ui.zIndex
11
+ import vn.momo.kits.application.DesignSystemKits
11
12
  import vn.momo.kits.components.Text
12
- import vn.momo.kits.const.KitDesignSystem
13
13
  import vn.momo.kits.const.Typography
14
14
  import vn.momo.kits.modifier.setAutomationId
15
15
 
16
16
  internal val headerFontFamily: String?
17
- get() = if (KitDesignSystem.isWhiteList) "MoMoTrustDisplay" else null
17
+ get() = if (DesignSystemKits.getConfigs().isWhiteList) "MoMoTrustDisplay" else null
18
18
 
19
19
  @Composable
20
20
  internal fun HeaderTitle(
@@ -5,6 +5,7 @@ package vn.momo.kits.const
5
5
  import androidx.compose.runtime.Composable
6
6
  import androidx.compose.runtime.remember
7
7
  import androidx.compose.ui.text.font.FontFamily
8
+ import androidx.compose.ui.text.font.FontWeight
8
9
  import androidx.compose.ui.text.platform.Font
9
10
  import kotlinx.cinterop.ExperimentalForeignApi
10
11
  import kotlinx.cinterop.addressOf
@@ -15,7 +16,7 @@ import platform.Foundation.dataWithContentsOfFile
15
16
  import platform.posix.memcpy
16
17
 
17
18
  @Composable
18
- internal actual fun hostFontFamily(fileName: String): FontFamily? = remember(fileName) {
19
+ internal actual fun hostFontFamily(fileName: String, weight: FontWeight): FontFamily? = remember(fileName, weight) {
19
20
  val name = fileName.substringBeforeLast('.')
20
21
  val ext = fileName.substringAfterLast('.', "")
21
22
  // Font từ font-source nằm rời ở bundle root, không nằm trong compose-resources.
@@ -26,6 +27,7 @@ internal actual fun hostFontFamily(fileName: String): FontFamily? = remember(fil
26
27
  getData = {
27
28
  NSData.dataWithContentsOfFile(path)?.toByteArray() ?: ByteArray(0)
28
29
  },
30
+ weight = weight,
29
31
  ),
30
32
  )
31
33
  }
package/gradle.properties CHANGED
@@ -18,7 +18,7 @@ kotlin.apple.xcodeCompatibility.nowarn=true
18
18
  name="ComposeKits"
19
19
  group=vn.momo.kits
20
20
  artifact.id=kits
21
- version=0.167.1-duo.15
21
+ version=0.167.1-beta.22
22
22
 
23
23
  repo=GitLab
24
24
  url=https://gitlab.mservice.com.vn/api/v4/projects/5400/packages/maven
@@ -17,8 +17,8 @@ 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
- // Per-mini-app font-scale override; nil falls back to FontScaleStore. Mirrors Compose's
21
- // MiniAppContext.userScaleRate / useOSScaleRate, which win per field over KitFontScale.
20
+ // Per-mini-app font-scale override; nil falls back to DesignSystemKits. Mirrors Compose's
21
+ // MiniAppContext.userScaleRate / useOSScaleRate, which win per field over DesignSystemKits.
22
22
  public var userScaleRate: CGFloat? = nil
23
23
  public var useOSScaleRate: Bool? = nil
24
24
 
@@ -37,10 +37,11 @@ public class MiniAppContext {
37
37
  }
38
38
  }
39
39
 
40
- public class KitConfig {
41
- public var trustBanner: TrustBannerData? = nil
42
- public var headerBar: String? = nil
43
- public var headerGradient: String? = nil
40
+ // Value type so DesignSystemKitsConfigs can dedupe host pushes; mirrors Compose's DesignSystemConfig.
41
+ public struct KitConfig: Equatable {
42
+ public var trustBanner: TrustBannerData?
43
+ public var headerBar: String?
44
+ public var headerGradient: String?
44
45
 
45
46
  public init(trustBanner: TrustBannerData? = nil, headerBar: String? = nil, headerGradient: String? = nil) {
46
47
  self.trustBanner = trustBanner
@@ -0,0 +1,100 @@
1
+ // File: ios/Application/DesignSystemKits.swift
2
+ // Created At: 2026-10-02 14:17:29 +07:00
3
+ // Created By: AI
4
+ // AI Agent: Claude Code
5
+ // Model: claude-opus-5-5
6
+
7
+ // AI-GENERATED START: app-wide design-system holder mirroring Compose's DesignSystemKits
8
+ import SwiftUI
9
+
10
+ /// Host-owned design-system inputs; mirrors Compose's `DesignSystemKitsConfigs` field for field.
11
+ public struct DesignSystemKitsConfigs: Equatable {
12
+ public var userScaleRate: CGFloat
13
+ public var useOSScaleRate: Bool
14
+ public var isWhiteList: Bool
15
+ public var designSystemConfig: KitConfig?
16
+
17
+ public init(
18
+ userScaleRate: CGFloat = 1,
19
+ useOSScaleRate: Bool = true,
20
+ isWhiteList: Bool = false,
21
+ designSystemConfig: KitConfig? = nil
22
+ ) {
23
+ self.userScaleRate = userScaleRate
24
+ self.useOSScaleRate = useOSScaleRate
25
+ self.isWhiteList = isWhiteList
26
+ self.designSystemConfig = designSystemConfig
27
+ }
28
+ }
29
+
30
+ /// App-wide holder for the SwiftUI kit, mirroring Compose's `DesignSystemKits`. The kit is sandboxed from the
31
+ /// host, so the host pushes `setConfigs(_:)` at startup and on every change; the OS Dynamic Type factor is
32
+ /// tracked here. Views re-render on change as long as the subtree got `.momoFontScale()` (`NavigationContainer`
33
+ /// applies it). Swift reserves `init`, so there is no `init(source:)` twin: the host observes and pushes.
34
+ public final class DesignSystemKits: ObservableObject {
35
+ public static let shared = DesignSystemKits()
36
+
37
+ @Published private var configs = DesignSystemKitsConfigs()
38
+ @Published public private(set) var osFontScale: CGFloat = DesignSystemKits.currentOsFontScale()
39
+
40
+ private init() {
41
+ NotificationCenter.default.addObserver(
42
+ self,
43
+ selector: #selector(contentSizeCategoryDidChange),
44
+ name: UIContentSizeCategory.didChangeNotification,
45
+ object: nil
46
+ )
47
+ }
48
+
49
+ public func setConfigs(_ configs: DesignSystemKitsConfigs) {
50
+ onMain {
51
+ // Re-assigning an unchanged value would still publish and invalidate every kit subtree.
52
+ guard self.configs != configs else { return }
53
+ self.configs = configs
54
+ }
55
+ }
56
+
57
+ public func getConfigs() -> DesignSystemKitsConfigs {
58
+ configs
59
+ }
60
+
61
+ public var fontScale: FontScale {
62
+ FontScale(
63
+ userScaleRate: configs.userScaleRate,
64
+ useOSScaleRate: configs.useOSScaleRate,
65
+ osFontScale: osFontScale
66
+ )
67
+ }
68
+
69
+ @objc private func contentSizeCategoryDidChange() {
70
+ let next = Self.currentOsFontScale()
71
+ guard next != osFontScale else { return }
72
+ osFontScale = next
73
+ }
74
+
75
+ /// Same factor Compose's iOS `ProvideOsFontScale` derives from
76
+ /// `UIFont.preferredFont(forTextStyle: .body).pointSize / 17`, so both iOS surfaces scale alike.
77
+ static func currentOsFontScale() -> CGFloat {
78
+ let base = UIFont.systemFont(ofSize: UIFont.labelFontSize)
79
+ return UIFontMetrics.default.scaledFont(for: base).pointSize / base.pointSize
80
+ }
81
+
82
+ /// Synchronous when already on main — an async hop would defer the change by a frame.
83
+ private func onMain(_ work: @escaping () -> Void) {
84
+ if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) }
85
+ }
86
+ }
87
+
88
+ private struct DesignSystemKitsConfigsKey: EnvironmentKey {
89
+ /// Computed, never stored: an un-injected subtree still reads the live holder, just not reactively.
90
+ static var defaultValue: DesignSystemKitsConfigs { DesignSystemKits.shared.getConfigs() }
91
+ }
92
+
93
+ public extension EnvironmentValues {
94
+ /// Injected by `.momoFontScale()`; never set it directly (same trap as `\.momoFontScale`).
95
+ var designSystemKitsConfigs: DesignSystemKitsConfigs {
96
+ get { self[DesignSystemKitsConfigsKey.self] }
97
+ set { self[DesignSystemKitsConfigsKey.self] = newValue }
98
+ }
99
+ }
100
+ // AI-GENERATED END: app-wide design-system holder mirroring Compose's DesignSystemKits
@@ -0,0 +1,68 @@
1
+ // File: ios/Application/DesignSystemKitsPayload.swift
2
+ // Created At: 2026-10-02 15:40:00 +07:00
3
+ // Created By: AI
4
+ // AI Agent: Claude Code
5
+ // Model: claude-opus-5-5
6
+
7
+ // AI-GENERATED START: JSON entry point for hosts that can't share Swift types with the kit
8
+ import Foundation
9
+
10
+ public extension DesignSystemKits {
11
+ /// Applies configs pushed as JSON by a host that can't share Swift types with the kit (e.g. the KMP bridge).
12
+ /// Invalid JSON is ignored and the current configs are kept.
13
+ func setConfigs(json: String) {
14
+ guard let data = json.data(using: .utf8),
15
+ let payload = try? JSONDecoder().decode(DesignSystemKitsPayload.self, from: data) else { return }
16
+ setConfigs(payload.configs)
17
+ }
18
+ }
19
+
20
+ // Wire format; field names match the host's serializer (momo-app HostDesignSystemKits / DesignSystemData.kt).
21
+ private struct DesignSystemKitsPayload: Decodable {
22
+ let userScaleRate: CGFloat
23
+ let useOSScaleRate: Bool
24
+ let isWhiteList: Bool
25
+ let designSystemConfig: KitConfigPayload?
26
+
27
+ var configs: DesignSystemKitsConfigs {
28
+ DesignSystemKitsConfigs(
29
+ userScaleRate: userScaleRate,
30
+ useOSScaleRate: useOSScaleRate,
31
+ isWhiteList: isWhiteList,
32
+ designSystemConfig: designSystemConfig?.kitConfig
33
+ )
34
+ }
35
+ }
36
+
37
+ private struct KitConfigPayload: Decodable {
38
+ let trustBanner: TrustBannerPayload?
39
+ let headerBar: String?
40
+ let headerGradient: String?
41
+
42
+ var kitConfig: KitConfig {
43
+ KitConfig(trustBanner: trustBanner?.trustBannerData, headerBar: headerBar, headerGradient: headerGradient)
44
+ }
45
+ }
46
+
47
+ private struct TrustBannerPayload: Decodable {
48
+ let content: [String: String]
49
+ let subContent: [String: String]
50
+ let pciImage: String?
51
+ let sslImage: String?
52
+ let urlConfig: String?
53
+ let icons: [String]?
54
+ let momoImage: String?
55
+
56
+ var trustBannerData: TrustBannerData {
57
+ TrustBannerData(
58
+ content: content,
59
+ subContent: subContent,
60
+ pciImage: pciImage,
61
+ momoImage: momoImage,
62
+ sslImage: sslImage,
63
+ icons: icons,
64
+ urlConfig: urlConfig ?? ""
65
+ )
66
+ }
67
+ }
68
+ // AI-GENERATED END: JSON entry point for hosts that can't share Swift types with the kit
@@ -203,7 +203,9 @@ public struct BadgeRibbon: View {
203
203
  /// rotation) — the label gets the same scale applied a second time so it stays right-reading
204
204
  /// while only the ribbon shape flips.
205
205
  public struct RoundedBadgeRibbon: View {
206
+ @Environment(\.appTheme) private var theme
206
207
  @Environment(\.momoFontScale) private var fontScale
208
+ @Environment(\.designSystemKitsConfigs) private var designSystemKitsConfigs
207
209
  let text: String
208
210
  let position: RibbonPosition
209
211
 
@@ -223,7 +225,12 @@ public struct RoundedBadgeRibbon: View {
223
225
 
224
226
  private var measuredTextSize: CGSize {
225
227
  let fontSize = fontScale(TypographyStyle.labelXsMedium.fontSize)
226
- let font = UIFont.systemFont(ofSize: fontSize, weight: .medium)
228
+ let font = momoUIFont(
229
+ size: fontSize,
230
+ weight: .medium,
231
+ family: theme.font,
232
+ isWhiteList: designSystemKitsConfigs.isWhiteList
233
+ )
227
234
  return text.size(withAttributes: [.font: font])
228
235
  }
229
236
 
@@ -153,7 +153,7 @@ public struct Input: View {
153
153
  handleFocusChange(focused)
154
154
  })
155
155
  .keyboardType(keyboardType)
156
- .font(fontScale.font(size: 14, weight: fontWeight == .bold ? .bold : .regular))
156
+ .kitFont(fontWeight == .bold ? .actionSBold : .bodyDefaultRegular)
157
157
  .ignoreBoldTextSetting()
158
158
  .foregroundColor(getTextColor())
159
159
  .disabled(disabled || readOnly)
@@ -345,7 +345,12 @@ private struct SecureInputField: UIViewRepresentable {
345
345
  textField.spellCheckingType = .no
346
346
  textField.delegate = context.coordinator
347
347
  textField.keyboardType = keyboardType
348
- textField.font = UIFont.systemFont(ofSize: fontSize, weight: fontWeight)
348
+ textField.font = momoUIFont(
349
+ size: fontSize,
350
+ weight: fontWeight,
351
+ family: context.environment.appTheme.font,
352
+ isWhiteList: context.environment.designSystemKitsConfigs.isWhiteList
353
+ )
349
354
  textField.textColor = textColor
350
355
  textField.isEnabled = !isDisabled
351
356
  textField.setContentHuggingPriority(.defaultLow, for: .horizontal)
@@ -370,7 +375,12 @@ private struct SecureInputField: UIViewRepresentable {
370
375
  if textField.text != text {
371
376
  textField.text = text
372
377
  }
373
- let font = UIFont.systemFont(ofSize: fontSize, weight: fontWeight)
378
+ let font = momoUIFont(
379
+ size: fontSize,
380
+ weight: fontWeight,
381
+ family: context.environment.appTheme.font,
382
+ isWhiteList: context.environment.designSystemKitsConfigs.isWhiteList
383
+ )
374
384
  if textField.font != font {
375
385
  textField.font = font
376
386
  }
@@ -104,9 +104,7 @@ public struct InputPhoneNumber: View {
104
104
  handleFocusChange(focused)
105
105
  })
106
106
  .keyboardType(.numberPad)
107
- .font(size == .small
108
- ? fontScale.font(size: 14, weight: .semibold)
109
- : fontScale.font(size: 18, weight: .bold))
107
+ .kitFont(size == .small ? .headerSSemibold : .headerMBold)
110
108
  .ignoreBoldTextSetting()
111
109
  .foregroundColor(theme.colors.text.default)
112
110
  .applyCursorColor(theme.colors.primary)
@@ -120,7 +120,6 @@ public struct InputSearchProps {
120
120
 
121
121
  public struct InputSearch: View {
122
122
  @Environment(\.appTheme) private var theme
123
- @Environment(\.momoFontScale) private var fontScale
124
123
  @Binding var text: String
125
124
  var buttonText: String = ""
126
125
  var showButtonText: Bool = false
@@ -263,7 +262,7 @@ public struct InputSearch: View {
263
262
  }, onCommit: onSubmit)
264
263
  .disabled(disabled)
265
264
  .foregroundColor(disabled ? theme.colors.text.disable : theme.colors.text.default)
266
- .font(.system(size: fontScale(14), weight: fontWeight))
265
+ .kitFont(size: 14, weight: fontWeight)
267
266
  .ignoreBoldTextSetting()
268
267
  .keyboardType(keyboardType)
269
268
  .applyCursorColor(theme.colors.primary)
@@ -41,9 +41,11 @@ struct UITextViewWrapper: UIViewRepresentable {
41
41
  uiView.tintColor = UIColor(cursorColor)
42
42
  // Read through `context.environment` rather than the global `scaleSize`: that is what makes
43
43
  // SwiftUI re-run updateUIView when the font scale changes.
44
- uiView.font = UIFont.systemFont(
45
- ofSize: context.environment.momoFontScale(16),
46
- weight: fontWeight == .bold ? .bold : .regular
44
+ uiView.font = momoUIFont(
45
+ size: context.environment.momoFontScale(16),
46
+ weight: fontWeight == .bold ? .bold : .regular,
47
+ family: context.environment.appTheme.font,
48
+ isWhiteList: context.environment.designSystemKitsConfigs.isWhiteList
47
49
  )
48
50
  uiView.isEditable = !disabled
49
51
  if uiView.keyboardType != keyboardType {
@@ -356,14 +356,13 @@ private struct MoneyTextField: UIViewRepresentable {
356
356
  return numeric.isEmpty ? "" : formatNumberToMoney(numeric)
357
357
  }
358
358
 
359
- // Theme family as Compose `getFontFamily`: Momo Trust Sans only for whitelisted hosts, else system.
360
359
  private func font(_ context: Context) -> UIFont {
361
- let size = context.environment.momoFontScale(20)
362
- if KitDesignSystem.shared.isWhiteList, fontFamily == "MomoTrustSans",
363
- let font = UIFont(name: fontWeight == .bold ? "MomoTrustSans-Bold" : "MomoTrustSans-Regular", size: size) {
364
- return font
365
- }
366
- return .systemFont(ofSize: size, weight: fontWeight)
360
+ momoUIFont(
361
+ size: context.environment.momoFontScale(20),
362
+ weight: fontWeight,
363
+ family: fontFamily,
364
+ isWhiteList: context.environment.designSystemKitsConfigs.isWhiteList
365
+ )
367
366
  }
368
367
 
369
368
  final class Coordinator: NSObject, UITextFieldDelegate {
@@ -84,7 +84,6 @@ private struct StepperButton: View {
84
84
 
85
85
  private struct StepperNumberView: View {
86
86
  @Environment(\.appTheme) private var theme
87
- @Environment(\.momoFontScale) private var fontScale
88
87
 
89
88
  @Binding var text: String
90
89
  var size: StepperSize
@@ -106,7 +105,7 @@ private struct StepperNumberView: View {
106
105
  MomoText(text, typography: .descriptionDefaultRegular, color: textColor)
107
106
  }
108
107
  }
109
- .font(.system(size: fontScale(12)))
108
+ .kitFont(size: 12)
110
109
  .ignoreBoldTextSetting()
111
110
  .foregroundColor(textColor)
112
111
  // Compose's min width includes the horizontal padding.
@@ -11,7 +11,7 @@ import SDWebImageSwiftUI
11
11
 
12
12
  let jsonUrl = "https://static.momocdn.net/app/json/component-kits/design_system.json"
13
13
 
14
- public struct TrustBannerData: Decodable {
14
+ public struct TrustBannerData: Decodable, Equatable {
15
15
  public let content: [String: String]
16
16
  public let subContent: [String: String]
17
17
  public let pciImage: String?
@@ -85,6 +85,7 @@ let defaultBanner = TrustBannerData(
85
85
  public struct TrustBanner: View {
86
86
  @Environment(\.applicationEnvironment) var applicationEnvironment
87
87
  @Environment(\.localize) private var localize
88
+ @Environment(\.designSystemKitsConfigs) private var designSystemKitsConfigs
88
89
 
89
90
  var onPress: ((String)->Void)?
90
91
  private let serviceName: String
@@ -104,7 +105,8 @@ public struct TrustBanner: View {
104
105
  }
105
106
 
106
107
  private var banner: TrustBannerData {
107
- applicationEnvironment.config?.trustBanner ?? defaultBanner
108
+ // Container config wins, else the host-pushed DesignSystemKits config (Compose parity).
109
+ (applicationEnvironment.config ?? designSystemKitsConfigs.designSystemConfig)?.trustBanner ?? defaultBanner
108
110
  }
109
111
 
110
112
  func onPressSecurity() {
@@ -52,7 +52,7 @@ public struct FontScale: Equatable {
52
52
  }
53
53
 
54
54
  /// A host-pushed mini-app context wins per field, matching Compose's
55
- /// `context?.useOSScaleRate ?: KitFontScale.useOSScaleRate`.
55
+ /// `context?.useOSScaleRate ?: DesignSystemKits.getConfigs().useOSScaleRate`.
56
56
  public func overriding(userScaleRate: CGFloat?, useOSScaleRate: Bool?) -> FontScale {
57
57
  guard userScaleRate != nil || useOSScaleRate != nil else { return self }
58
58
  return FontScale(
@@ -66,7 +66,7 @@ public struct FontScale: Equatable {
66
66
  private struct FontScaleKey: EnvironmentKey {
67
67
  /// Computed, never stored: a bare `UIHostingController` root that never got `.momoFontScale()`
68
68
  /// still reads the live store instead of a value frozen at first access.
69
- static var defaultValue: FontScale { FontScaleStore.shared.snapshot }
69
+ static var defaultValue: FontScale { DesignSystemKits.shared.fontScale }
70
70
  }
71
71
 
72
72
  public extension EnvironmentValues {
@@ -77,9 +77,10 @@ public extension EnvironmentValues {
77
77
  }
78
78
 
79
79
  public extension View {
80
- /// Injects a Dynamic-Type-observing font scale and re-renders this subtree on change.
80
+ /// Injects a Dynamic-Type-observing font scale plus `\.designSystemKitsConfigs` (whitelist, remote config)
81
+ /// and re-renders this subtree on change.
81
82
  /// Use at every `UIHostingController` root that is not inside a `NavigationContainer`.
82
- func momoFontScale(_ store: FontScaleStore = .shared, context: MiniAppContext? = nil) -> some View {
83
+ func momoFontScale(_ store: DesignSystemKits = .shared, context: MiniAppContext? = nil) -> some View {
83
84
  modifier(MomoFontScaleModifier(store: store, context: context))
84
85
  }
85
86
  }
@@ -87,17 +88,19 @@ public extension View {
87
88
  /// `@ObservedObject` is load-bearing, same as `MomoLocalizeModifier`: `@Environment(\.momoFontScale)`
88
89
  /// only invalidates when the boxed snapshot changes, which requires *this* body to re-run on publish.
89
90
  private struct MomoFontScaleModifier: ViewModifier {
90
- @ObservedObject var store: FontScaleStore
91
+ @ObservedObject var store: DesignSystemKits
91
92
  let context: MiniAppContext?
92
93
 
93
94
  func body(content: Content) -> some View {
94
- content.environment(
95
- \.momoFontScale,
96
- store.snapshot.overriding(
97
- userScaleRate: context?.userScaleRate,
98
- useOSScaleRate: context?.useOSScaleRate
95
+ content
96
+ .environment(
97
+ \.momoFontScale,
98
+ store.fontScale.overriding(
99
+ userScaleRate: context?.userScaleRate,
100
+ useOSScaleRate: context?.useOSScaleRate
101
+ )
99
102
  )
100
- )
103
+ .environment(\.designSystemKitsConfigs, store.getConfigs())
101
104
  }
102
105
  }
103
106
  // AI-GENERATED END: reactive font-scale snapshot injected through the environment
@@ -4,7 +4,7 @@ import SwiftUI
4
4
  /// free functions). Inside a SwiftUI view prefer `@Environment(\.momoFontScale)` so the view
5
5
  /// re-renders when Dynamic Type or the host preference changes.
6
6
  public func scaleSize(_ size: CGFloat) -> CGFloat {
7
- FontScaleStore.shared.snapshot(size)
7
+ DesignSystemKits.shared.fontScale(size)
8
8
  }
9
9
 
10
10
  @available(*, deprecated, message: "Max font scale is fixed at 1.5; this override is no longer applied. Drop the second argument.")
@@ -105,10 +105,10 @@ private enum MomoFontFace {
105
105
  }
106
106
 
107
107
  /// Port of Compose `getFontFamily`; SF Pro Text is the iOS system font, so the legacy family is `.system`.
108
- private func momoFontFace(family: String, weight: Font.Weight) -> MomoFontFace? {
108
+ private func momoFontFace(family: String, weight: Font.Weight, isWhiteList: Bool) -> MomoFontFace? {
109
109
  let isNewFamily = family == "MomoTrustSans" || family == headerFontFamily
110
110
  // Outside the whitelist the new DS families drop to the legacy font, as before DS-791.
111
- if family == "SFProText" || (isNewFamily && !KitDesignSystem.shared.isWhiteList) { return .system }
111
+ if family == "SFProText" || (isNewFamily && !isWhiteList) { return .system }
112
112
  // single-file families (Compose maps every weight to the same .otf)
113
113
  if family == headerFontFamily || family == "MoMoSignature" { return .named(family) }
114
114
  guard family == "MomoTrustSans" else { return nil }
@@ -127,8 +127,14 @@ private func momoFontFace(family: String, weight: Font.Weight) -> MomoFontFace?
127
127
  /// Header title family (Compose `HEADER_FONT_FAMILY`), whitelist only like the default font.
128
128
  let headerFontFamily = "MoMoTrustDisplay"
129
129
 
130
- private func momoFont(size: CGFloat, weight: Font.Weight, family: String, fallbackFamily: String? = nil) -> Font {
131
- switch momoFontFace(family: family, weight: weight) {
130
+ private func momoFont(
131
+ size: CGFloat,
132
+ weight: Font.Weight,
133
+ family: String,
134
+ fallbackFamily: String? = nil,
135
+ isWhiteList: Bool
136
+ ) -> Font {
137
+ switch momoFontFace(family: family, weight: weight, isWhiteList: isWhiteList) {
132
138
  case .system:
133
139
  return .system(size: size, weight: weight)
134
140
  case .named(let name) where UIFont(name: name, size: size) != nil:
@@ -136,15 +142,15 @@ private func momoFont(size: CGFloat, weight: Font.Weight, family: String, fallba
136
142
  default:
137
143
  // Compose falls back to the default font file when a family is missing
138
144
  if let fallbackFamily, fallbackFamily != family {
139
- return momoFont(size: size, weight: weight, family: fallbackFamily)
145
+ return momoFont(size: size, weight: weight, family: fallbackFamily, isWhiteList: isWhiteList)
140
146
  }
141
147
  return .system(size: size, weight: weight)
142
148
  }
143
149
  }
144
150
 
145
151
  // lineHeight is identical across weights of a family, so the regular face is enough.
146
- private func momoLineHeight(size: CGFloat, family: String, fallbackFamily: String? = nil) -> CGFloat {
147
- switch momoFontFace(family: family, weight: .regular) {
152
+ private func momoLineHeight(size: CGFloat, family: String, fallbackFamily: String? = nil, isWhiteList: Bool) -> CGFloat {
153
+ switch momoFontFace(family: family, weight: .regular, isWhiteList: isWhiteList) {
148
154
  case .system:
149
155
  break
150
156
  case .named(let name):
@@ -152,15 +158,71 @@ private func momoLineHeight(size: CGFloat, family: String, fallbackFamily: Strin
152
158
  fallthrough
153
159
  case nil:
154
160
  if let fallbackFamily, fallbackFamily != family {
155
- return momoLineHeight(size: size, family: fallbackFamily)
161
+ return momoLineHeight(size: size, family: fallbackFamily, isWhiteList: isWhiteList)
156
162
  }
157
163
  }
158
164
  return UIFont.systemFont(ofSize: size).lineHeight
159
165
  }
160
166
 
167
+ /// UIKit counterpart of `momoFont`, for text that is not a `MomoText` (UITextField/UITextView, text measuring).
168
+ func momoUIFont(size: CGFloat, weight: UIFont.Weight, family: String, isWhiteList: Bool) -> UIFont {
169
+ guard case .named(let name) = momoFontFace(family: family, weight: Font.Weight(weight), isWhiteList: isWhiteList),
170
+ let font = UIFont(name: name, size: size) else {
171
+ return .systemFont(ofSize: size, weight: weight)
172
+ }
173
+ return font
174
+ }
175
+
176
+ private extension Font.Weight {
177
+ init(_ weight: UIFont.Weight) {
178
+ switch weight {
179
+ case .ultraLight: self = .ultraLight
180
+ case .thin: self = .thin
181
+ case .light: self = .light
182
+ case .medium: self = .medium
183
+ case .semibold: self = .semibold
184
+ case .bold: self = .bold
185
+ case .heavy: self = .heavy
186
+ case .black: self = .black
187
+ default: self = .regular
188
+ }
189
+ }
190
+ }
191
+
192
+ /// Gives input text the same family as `MomoText` (Compose's `getFontFamily`), e.g. a TextField
193
+ /// next to a `MomoText` placeholder. `size` is unscaled; the environment font scale is applied here.
194
+ private struct KitFontModifier: ViewModifier {
195
+ @Environment(\.appTheme) private var theme
196
+ @Environment(\.momoFontScale) private var fontScale
197
+ @Environment(\.designSystemKitsConfigs) private var designSystemKitsConfigs
198
+ let size: CGFloat
199
+ let weight: Font.Weight
200
+
201
+ func body(content: Content) -> some View {
202
+ content.font(momoFont(
203
+ size: fontScale(size),
204
+ weight: weight,
205
+ family: theme.font,
206
+ isWhiteList: designSystemKitsConfigs.isWhiteList
207
+ ))
208
+ }
209
+ }
210
+
211
+ extension View {
212
+ func kitFont(_ typography: TypographyStyle) -> some View {
213
+ modifier(KitFontModifier(size: typography.fontSize, weight: typography.fontWeight))
214
+ }
215
+
216
+ func kitFont(size: CGFloat, weight: Font.Weight = .regular) -> some View {
217
+ modifier(KitFontModifier(size: size, weight: weight))
218
+ }
219
+ }
220
+
161
221
  public struct MomoText: View {
162
222
  @Environment(\.appTheme) private var theme
163
223
  @Environment(\.momoFontScale) private var fontScale
224
+ // From the environment, not DesignSystemKits.shared, so a whitelist flip re-renders the text.
225
+ @Environment(\.designSystemKitsConfigs) private var designSystemKitsConfigs
164
226
  private let content: String
165
227
  private let typography: TypographyStyle
166
228
  private let color: Color?
@@ -236,7 +298,8 @@ public struct MomoText: View {
236
298
  size: fontScale(fontSize),
237
299
  weight: typography.fontWeight,
238
300
  family: fontFamily ?? theme.font,
239
- fallbackFamily: theme.font
301
+ fallbackFamily: theme.font,
302
+ isWhiteList: designSystemKitsConfigs.isWhiteList
240
303
  )
241
304
  }
242
305
 
@@ -244,7 +307,13 @@ public struct MomoText: View {
244
307
  private var baseText: SwiftUI.Text {
245
308
  let keywords = highlightText.filter { !$0.isEmpty }.sorted { $0.count > $1.count }
246
309
  guard !keywords.isEmpty else { return SwiftUI.Text(content) }
247
- let highlightFont = momoFont(size: fontScale(fontSize), weight: TypographyStyle.headerXsSemibold.fontWeight, family: fontFamily ?? theme.font, fallbackFamily: theme.font)
310
+ let highlightFont = momoFont(
311
+ size: fontScale(fontSize),
312
+ weight: TypographyStyle.headerXsSemibold.fontWeight,
313
+ family: fontFamily ?? theme.font,
314
+ fallbackFamily: theme.font,
315
+ isWhiteList: designSystemKitsConfigs.isWhiteList
316
+ )
248
317
  func isWordChar(_ character: Character?) -> Bool {
249
318
  guard let character else { return false }
250
319
  return character.isLetter || character.isNumber || character == "_"
@@ -282,7 +351,12 @@ public struct MomoText: View {
282
351
 
283
352
  // lineSpacing is added on top of the font's natural line height, not on top of fontSize.
284
353
  let lineSpacing = max(0, fontScale(lineHeight)
285
- - momoLineHeight(size: fontScale(fontSize), family: fontFamily ?? theme.font, fallbackFamily: theme.font))
354
+ - momoLineHeight(
355
+ size: fontScale(fontSize),
356
+ family: fontFamily ?? theme.font,
357
+ fallbackFamily: theme.font,
358
+ isWhiteList: designSystemKitsConfigs.isWhiteList
359
+ ))
286
360
 
287
361
  var text = baseText
288
362
  .font(font)
@@ -10,12 +10,12 @@ public extension View {
10
10
  }
11
11
 
12
12
  public extension Font {
13
- /// Non-reactive: resolves against the current `FontScaleStore` snapshot at each access. The
13
+ /// Non-reactive: resolves against the current `DesignSystemKits` font scale at each access. The
14
14
  /// styles below are computed properties for that reason — as stored `static let`s they would
15
15
  /// freeze the scale at first access and never pick up a Dynamic Type change.
16
16
  /// Inside a view, `@Environment(\.momoFontScale)` + `fontScale.font(size:weight:)` also re-renders.
17
17
  static func appFont(size: CGFloat) -> Font {
18
- FontScaleStore.shared.snapshot.font(size: size)
18
+ DesignSystemKits.shared.fontScale.font(size: size)
19
19
  }
20
20
 
21
21
  // New supported typography styles
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |s|
2
2
  s.name = 'MoMoUIKits'
3
- s.version = '0.167.1-duo.15'
3
+ s.version = '0.167.1-beta.22'
4
4
  s.summary = 'MoMoUIKits for iOS'
5
5
  s.homepage = 'https://momo.vn'
6
6
  s.license = { :type => 'MIT' }
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |s|
2
2
  s.name = 'MoMoUIKitsDemo'
3
- s.version = '0.167.1-duo.15'
3
+ s.version = '0.167.1-beta.22'
4
4
  s.summary = 'Demo browser for MoMoUIKits SwiftUI components'
5
5
  s.homepage = 'https://momo.vn'
6
6
  s.license = { :type => 'MIT' }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/native-kits",
3
- "version": "0.168.1-beta.5-debug",
3
+ "version": "0.168.1-beta.6-debug",
4
4
  "private": false,
5
5
  "dependeNavigationncies": {},
6
6
  "devDependencies": {},
@@ -1,18 +0,0 @@
1
- package vn.momo.kits.const
2
-
3
- import androidx.compose.runtime.getValue
4
- import androidx.compose.runtime.mutableStateOf
5
- import androidx.compose.runtime.setValue
6
-
7
- // Design-system rollout holder, same shape as KitFontScale: the kit is sandboxed and can't read the
8
- // host config, so the host seeds this once at startup from DesignSystemConfig. getFontFamily() reads
9
- // it to decide whether the new default font applies, so every screen follows the flag with no prop
10
- // threading. Backed by snapshot state: writes recompose text automatically.
11
- object KitDesignSystem {
12
- var isWhiteList by mutableStateOf(false)
13
- private set
14
-
15
- fun update(isWhiteList: Boolean) {
16
- this.isWhiteList = isWhiteList
17
- }
18
- }
@@ -1,22 +0,0 @@
1
- package vn.momo.kits.const
2
-
3
- import androidx.compose.runtime.getValue
4
- import androidx.compose.runtime.mutableStateOf
5
- import androidx.compose.runtime.setValue
6
-
7
- // App-wide font-scale holder. The kit is sandboxed and can't read the host cache directly, so the
8
- // host (which sees both core and this kit) seeds this once at startup and pushes runtime updates into
9
- // it. scaleSize() reads it as the fallback, so every screen — including direct NavigationContainer
10
- // screens with no host-pushed MiniAppContext — scales with no per-render prop and no async flicker.
11
- // Backed by snapshot state: writes recompose text automatically.
12
- object KitFontScale {
13
- var userScaleRate by mutableStateOf(1f)
14
- private set
15
- var useOSScaleRate by mutableStateOf(true)
16
- private set
17
-
18
- fun update(userScaleRate: Float, useOSScaleRate: Boolean) {
19
- this.userScaleRate = userScaleRate
20
- this.useOSScaleRate = useOSScaleRate
21
- }
22
- }
@@ -1,58 +0,0 @@
1
- import SwiftUI
2
-
3
- /// App-wide font-scale inputs for the SwiftUI kit, and the single place they publish from.
4
- /// The host writes `update(...)` at startup (from the cache-backed `FontScaleProvider`) and again
5
- /// whenever the in-app font-size preference changes; the OS Dynamic Type factor is tracked here.
6
- /// Mirrors Compose's `KitFontScale` + `ProvideOsFontScale` pair, including its live behaviour —
7
- /// views re-render on change as long as the subtree got `.momoFontScale()` (`NavigationContainer`
8
- /// applies it). When `useOSScaleRate` is false the kit applies `userScaleRate` instead of the OS factor.
9
- public final class FontScaleStore: ObservableObject {
10
- public static let shared = FontScaleStore()
11
-
12
- @Published public private(set) var userScaleRate: CGFloat = 1
13
- @Published public private(set) var useOSScaleRate: Bool = true
14
- @Published public private(set) var osFontScale: CGFloat = FontScaleStore.currentOsFontScale()
15
-
16
- private init() {
17
- NotificationCenter.default.addObserver(
18
- self,
19
- selector: #selector(contentSizeCategoryDidChange),
20
- name: UIContentSizeCategory.didChangeNotification,
21
- object: nil
22
- )
23
- }
24
-
25
- public var snapshot: FontScale {
26
- FontScale(
27
- userScaleRate: userScaleRate,
28
- useOSScaleRate: useOSScaleRate,
29
- osFontScale: osFontScale
30
- )
31
- }
32
-
33
- public func update(userScaleRate: CGFloat, useOSScaleRate: Bool) {
34
- onMain {
35
- // Re-assigning an unchanged value would still publish and invalidate every kit subtree.
36
- if self.userScaleRate != userScaleRate { self.userScaleRate = userScaleRate }
37
- if self.useOSScaleRate != useOSScaleRate { self.useOSScaleRate = useOSScaleRate }
38
- }
39
- }
40
-
41
- @objc private func contentSizeCategoryDidChange() {
42
- let next = Self.currentOsFontScale()
43
- guard next != osFontScale else { return }
44
- osFontScale = next
45
- }
46
-
47
- /// Same factor Compose's iOS `ProvideOsFontScale` derives from
48
- /// `UIFont.preferredFont(forTextStyle: .body).pointSize / 17`, so both iOS surfaces scale alike.
49
- static func currentOsFontScale() -> CGFloat {
50
- let base = UIFont.systemFont(ofSize: UIFont.labelFontSize)
51
- return UIFontMetrics.default.scaledFont(for: base).pointSize / base.pointSize
52
- }
53
-
54
- /// Synchronous when already on main — an async hop would defer the change by a frame.
55
- private func onMain(_ work: @escaping () -> Void) {
56
- if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) }
57
- }
58
- }
@@ -1,17 +0,0 @@
1
- import Foundation
2
-
3
- /// Design-system rollout flag for the SwiftUI kit, mirroring Compose's `KitDesignSystem`.
4
- /// The kit is sandboxed and can't read the host config, so the host writes `update(_:)` once at
5
- /// startup from `DesignSystemConfig`. Startup-only and non-reactive, like `FontScaleStore` was
6
- /// before it gained live updates: there is no SwiftUI navigation container to re-render from.
7
- public final class KitDesignSystem {
8
- public static let shared = KitDesignSystem()
9
-
10
- public private(set) var isWhiteList: Bool = false
11
-
12
- private init() {}
13
-
14
- public func update(isWhiteList: Bool) {
15
- self.isWhiteList = isWhiteList
16
- }
17
- }