@momo-kits/native-kits 0.168.1-beta.2-debug → 0.168.1-beta.24-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 (62) hide show
  1. package/CLAUDE.md +10 -6
  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/components/PopupNotify.kt +25 -16
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +3 -3
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +2 -1
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +18 -14
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +25 -13
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +206 -45
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +14 -3
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +2 -2
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +53 -30
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +2 -3
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +124 -59
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +1 -1
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +3 -1
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +2 -2
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +10 -3
  23. package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +3 -1
  24. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +9 -8
  25. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +54 -10
  26. package/gradle.properties +1 -1
  27. package/ios/Application/ApplicationEnvironment.swift +7 -6
  28. package/ios/Application/DesignSystemKits.swift +163 -0
  29. package/ios/Application/DesignSystemKitsPayload.swift +68 -0
  30. package/ios/Badge/BadgeRibbon.swift +8 -1
  31. package/ios/Extensions/Color++.swift +2 -4
  32. package/ios/Input/Input.swift +13 -3
  33. package/ios/Input/InputPhoneNumber.swift +1 -3
  34. package/ios/Input/InputSearch.swift +1 -2
  35. package/ios/Input/InputTextArea.swift +5 -3
  36. package/ios/InputMoney/InputMoney.swift +6 -7
  37. package/ios/Layout/ScreenLayout.swift +65 -10
  38. package/ios/Navigation/BottomSheet.swift +153 -23
  39. package/ios/Navigation/BottomTab/DuoBarCompression.swift +2 -3
  40. package/ios/Navigation/BottomTab/DuoTabBar.swift +94 -69
  41. package/ios/Navigation/Component/HeaderBackground.swift +22 -1
  42. package/ios/Navigation/Component/SnackBar.swift +4 -0
  43. package/ios/Navigation/ModalScreen.swift +7 -3
  44. package/ios/Navigation/StackScreen.swift +3 -7
  45. package/ios/Popup/PopupDisplay.swift +2 -2
  46. package/ios/Popup/PopupInput.swift +2 -2
  47. package/ios/Popup/PopupLayout.swift +15 -10
  48. package/ios/Popup/PopupNotify.swift +2 -2
  49. package/ios/Stepper/Stepper.swift +1 -2
  50. package/ios/Template/TrustBanner/TrustBanner.swift +4 -2
  51. package/ios/Typography/FontScale.swift +29 -15
  52. package/ios/Typography/Text.swift +86 -12
  53. package/ios/Typography/Typography.swift +2 -2
  54. package/ios/native-kits.podspec +1 -1
  55. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  56. package/local.properties +8 -0
  57. package/package.json +1 -1
  58. package/.claude/settings.local.json +0 -9
  59. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitDesignSystem.kt +0 -18
  60. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitFontScale.kt +0 -22
  61. package/ios/Typography/FontScaleStore.swift +0 -58
  62. package/ios/Typography/KitDesignSystem.swift +0 -17
@@ -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
  }
@@ -21,6 +21,8 @@ import androidx.compose.ui.backhandler.BackHandler as ComposeBackHandler
21
21
  import androidx.compose.ui.graphics.Color
22
22
  import androidx.compose.ui.graphics.NativePaint
23
23
  import androidx.compose.ui.graphics.toArgb
24
+ import androidx.compose.ui.platform.LocalDensity
25
+ import androidx.compose.ui.platform.LocalWindowInfo
24
26
  import androidx.compose.ui.unit.Dp
25
27
  import androidx.compose.ui.unit.dp
26
28
  import androidx.compose.ui.viewinterop.UIKitInteropProperties
@@ -29,8 +31,6 @@ import cocoapods.lottie_ios.CompatibleAnimation
29
31
  import cocoapods.lottie_ios.CompatibleAnimationKeypath
30
32
  import cocoapods.lottie_ios.CompatibleAnimationView
31
33
  import kotlinx.cinterop.ExperimentalForeignApi
32
- import kotlinx.cinterop.get
33
- import kotlinx.cinterop.memScoped
34
34
  import androidx.navigationevent.NavigationEventDispatcher
35
35
  import androidx.navigationevent.NavigationEventDispatcherOwner
36
36
  import org.jetbrains.skia.FilterBlurMode
@@ -38,7 +38,6 @@ import org.jetbrains.skia.MaskFilter
38
38
  import platform.Foundation.NSBundle
39
39
  import platform.UIKit.UIColor
40
40
  import platform.UIKit.UIDevice
41
- import platform.UIKit.UIScreen
42
41
  import platform.UIKit.UIUserInterfaceIdiomPhone
43
42
  import vn.momo.kits.navigation.AttachHostBackGesture
44
43
  import vn.momo.kits.navigation.Navigator
@@ -48,15 +47,17 @@ actual fun getPlatformName(): String = "iOS"
48
47
  actual fun isPhoneIdiom(): Boolean =
49
48
  UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPhone
50
49
 
51
- @OptIn(ExperimentalForeignApi::class)
50
+ // AI-GENERATED START: window size, not UIScreen.main (stays on one display on iPhone Duo); recomposes on fold/unfold
52
51
  @Composable
53
- actual fun getScreenDimensions(): ScreenDimension = memScoped {
54
- val config = UIScreen.mainScreen.bounds.ptr[0]
52
+ actual fun getScreenDimensions(): ScreenDimension {
53
+ val density = LocalDensity.current
54
+ val size = LocalWindowInfo.current.containerSize
55
55
  return ScreenDimension(
56
- config.size.width.toInt(),
57
- config.size.height.toInt()
56
+ (size.width / density.density).toInt(),
57
+ (size.height / density.density).toInt()
58
58
  )
59
59
  }
60
+ // AI-GENERATED END: window size, not UIScreen.main (stays on one display on iPhone Duo); recomposes on fold/unfold
60
61
 
61
62
  actual fun NativePaint.setMaskFilter(blurRadius: Float) {
62
63
  this.maskFilter = MaskFilter.makeBlur(FilterBlurMode.NORMAL, blurRadius / 2, true)
@@ -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.167.1-duo.15
21
+ version=0.168.1-beta.24
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