@momo-kits/native-kits 0.168.1-beta.1-debug → 0.168.1-beta.10-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 (49) hide show
  1. package/CLAUDE.md +7 -5
  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/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +1 -1
  6. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +0 -14
  7. package/compose/src/commonMain/kotlin/vn/momo/kits/application/DesignSystemKits.kt +62 -0
  8. package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +2 -1
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +18 -14
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +9 -12
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +2 -2
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +2 -3
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +124 -59
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +1 -1
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +3 -1
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +2 -2
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +10 -3
  18. package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +3 -1
  19. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +54 -10
  20. package/gradle.properties +1 -1
  21. package/ios/Application/ApplicationEnvironment.swift +7 -6
  22. package/ios/Application/DesignSystemKits.swift +100 -0
  23. package/ios/Application/DesignSystemKitsPayload.swift +68 -0
  24. package/ios/Badge/BadgeRibbon.swift +8 -1
  25. package/ios/Extensions/Color++.swift +2 -4
  26. package/ios/Input/Input.swift +13 -3
  27. package/ios/Input/InputPhoneNumber.swift +1 -3
  28. package/ios/Input/InputSearch.swift +1 -2
  29. package/ios/Input/InputTextArea.swift +5 -3
  30. package/ios/InputMoney/InputMoney.swift +6 -7
  31. package/ios/Layout/ScreenLayout.swift +46 -11
  32. package/ios/Navigation/BottomTab/DuoBarCompression.swift +2 -3
  33. package/ios/Navigation/BottomTab/DuoTabBar.swift +94 -69
  34. package/ios/Navigation/Component/HeaderBackground.swift +22 -1
  35. package/ios/Navigation/StackScreen.swift +3 -7
  36. package/ios/Popup/PopupLayout.swift +0 -2
  37. package/ios/Stepper/Stepper.swift +1 -2
  38. package/ios/Template/TrustBanner/TrustBanner.swift +4 -2
  39. package/ios/Typography/FontScale.swift +14 -11
  40. package/ios/Typography/Text.swift +86 -12
  41. package/ios/Typography/Typography.swift +2 -2
  42. package/ios/native-kits.podspec +1 -1
  43. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  44. package/package.json +1 -1
  45. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitDesignSystem.kt +0 -18
  46. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitFontScale.kt +0 -22
  47. package/ios/Typography/FontScaleStore.swift +0 -58
  48. package/ios/Typography/KitDesignSystem.swift +0 -17
  49. package/local.properties +0 -8
@@ -2,8 +2,12 @@ package vn.momo.kits.navigation.bottomtab
2
2
 
3
3
  import androidx.compose.animation.core.Spring
4
4
  import androidx.compose.animation.core.animateDpAsState
5
+ import androidx.compose.animation.core.animateFloatAsState
5
6
  import androidx.compose.animation.core.spring
6
7
  import androidx.compose.foundation.background
8
+ import androidx.compose.foundation.clickable
9
+ import androidx.compose.foundation.interaction.MutableInteractionSource
10
+ import androidx.compose.foundation.interaction.collectIsPressedAsState
7
11
  import androidx.compose.foundation.border
8
12
  import androidx.compose.foundation.layout.Arrangement
9
13
  import androidx.compose.foundation.layout.Box
@@ -15,6 +19,7 @@ import androidx.compose.foundation.layout.RowScope
15
19
  import androidx.compose.foundation.layout.absolutePadding
16
20
  import androidx.compose.foundation.layout.fillMaxHeight
17
21
  import androidx.compose.foundation.layout.fillMaxSize
22
+ import androidx.compose.foundation.layout.fillMaxWidth
18
23
  import androidx.compose.foundation.layout.height
19
24
  import androidx.compose.foundation.layout.offset
20
25
  import androidx.compose.foundation.layout.padding
@@ -23,25 +28,33 @@ import androidx.compose.foundation.layout.width
23
28
  import androidx.compose.foundation.shape.CircleShape
24
29
  import androidx.compose.foundation.shape.RoundedCornerShape
25
30
  import androidx.compose.runtime.Composable
31
+ import androidx.compose.runtime.CompositionLocalProvider
32
+ import androidx.compose.runtime.LaunchedEffect
33
+ import androidx.compose.runtime.MutableState
26
34
  import androidx.compose.runtime.compositionLocalOf
27
35
  import androidx.compose.runtime.getValue
36
+ import androidx.compose.runtime.mutableStateOf
37
+ import androidx.compose.runtime.remember
28
38
  import androidx.compose.ui.AbsoluteAlignment
29
39
  import androidx.compose.ui.Alignment
30
40
  import androidx.compose.ui.Modifier
31
41
  import androidx.compose.ui.draw.clip
32
42
  import androidx.compose.ui.draw.shadow
43
+ import androidx.compose.ui.graphics.graphicsLayer
33
44
  import androidx.compose.ui.graphics.Color
34
45
  import androidx.compose.ui.graphics.Shape
46
+ import androidx.compose.ui.semantics.Role
35
47
  import androidx.compose.ui.text.style.TextOverflow
36
48
  import androidx.compose.ui.unit.Dp
37
49
  import androidx.compose.ui.unit.dp
50
+ import vn.momo.kits.application.LocalContext
51
+ import vn.momo.kits.application.MiniAppContext
38
52
  import vn.momo.kits.components.Badge
39
53
  import vn.momo.kits.components.BadgeDot
40
54
  import vn.momo.kits.components.DotSize
41
55
  import vn.momo.kits.components.Icon
42
56
  import vn.momo.kits.components.Text
43
57
  import vn.momo.kits.const.AppTheme
44
- import vn.momo.kits.const.Radius
45
58
  import vn.momo.kits.const.Spacing
46
59
  import vn.momo.kits.const.Typography
47
60
  import vn.momo.kits.layout.DuoMetrics
@@ -59,14 +72,18 @@ private val RAIL_GAP = Spacing.XXS
59
72
  // selection bubble overflows the 48 rail cell by 5 above and below
60
73
  private val RAIL_BUBBLE_WIDTH = 44.dp
61
74
  private val RAIL_BUBBLE_HEIGHT = 58.dp
62
- private val FLOATING_HEIGHT = 62.dp
75
+ private val FLOATING_HEIGHT = DuoMetrics.floatingTabBarHeight
63
76
  private val FLOATING_PADDING = Spacing.XS
64
- private val FLOATING_QR_SIZE = 44.dp
65
- // floating bar spans 6 of the 8 portrait grid columns, like the content
66
- private const val GRID_COLUMNS = 8
67
- private const val GRID_SPAN = 6
77
+ // the system bar sizes its pill to the tabs instead of stretching it across the display
78
+ private val FLOATING_ITEM_WIDTH = 80.dp
79
+ // while pressed the selection bubble grows into a lens past the pill's edges
80
+ private const val LENS_SCALE_X = 1.2f
81
+ private const val LENS_SCALE_Y = 1.3f
82
+ // Figma Home-2026 tab bar "Selection": brand pink at 8%
83
+ private const val SELECTION_TINT_ALPHA = 0x14 / 255f
68
84
  // RN reanimated spring { damping: 18, stiffness: 220, mass: 0.8 }
69
85
  private val BUBBLE_SPRING = spring<Dp>(dampingRatio = 0.68f, stiffness = 275f, visibilityThreshold = Dp(0.5f))
86
+ private val LENS_SPRING = spring<Float>(dampingRatio = 0.68f, stiffness = 275f)
70
87
 
71
88
  // Space from the window bottom the rail stack and its gap take, so the header side bar stays above it.
72
89
  internal val LocalDuoRailReserve = compositionLocalOf { 0.dp }
@@ -78,11 +95,15 @@ internal fun duoRailHeight(itemCount: Int, hasQr: Boolean, compressed: Boolean =
78
95
  return rail + if (hasQr) Spacing.M + ITEM_SIZE else 0.dp
79
96
  }
80
97
 
98
+ // Overlaps the home indicator inset like the system tab bar (21pt above the bottom with a 34pt inset).
99
+ private fun floatingTabBarBottom(insetBottom: Dp): Dp =
100
+ maxOf(insetBottom - DuoMetrics.floatingTabBarInsetOverlap, Spacing.S)
101
+
81
102
  // Space tab content leaves under the floating bar; 0 when the rail sits in the side strip or off Duo.
82
103
  internal fun duoFloatingTabBarInset(layout: ScreenLayout): Dp =
83
- if (layout.isIPhoneDuo && layout.sideBar == null) FLOATING_HEIGHT + Spacing.M + layout.insetBottom else 0.dp
104
+ if (layout.isIPhoneDuo && layout.sideBar == null) FLOATING_HEIGHT + floatingTabBarBottom(layout.insetBottom) else 0.dp
84
105
 
85
- // iPhone Duo tab bar: glass rail in the side bar, or a floating glass bar when unfolded portrait.
106
+ // iPhone Duo tab bar: rail in the side bar, or a floating bar when unfolded portrait.
86
107
  @Composable
87
108
  internal fun BoxScope.DuoTabBar(
88
109
  layout: ScreenLayout,
@@ -147,18 +168,19 @@ private fun BoxScope.DuoRailTabBar(
147
168
  }
148
169
  }
149
170
 
150
- // Glass column of tab items with the sliding selection bubble.
171
+ // Column of icon-only tab items with the sliding selection bubble.
151
172
  @Composable
152
173
  private fun Rail(items: List<BottomTabItem>, selectedIndex: Int, onItemClick: (Int) -> Unit) {
174
+ val pressedIndex = remember { mutableStateOf<Int?>(null) }
153
175
  val bubbleOffset by animateDpAsState(
154
- targetValue = (ITEM_SIZE + RAIL_GAP) * selectedIndex.coerceAtLeast(0),
176
+ targetValue = (ITEM_SIZE + RAIL_GAP) * (pressedIndex.value ?: selectedIndex).coerceAtLeast(0),
155
177
  animationSpec = BUBBLE_SPRING,
156
178
  label = "DuoRailBubble",
157
179
  )
158
180
  Box(
159
181
  Modifier
160
182
  .width(ITEM_SIZE)
161
- .tabBarMaterial(RoundedCornerShape(Radius.XL))
183
+ .tabBarMaterial(CircleShape)
162
184
  .setAutomationId("bottom_tab_bar")
163
185
  ) {
164
186
  Box(
@@ -168,7 +190,7 @@ private fun Rail(items: List<BottomTabItem>, selectedIndex: Int, onItemClick: (I
168
190
  y = RAIL_PADDING - (RAIL_BUBBLE_HEIGHT - ITEM_SIZE) / 2 + bubbleOffset,
169
191
  )
170
192
  .size(RAIL_BUBBLE_WIDTH, RAIL_BUBBLE_HEIGHT)
171
- .background(AppTheme.current.colors.background.selected, CircleShape)
193
+ .selectionLens(pressedIndex.value != null)
172
194
  )
173
195
  Column(
174
196
  modifier = Modifier.padding(vertical = RAIL_PADDING),
@@ -178,7 +200,7 @@ private fun Rail(items: List<BottomTabItem>, selectedIndex: Int, onItemClick: (I
178
200
  Box(
179
201
  modifier = Modifier
180
202
  .size(ITEM_SIZE)
181
- .noFeedbackClickable { onItemClick(index) },
203
+ .tabClickable(index, pressedIndex) { onItemClick(index) },
182
204
  contentAlignment = Alignment.Center,
183
205
  ) {
184
206
  DuoTabIcon(item, selected = index == selectedIndex)
@@ -188,6 +210,7 @@ private fun Rail(items: List<BottomTabItem>, selectedIndex: Int, onItemClick: (I
188
210
  }
189
211
  }
190
212
 
213
+ // Floating labelled bar for the unfolded portrait display, QR pinned in its own platter beside it.
191
214
  @Composable
192
215
  private fun BoxScope.DuoFloatingTabBar(
193
216
  layout: ScreenLayout,
@@ -196,73 +219,115 @@ private fun BoxScope.DuoFloatingTabBar(
196
219
  selectedIndex: Int,
197
220
  onTabSelected: (Int) -> Unit,
198
221
  ) {
199
- val columnWidth = (layout.width - Spacing.M * 2 - Spacing.M * (GRID_COLUMNS - 1)) / GRID_COLUMNS
200
- val barWidth = columnWidth * GRID_SPAN + Spacing.M * (GRID_SPAN - 1)
201
- val useQr = floatingButton != null && items.size % 2 == 0
202
- val middle = items.size / 2
203
- val cellCount = items.size + if (useQr) 1 else 0
204
- val visualIndex = if (useQr && selectedIndex >= middle) selectedIndex + 1 else selectedIndex
222
+ val pressedIndex = remember { mutableStateOf<Int?>(null) }
205
223
 
206
- Box(
224
+ Row(
207
225
  modifier = Modifier
208
226
  .align(Alignment.BottomCenter)
209
- .padding(bottom = layout.insetBottom + Spacing.M)
210
- .width(barWidth)
211
- .height(FLOATING_HEIGHT)
212
- .tabBarMaterial(RoundedCornerShape(FLOATING_HEIGHT / 2))
213
- .padding(FLOATING_PADDING)
214
- .setAutomationId("bottom_tab_bar")
215
- ) {
216
- BoxWithConstraints(Modifier.fillMaxSize()) {
217
- val cellWidth = maxWidth / cellCount
218
- val bubbleOffset by animateDpAsState(
219
- targetValue = cellWidth * visualIndex.coerceAtLeast(0),
220
- animationSpec = BUBBLE_SPRING,
221
- label = "DuoFloatingBubble",
227
+ .fillMaxWidth()
228
+ .absolutePadding(
229
+ left = layout.insetLeft + DuoMetrics.floatingTabBarMargin,
230
+ right = layout.insetRight + DuoMetrics.floatingTabBarMargin,
222
231
  )
223
- Box(
224
- Modifier
225
- .offset(x = bubbleOffset)
226
- .width(cellWidth)
227
- .fillMaxHeight()
228
- .background(AppTheme.current.colors.background.selected, CircleShape)
229
- )
230
- Row(Modifier.fillMaxSize()) {
231
- items.forEachIndexed { index, item ->
232
- if (useQr && index == middle) {
233
- Box(Modifier.weight(1f).fillMaxHeight(), contentAlignment = Alignment.Center) {
234
- QrButton(floatingButton, size = FLOATING_QR_SIZE)
232
+ .padding(bottom = floatingTabBarBottom(layout.insetBottom)),
233
+ horizontalArrangement = Arrangement.spacedBy(Spacing.S, Alignment.CenterHorizontally),
234
+ ) {
235
+ Box(
236
+ modifier = Modifier
237
+ .weight(1f, fill = false)
238
+ .width(FLOATING_ITEM_WIDTH * items.size + FLOATING_PADDING * 2)
239
+ .height(FLOATING_HEIGHT)
240
+ .tabBarMaterial(RoundedCornerShape(FLOATING_HEIGHT / 2))
241
+ .padding(FLOATING_PADDING)
242
+ .setAutomationId("bottom_tab_bar")
243
+ ) {
244
+ BoxWithConstraints(Modifier.fillMaxSize()) {
245
+ val cellWidth = maxWidth / items.size.coerceAtLeast(1)
246
+ val bubbleOffset by animateDpAsState(
247
+ targetValue = cellWidth * (pressedIndex.value ?: selectedIndex).coerceAtLeast(0),
248
+ animationSpec = BUBBLE_SPRING,
249
+ label = "DuoFloatingBubble",
250
+ )
251
+ Box(
252
+ Modifier
253
+ .offset(x = bubbleOffset)
254
+ .width(cellWidth)
255
+ .fillMaxHeight()
256
+ .selectionLens(pressedIndex.value != null)
257
+ )
258
+ Row(Modifier.fillMaxSize()) {
259
+ items.forEachIndexed { index, item ->
260
+ FloatingCell(item, selected = index == selectedIndex, index, pressedIndex) {
261
+ onTabSelected(index)
235
262
  }
236
263
  }
237
- FloatingCell(item, selected = index == selectedIndex) { onTabSelected(index) }
238
264
  }
239
265
  }
240
266
  }
267
+ if (floatingButton != null) {
268
+ QrButton(floatingButton, size = FLOATING_HEIGHT, Modifier.shadow(3.dp, CircleShape))
269
+ }
241
270
  }
242
271
  }
243
272
 
244
273
  @Composable
245
- private fun RowScope.FloatingCell(item: BottomTabItem, selected: Boolean, onClick: () -> Unit) {
274
+ private fun RowScope.FloatingCell(
275
+ item: BottomTabItem,
276
+ selected: Boolean,
277
+ index: Int,
278
+ pressedIndex: MutableState<Int?>,
279
+ onClick: () -> Unit,
280
+ ) {
246
281
  val colors = AppTheme.current.colors
247
282
  Column(
248
283
  modifier = Modifier
249
284
  .weight(1f)
250
285
  .fillMaxHeight()
251
- .noFeedbackClickable(onClick = onClick),
286
+ .tabClickable(index, pressedIndex, onClick),
252
287
  horizontalAlignment = Alignment.CenterHorizontally,
253
288
  verticalArrangement = Arrangement.spacedBy(Spacing.XXS, Alignment.CenterVertically),
254
289
  ) {
255
290
  DuoTabIcon(item, selected)
256
- Text(
257
- text = item.label,
258
- style = Typography.labelXsMedium,
259
- color = if (selected) colors.primary else colors.text.default,
260
- maxLines = 1,
261
- overflow = TextOverflow.Ellipsis,
262
- )
291
+ // like the system tab bar the label keeps its design size; Text grows it up to 5pt on the wide Duo displays
292
+ val context = LocalContext.current
293
+ val fixedScale = remember(context) {
294
+ (context ?: MiniAppContext()).copy(userScaleRate = 1f, useOSScaleRate = false)
295
+ }
296
+ CompositionLocalProvider(LocalContext provides fixedScale) {
297
+ Text(
298
+ text = item.label,
299
+ style = Typography.labelXsMedium,
300
+ color = if (selected) colors.primary else colors.text.hint,
301
+ maxLines = 1,
302
+ overflow = TextOverflow.Ellipsis,
303
+ )
304
+ }
263
305
  }
264
306
  }
265
307
 
308
+ // Tracks the finger so the selection lens jumps to the pressed tab before it is selected.
309
+ @Composable
310
+ private fun Modifier.tabClickable(index: Int, pressedIndex: MutableState<Int?>, onClick: () -> Unit): Modifier {
311
+ val interactionSource = remember { MutableInteractionSource() }
312
+ val pressed by interactionSource.collectIsPressedAsState()
313
+ LaunchedEffect(pressed) {
314
+ if (pressed) pressedIndex.value = index
315
+ else if (pressedIndex.value == index) pressedIndex.value = null
316
+ }
317
+ return clickable(interactionSource = interactionSource, indication = null, role = Role.Tab, onClick = onClick)
318
+ }
319
+
320
+ // Selection bubble; while pressed it grows into a lens (80x54 to about 96x70).
321
+ @Composable
322
+ private fun Modifier.selectionLens(pressed: Boolean): Modifier {
323
+ val lens by animateFloatAsState(if (pressed) 1f else 0f, LENS_SPRING, label = "DuoSelectionLens")
324
+ val tint = AppTheme.current.colors.primary.copy(alpha = SELECTION_TINT_ALPHA)
325
+ return graphicsLayer {
326
+ scaleX = 1 + lens * (LENS_SCALE_X - 1)
327
+ scaleY = 1 + lens * (LENS_SCALE_Y - 1)
328
+ }.background(tint, CircleShape)
329
+ }
330
+
266
331
  @Composable
267
332
  private fun DuoTabIcon(item: BottomTabItem, selected: Boolean) {
268
333
  val colors = AppTheme.current.colors
@@ -271,7 +336,7 @@ private fun DuoTabIcon(item: BottomTabItem, selected: Boolean) {
271
336
  Icon(
272
337
  source = item.icon,
273
338
  size = ICON_SIZE,
274
- color = if (selected) colors.primary else colors.text.default,
339
+ color = if (selected) colors.primary else colors.text.hint,
275
340
  )
276
341
  when {
277
342
  badgeLabel == null && !item.showDot -> Unit
@@ -288,9 +353,9 @@ private fun DuoTabIcon(item: BottomTabItem, selected: Boolean) {
288
353
  }
289
354
 
290
355
  @Composable
291
- private fun QrButton(data: BottomTabFloatingButton, size: Dp) {
356
+ private fun QrButton(data: BottomTabFloatingButton, size: Dp, modifier: Modifier = Modifier) {
292
357
  Box(
293
- modifier = Modifier
358
+ modifier = modifier
294
359
  .size(size)
295
360
  .clip(CircleShape)
296
361
  .background(AppTheme.current.colors.primary)
@@ -302,7 +367,7 @@ private fun QrButton(data: BottomTabFloatingButton, size: Dp) {
302
367
  }
303
368
  }
304
369
 
305
- // RN falls back to this when native glass is missing; Compose has no backdrop blur, so it always does.
370
+ // RN BarMaterial: surface with a 1dp border.
306
371
  @Composable
307
372
  private fun Modifier.tabBarMaterial(shape: Shape): Modifier {
308
373
  val colors = AppTheme.current.colors
@@ -97,7 +97,7 @@ internal fun headerTitleMaxWidth(): Dp {
97
97
  // Scroll distance over which the Duo glass bar fades in.
98
98
  private val DUO_GLASS_FADE_DISTANCE = 16.dp
99
99
 
100
- // RN DuoGlassBackground fallback: translucent surface with a hairline once content scrolls under it.
100
+ // RN DuoGlassBackground: translucent surface with a hairline once content scrolls under it.
101
101
  @Suppress("FrequentlyChangingValue")
102
102
  @Composable
103
103
  private fun Modifier.duoGlassBar(): Modifier {
@@ -90,7 +90,9 @@ internal fun HeaderBackground() {
90
90
  .fillMaxWidth()
91
91
  .then(if (fillsBox && !theme.isHeaderImage()) Modifier.fillMaxHeight() else Modifier.height(maxHeight))
92
92
  .conditional(!theme.isHeaderImage()) {
93
- background(Brush.verticalGradient(colors = listOf(color, backgroundColor)))
93
+ // RN spans the gradient over 375:154 and clips it, so a default header shows only its top
94
+ val endY = if (fillsBox) Float.POSITIVE_INFINITY else with(density) { maxHeight.toPx() }
95
+ background(Brush.verticalGradient(colors = listOf(color, backgroundColor), endY = endY))
94
96
  }
95
97
  ){
96
98
  if (!theme.isHeaderImage()) return@Box
@@ -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(
@@ -7,7 +7,14 @@ import androidx.compose.ui.unit.Dp
7
7
  @Immutable
8
8
  internal data class ReservedRegion(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
9
9
 
10
- // Regions the system occludes (iOS 27.1+), in the coordinates of the Compose root view: the
11
- // iPhone Duo camera, and the status bar cluster as it grows for a call or a Live Activity.
10
+ // occlusion: regions the system occludes (iOS 27.1+), in the coordinates of the Compose root view:
11
+ // the iPhone Duo camera, and the status bar cluster as it grows for a call or a Live Activity.
12
+ // isFoldable: the device reports a `.division` (hinge) region, Apple's foldable check.
13
+ @Immutable
14
+ internal data class ReservedRegions(
15
+ val occlusion: List<ReservedRegion> = emptyList(),
16
+ val isFoldable: Boolean = false,
17
+ )
18
+
12
19
  @Composable
13
- internal expect fun rememberReservedRegions(): List<ReservedRegion>
20
+ internal expect fun rememberReservedRegions(): ReservedRegions
@@ -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
  }
@@ -9,9 +9,15 @@ import androidx.compose.runtime.setValue
9
9
  import androidx.compose.ui.uikit.LocalUIViewController
10
10
  import androidx.compose.ui.unit.dp
11
11
  import kotlinx.cinterop.BetaInteropApi
12
+ import kotlinx.cinterop.CFunction
13
+ import kotlinx.cinterop.COpaquePointer
12
14
  import kotlinx.cinterop.ExperimentalForeignApi
15
+ import kotlinx.cinterop.interpretCPointer
13
16
  import kotlinx.cinterop.interpretObjCPointer
17
+ import kotlinx.cinterop.interpretObjCPointerOrNull
18
+ import kotlinx.cinterop.invoke
14
19
  import kotlinx.cinterop.objcPtr
20
+ import kotlinx.cinterop.reinterpret
15
21
  import kotlinx.cinterop.readValue
16
22
  import kotlinx.cinterop.useContents
17
23
  import platform.CoreGraphics.CGRectZero
@@ -25,18 +31,54 @@ import platform.UIKit.UIViewAutoresizingFlexibleHeight
25
31
  import platform.UIKit.UIViewAutoresizingFlexibleWidth
26
32
  import platform.darwin.NSObject
27
33
  import platform.Foundation.NSClassFromString
34
+ import platform.posix.RTLD_DEFAULT
35
+ import platform.posix.dlsym
28
36
 
29
37
  // The API is newer than the SDK Kotlin/Native binds, so it is reached through the runtime.
30
38
  @OptIn(ExperimentalForeignApi::class)
31
39
  private val regionsOfKind = NSSelectorFromString("reservedRegionsOfKind:")
32
40
 
41
+ @OptIn(ExperimentalForeignApi::class)
42
+ private val regionsOfKindOptions = NSSelectorFromString("reservedRegionsOfKind:options:")
43
+
44
+ // a hinge never goes away, so a positive answer is kept for every later container
45
+ private var isKnownFoldable = false
46
+
33
47
  @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
34
- private fun UIView.occlusionRegions(target: UIView): List<ReservedRegion> {
35
- if (window == null || !respondsToSelector(regionsOfKind)) return emptyList()
48
+ private fun regionKind(name: String): Any? {
36
49
  // a class object fails `as? NSObjectProtocol` (K/N checks it as an instance), so it is reinterpreted
37
50
  val kindClass = NSClassFromString("UIViewReservedRegionKind")
38
- ?.let { interpretObjCPointer<NSObject>(it.objcPtr()) } ?: return emptyList()
39
- val kind = kindClass.performSelector(NSSelectorFromString("occlusionRegionKind")) ?: return emptyList()
51
+ ?.let { interpretObjCPointer<NSObject>(it.objcPtr()) } ?: return null
52
+ return kindClass.performSelector(NSSelectorFromString(name))
53
+ }
54
+
55
+ // performSelector cannot pass the NSUInteger options, so objc_msgSend is called with the method's own signature.
56
+ @OptIn(ExperimentalForeignApi::class)
57
+ private val msgSendRegionsOfKindOptions = dlsym(RTLD_DEFAULT, "objc_msgSend")
58
+ ?.reinterpret<CFunction<(COpaquePointer?, COpaquePointer?, COpaquePointer?, ULong) -> COpaquePointer?>>()
59
+
60
+ // Apple's foldable check: only a device with a hinge reports a `.division` region, inactive while
61
+ // flat (UIViewReservedRegionQueryOptionsIncludeInactive) but still there. Screen size cannot tell it
62
+ // apart from a large iPhone in landscape or a narrowed window.
63
+ @OptIn(ExperimentalForeignApi::class)
64
+ private fun UIView.hasDivisionRegion(): Boolean {
65
+ if (window == null || !respondsToSelector(regionsOfKindOptions)) return false
66
+ val msgSend = msgSendRegionsOfKindOptions ?: return false
67
+ val kind = regionKind("divisionRegionKind") as? NSObject ?: return false
68
+ val result = msgSend(
69
+ interpretCPointer(objcPtr()),
70
+ regionsOfKindOptions,
71
+ interpretCPointer(kind.objcPtr()),
72
+ 1uL,
73
+ )
74
+ val regions = interpretObjCPointerOrNull<NSArray>(result?.rawValue ?: return false)
75
+ return (regions?.count ?: 0uL) > 0uL
76
+ }
77
+
78
+ @OptIn(ExperimentalForeignApi::class)
79
+ private fun UIView.occlusionRegions(target: UIView): List<ReservedRegion> {
80
+ if (window == null || !respondsToSelector(regionsOfKind)) return emptyList()
81
+ val kind = regionKind("occlusionRegionKind") ?: return emptyList()
40
82
  val regions = when (val result = performSelector(regionsOfKind, withObject = kind)) {
41
83
  is List<*> -> result
42
84
  is NSArray -> List(result.count.toInt()) { result.objectAtIndex(it.toULong()) }
@@ -54,9 +96,9 @@ private fun UIView.occlusionRegions(target: UIView): List<ReservedRegion> {
54
96
  // when they change, so nothing polls.
55
97
  @OptIn(ExperimentalForeignApi::class)
56
98
  private class ReservedRegionsProbe(
57
- private val onChange: (List<ReservedRegion>) -> Unit,
99
+ private val onChange: (ReservedRegions) -> Unit,
58
100
  ) : UIView(frame = CGRectZero.readValue()) {
59
- private var last: List<ReservedRegion>? = null
101
+ private var last: ReservedRegions? = null
60
102
 
61
103
  init {
62
104
  userInteractionEnabled = false
@@ -65,7 +107,9 @@ private class ReservedRegionsProbe(
65
107
 
66
108
  override fun layoutSubviews() {
67
109
  super.layoutSubviews()
68
- val regions = superview?.let { occlusionRegions(it) } ?: emptyList()
110
+ // re-read until found: the window may not report the hinge on the first pass
111
+ if (!isKnownFoldable) isKnownFoldable = hasDivisionRegion()
112
+ val regions = ReservedRegions(superview?.let { occlusionRegions(it) } ?: emptyList(), isKnownFoldable)
69
113
  if (regions != last) {
70
114
  last = regions
71
115
  onChange(regions)
@@ -80,10 +124,10 @@ private class ReservedRegionsProbe(
80
124
 
81
125
  @OptIn(ExperimentalForeignApi::class)
82
126
  @Composable
83
- internal actual fun rememberReservedRegions(): List<ReservedRegion> {
84
- if (!isPhoneIdiom()) return emptyList()
127
+ internal actual fun rememberReservedRegions(): ReservedRegions {
128
+ if (!isPhoneIdiom()) return ReservedRegions()
85
129
  val host = LocalUIViewController.current.view
86
- var regions by remember { mutableStateOf(emptyList<ReservedRegion>()) }
130
+ var regions by remember { mutableStateOf(ReservedRegions(isFoldable = isKnownFoldable)) }
87
131
  DisposableEffect(host) {
88
132
  val probe = ReservedRegionsProbe { regions = it }
89
133
  probe.setFrame(host.bounds)
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.168.1-beta.1
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