@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
@@ -6,7 +6,9 @@ import androidx.compose.animation.core.tween
6
6
  import androidx.compose.foundation.background
7
7
  import androidx.compose.foundation.border
8
8
  import androidx.compose.foundation.gestures.detectDragGestures
9
+ import androidx.compose.foundation.layout.Arrangement
9
10
  import androidx.compose.foundation.layout.Box
11
+ import androidx.compose.foundation.layout.BoxScope
10
12
  import androidx.compose.foundation.layout.BoxWithConstraints
11
13
  import androidx.compose.foundation.layout.Column
12
14
  import androidx.compose.foundation.layout.Row
@@ -17,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
17
19
  import androidx.compose.foundation.layout.height
18
20
  import androidx.compose.foundation.layout.heightIn
19
21
  import androidx.compose.foundation.layout.offset
22
+ import androidx.compose.foundation.layout.PaddingValues
20
23
  import androidx.compose.foundation.layout.padding
21
24
  import androidx.compose.foundation.layout.size
22
25
  import androidx.compose.foundation.layout.width
@@ -25,15 +28,22 @@ import androidx.compose.foundation.shape.RoundedCornerShape
25
28
  import androidx.compose.runtime.Composable
26
29
  import androidx.compose.runtime.DisposableEffect
27
30
  import androidx.compose.runtime.LaunchedEffect
31
+ import androidx.compose.runtime.getValue
32
+ import androidx.compose.runtime.mutableStateOf
28
33
  import androidx.compose.runtime.remember
34
+ import androidx.compose.runtime.setValue
29
35
  import androidx.compose.runtime.rememberCoroutineScope
36
+ import androidx.compose.ui.AbsoluteAlignment
30
37
  import androidx.compose.ui.Alignment
31
38
  import androidx.compose.ui.Modifier
32
39
  import androidx.compose.ui.graphics.Brush
33
40
  import androidx.compose.ui.graphics.Color
34
41
  import androidx.compose.ui.input.pointer.pointerInput
42
+ import androidx.compose.ui.layout.layout
35
43
  import androidx.compose.ui.platform.LocalDensity
44
+ import androidx.compose.ui.unit.Dp
36
45
  import androidx.compose.ui.unit.IntOffset
46
+ import androidx.compose.ui.unit.constrainHeight
37
47
  import androidx.compose.ui.unit.dp
38
48
  import kotlinx.coroutines.launch
39
49
  import vn.momo.kits.components.Divider
@@ -52,11 +62,24 @@ import vn.momo.kits.modifier.conditional
52
62
  import vn.momo.kits.modifier.noFeedbackClickable
53
63
  import vn.momo.kits.platform.BackHandler
54
64
  import vn.momo.kits.platform.getScreenHeight
65
+ import vn.momo.kits.layout.DuoMetrics
66
+ import vn.momo.kits.layout.SideBarEdge
67
+ import vn.momo.kits.layout.SideBarLayout
55
68
  import vn.momo.kits.layout.getScreenLayout
69
+ import vn.momo.kits.modifier.setAutomationId
70
+ import vn.momo.kits.navigation.component.HeaderType
71
+ import vn.momo.kits.navigation.component.SideBarButton
72
+ import vn.momo.kits.navigation.component.getHeaderColor
56
73
 
57
74
  // iPhone Duo inner display: center the sheet instead of spanning the width (RN BottomSheet).
58
75
  private val DUO_REGULAR_MIN_WIDTH = 600.dp
59
76
 
77
+ private val INDICATOR_HEIGHT = 16.dp
78
+ private val HEADER_ROW_HEIGHT = 56.dp
79
+
80
+ // Side column controls line up with the header row while the sheet is short.
81
+ private val SIDE_COLUMN_OFFSET = INDICATOR_HEIGHT + (HEADER_ROW_HEIGHT - DuoMetrics.controlSize) / 2
82
+
60
83
  @Composable
61
84
  internal fun BottomSheet(
62
85
  content: @Composable () -> Unit,
@@ -70,7 +93,21 @@ internal fun BottomSheet(
70
93
  ) {
71
94
  val layout = getScreenLayout()
72
95
  val isDuo = layout.isIPhoneDuoDevice
73
- val isDuoCentered = isDuo && layout.width >= DUO_REGULAR_MIN_WIDTH
96
+ val isDuoCentered = isDuo && minOf(layout.width, layout.height) >= DUO_REGULAR_MIN_WIDTH
97
+ val isDuoFloating = isDuo && !isDuoCentered
98
+ val title = header as? BottomHeader.Title
99
+ val sideColumn = layout.sideBar?.takeIf {
100
+ isDuoFloating && title != null && it.width - Spacing.M >= DuoMetrics.controlSize + Spacing.M
101
+ }
102
+ // the floating sheet spans the side safe area, its content stays clear of it
103
+ val sidePadding = if (isDuoFloating) {
104
+ PaddingValues.Absolute(
105
+ left = (layout.insetLeft - Spacing.M).coerceAtLeast(0.dp),
106
+ right = (layout.insetRight - Spacing.M).coerceAtLeast(0.dp),
107
+ )
108
+ } else {
109
+ PaddingValues()
110
+ }
74
111
  // the screen is the outer display on iPhone Duo, even when unfolded
75
112
  val screenHeightDp = if (isDuo) layout.height else getScreenHeight()
76
113
  val screenHeightPx = with(LocalDensity.current) { screenHeightDp.toPx() }
@@ -96,6 +133,11 @@ internal fun BottomSheet(
96
133
  onDispose { }
97
134
  }
98
135
 
136
+ // a closing sheet keeps going: a second close, the open slide or a drag would cancel its animation
137
+ var isClosing by remember { mutableStateOf(false) }
138
+ // runs once the sheet has closed itself, after its onDismiss
139
+ var afterDismiss by remember { mutableStateOf<(() -> Unit)?>(null) }
140
+
99
141
  suspend fun openEvent(){
100
142
  backgroundAlpha.animateTo(
101
143
  targetValue = 0.3f,
@@ -104,6 +146,7 @@ internal fun BottomSheet(
104
146
  easing = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)
105
147
  )
106
148
  )
149
+ if (isClosing) return
107
150
  sheetOffset.animateTo(
108
151
  targetValue = 0f,
109
152
  animationSpec = tween(
@@ -113,7 +156,9 @@ internal fun BottomSheet(
113
156
  )
114
157
  }
115
158
 
116
- fun closeEvent(){
159
+ fun closeEvent(then: (() -> Unit)? = null){
160
+ if (isClosing) return
161
+ isClosing = true
117
162
  coroutineScope.launch {
118
163
  sheetOffset.animateTo(
119
164
  targetValue = screenHeightPx,
@@ -129,10 +174,15 @@ internal fun BottomSheet(
129
174
  easing = CubicBezierEasing(0.3f, 0.0f, 0.8f, 0.15f)
130
175
  )
131
176
  )
177
+ // not reached when a newer overlay replaced this one, so its back is dropped
178
+ afterDismiss = then
132
179
  OverplayComponentRegistry.hardClearAfterDismiss()
133
180
  }
134
181
  }
135
182
 
183
+ // RN `leftOptions.onPressIconLeft`: the sheet closes first, then the back runs
184
+ val onBack = title?.onBack?.let { back -> { closeEvent(then = back) } }
185
+
136
186
  LaunchedEffect(Unit) {
137
187
  openEvent()
138
188
  }
@@ -141,6 +191,7 @@ internal fun BottomSheet(
141
191
  OverplayComponentRegistry.bindClose { closeEvent() }
142
192
  onDispose {
143
193
  onDismiss?.invoke()
194
+ afterDismiss?.invoke()
144
195
  }
145
196
  }
146
197
 
@@ -162,30 +213,29 @@ internal fun BottomSheet(
162
213
  } else {
163
214
  parentHeight - 90.dp
164
215
  }
165
- // landscape side buttons span the full height; in portrait the side status bar only covers the top
166
- val sideInset = isDuo && !isDuoCentered && layout.width > layout.height
216
+ val radius = if (isDuo) Radius.XL else Radius.M
167
217
 
168
218
  Box(
169
219
  modifier = Modifier
170
220
  .align(Alignment.BottomCenter)
171
221
  .offset { IntOffset(x = 0, y = sheetOffset.value.toInt()) }
172
222
  .then(
173
- if (isDuoCentered) {
223
+ when {
174
224
  // keeps the portrait width in landscape, like system sheets
175
- Modifier.width(
225
+ isDuoCentered -> Modifier.width(
176
226
  minOf(
177
227
  minOf(layout.width, layout.height) - Spacing.L * 2,
178
228
  layout.width - maxOf(layout.insetLeft, layout.insetRight, Spacing.L) * 2,
179
229
  )
180
230
  )
181
- } else {
182
- Modifier.fillMaxWidth()
231
+ isDuoFloating -> Modifier.fillMaxWidth().padding(horizontal = Spacing.M)
232
+ else -> Modifier.fillMaxWidth()
183
233
  }
184
234
  )
185
235
  .heightIn(max = maxHeight)
186
236
  .background(
187
237
  color = if (isSurface) AppTheme.current.colors.background.surface else AppTheme.current.colors.background.default,
188
- shape = RoundedCornerShape(topStart = Radius.M, topEnd = Radius.M)
238
+ shape = RoundedCornerShape(topStart = radius, topEnd = radius)
189
239
  )
190
240
  .conditional(IsShowBaseLineDebug) {
191
241
  border(1.dp, Colors.blue_03)
@@ -196,28 +246,24 @@ internal fun BottomSheet(
196
246
  val bottomInset = if (useBottomInset) {
197
247
  (AppNavigationBar.current - keyboardSizeState().value).coerceAtLeast(0.dp)
198
248
  } else 0.dp
199
- Column(
200
- Modifier
201
- .padding(bottom = bottomInset)
202
- .conditional(sideInset) {
203
- absolutePadding(left = layout.insetLeft, right = layout.insetRight)
204
- }
205
- ) {
249
+ Column(Modifier.padding(bottom = bottomInset)) {
206
250
  Box(
207
251
  modifier = Modifier
208
- .height(72.dp)
252
+ .height(INDICATOR_HEIGHT + HEADER_ROW_HEIGHT)
209
253
  .fillMaxWidth()
210
254
  .conditional(draggable) {
211
255
  pointerInput(Unit) {
212
256
  detectDragGestures(
213
257
  onDrag = { change, dragAmount ->
214
258
  change.consume()
259
+ if (isClosing) return@detectDragGestures
215
260
  coroutineScope.launch {
216
261
  val newOffset = (sheetOffset.value + dragAmount.y).coerceAtLeast(0f)
217
262
  sheetOffset.snapTo(newOffset)
218
263
  }
219
264
  },
220
265
  onDragEnd = {
266
+ if (isClosing) return@detectDragGestures
221
267
  coroutineScope.launch {
222
268
  if (sheetOffset.value > sheetCloseOffset) {
223
269
  closeEvent()
@@ -233,6 +279,7 @@ internal fun BottomSheet(
233
279
  Column(
234
280
  modifier = Modifier
235
281
  .fillMaxSize()
282
+ .padding(sidePadding)
236
283
  .padding(horizontal = Spacing.M),
237
284
  horizontalAlignment = Alignment.CenterHorizontally
238
285
  ) {
@@ -247,29 +294,16 @@ internal fun BottomSheet(
247
294
  )
248
295
  )
249
296
  Spacer(Modifier.height(4.dp))
250
- Box(Modifier.fillMaxWidth().height(56.dp), contentAlignment = Alignment.Center){
297
+ Box(Modifier.fillMaxWidth().height(HEADER_ROW_HEIGHT), contentAlignment = Alignment.Center){
251
298
  when (header) {
252
- is BottomHeader.Title -> {
253
- Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
254
- Box(Modifier.size(24.dp))
255
- Box(Modifier.fillMaxHeight().weight(1f), contentAlignment = Alignment.Center){
256
- Text(
257
- text = header.data,
258
- color = Colors.black_17,
259
- style = Typography.headerDefaultBold
260
- )
261
- }
262
- Box(Modifier
263
- .size(24.dp)
264
- .noFeedbackClickable {
265
- closeEvent()
266
- },
267
- contentAlignment = Alignment.Center
268
- ){
269
- Icon(source = "navigation_close")
270
- }
271
- }
272
- }
299
+ is BottomHeader.Title -> BottomSheetTitle(
300
+ text = header.data,
301
+ backIcon = header.backIcon,
302
+ // the side column holds back and close, the row keeps only the title
303
+ showButtons = sideColumn == null,
304
+ onBack = onBack,
305
+ onClose = { closeEvent() },
306
+ )
273
307
  is BottomHeader.Custom -> {
274
308
  header.content()
275
309
  }
@@ -277,27 +311,141 @@ internal fun BottomSheet(
277
311
  }
278
312
  }
279
313
  }
280
- Divider()
314
+ Box(Modifier.padding(sidePadding)) {
315
+ Divider()
316
+ }
281
317
  if (footerComponent != null) {
282
- Box(Modifier.weight(1f, fill = false)) {
318
+ Box(Modifier.weight(1f, fill = false).padding(sidePadding)) {
283
319
  content()
284
320
  }
285
- BottomSheetFooter(footerComponent)
321
+ BottomSheetFooter(footerComponent, sidePadding)
286
322
  } else if (isDuo) {
287
323
  // content shrinks so a tall sheet keeps its header
288
- Box(Modifier.weight(1f, fill = false)) {
324
+ Box(Modifier.weight(1f, fill = false).padding(sidePadding)) {
289
325
  content()
290
326
  }
291
327
  } else {
292
328
  content()
293
329
  }
294
330
  }
331
+ if (sideColumn != null && title != null) {
332
+ BottomSheetSideColumn(
333
+ sideBar = sideColumn,
334
+ parentHeight = parentHeight,
335
+ backIcon = title.backIcon,
336
+ onBack = onBack,
337
+ onClose = { closeEvent() },
338
+ )
339
+ }
295
340
  }
296
341
  }
297
342
  }
298
343
 
299
344
  @Composable
300
- private fun BottomSheetFooter(footerComponent: @Composable () -> Unit) {
345
+ private fun BottomSheetTitle(
346
+ text: String,
347
+ backIcon: String,
348
+ showButtons: Boolean,
349
+ onBack: (() -> Unit)?,
350
+ onClose: () -> Unit,
351
+ ) {
352
+ Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
353
+ if (showButtons) {
354
+ if (onBack != null) {
355
+ BottomSheetHeaderButton(icon = backIcon, automationId = "btn_bottom_sheet_back", onClick = onBack)
356
+ } else {
357
+ Box(Modifier.size(24.dp))
358
+ }
359
+ }
360
+ Box(
361
+ Modifier
362
+ .fillMaxHeight()
363
+ .weight(1f)
364
+ .conditional(!showButtons) { padding(horizontal = Spacing.M) },
365
+ contentAlignment = Alignment.Center
366
+ ) {
367
+ Text(
368
+ text = text,
369
+ color = Colors.black_17,
370
+ style = Typography.headerDefaultBold
371
+ )
372
+ }
373
+ if (showButtons) {
374
+ BottomSheetHeaderButton(icon = "navigation_close", automationId = "btn_bottom_sheet_close", onClick = onClose)
375
+ }
376
+ }
377
+ }
378
+
379
+ @Composable
380
+ private fun BottomSheetHeaderButton(icon: String, automationId: String, onClick: () -> Unit) {
381
+ Box(
382
+ Modifier
383
+ .size(24.dp)
384
+ .noFeedbackClickable(onClick = onClick)
385
+ .setAutomationId(automationId),
386
+ contentAlignment = Alignment.Center
387
+ ) {
388
+ Icon(source = icon)
389
+ }
390
+ }
391
+
392
+ // iPhone Duo outer display: back and close sit on the part of the sheet under the side safe area,
393
+ // 12pt from the strip's inner edge like the screen side bar. They follow the header row while the
394
+ // sheet is short and stay below the status bar cluster while it is tall.
395
+ @Composable
396
+ private fun BoxScope.BottomSheetSideColumn(
397
+ sideBar: SideBarLayout,
398
+ parentHeight: Dp,
399
+ backIcon: String,
400
+ onBack: (() -> Unit)?,
401
+ onClose: () -> Unit,
402
+ ) {
403
+ val isRight = sideBar.edge == SideBarEdge.RIGHT
404
+ val headerColor = getHeaderColor(HeaderType.None, 1f, null, AppTheme.current.colors.text.default)
405
+
406
+ Box(Modifier.matchParentSize()) {
407
+ Column(
408
+ modifier = Modifier
409
+ .align(if (isRight) AbsoluteAlignment.TopRight else AbsoluteAlignment.TopLeft)
410
+ .width(sideBar.width - Spacing.M)
411
+ .layout { measurable, constraints ->
412
+ val placeable = measurable.measure(constraints.copy(minHeight = 0))
413
+ val sheetHeight = constraints.maxHeight
414
+ // the sheet rests on the bottom of the overlay, which starts at the window top
415
+ val sheetTop = parentHeight.roundToPx() - sheetHeight
416
+ // clear of a camera at the bottom end of the strip when it can
417
+ val lowest = sheetHeight - sideBar.bottom.roundToPx() - placeable.height
418
+ val y = maxOf(sideBar.top.roundToPx() - sheetTop, SIDE_COLUMN_OFFSET.roundToPx())
419
+ .coerceAtMost(lowest)
420
+ .coerceAtLeast(0)
421
+ layout(placeable.width, constraints.constrainHeight(y + placeable.height)) {
422
+ placeable.place(0, y)
423
+ }
424
+ }
425
+ .absolutePadding(left = if (isRight) Spacing.M else 0.dp, right = if (isRight) 0.dp else Spacing.M),
426
+ horizontalAlignment = if (isRight) AbsoluteAlignment.Left else AbsoluteAlignment.Right,
427
+ verticalArrangement = Arrangement.spacedBy(Spacing.M),
428
+ ) {
429
+ if (onBack != null) {
430
+ SideBarButton(
431
+ icon = backIcon,
432
+ headerColor = headerColor,
433
+ automationId = "btn_bottom_sheet_back",
434
+ onClick = onBack,
435
+ )
436
+ }
437
+ SideBarButton(
438
+ icon = "navigation_close",
439
+ headerColor = headerColor,
440
+ automationId = "btn_bottom_sheet_close",
441
+ onClick = onClose,
442
+ )
443
+ }
444
+ }
445
+ }
446
+
447
+ @Composable
448
+ private fun BottomSheetFooter(footerComponent: @Composable () -> Unit, sidePadding: PaddingValues) {
301
449
  val shadowBrush = remember {
302
450
  Brush.verticalGradient(
303
451
  colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.05f))
@@ -312,6 +460,7 @@ private fun BottomSheetFooter(footerComponent: @Composable () -> Unit) {
312
460
  .conditional(IsShowBaseLineDebug) {
313
461
  border(1.dp, Colors.blue_03)
314
462
  }
463
+ .padding(sidePadding)
315
464
  .padding(vertical = Spacing.S)
316
465
  ) {
317
466
  footerComponent()
@@ -330,7 +479,19 @@ private fun BottomSheetFooter(footerComponent: @Composable () -> Unit) {
330
479
  sealed class BottomHeader {
331
480
  data class Title(
332
481
  val data: String = "Bottom Sheet Title",
333
- ) : BottomHeader()
482
+ ) : BottomHeader() {
483
+ // Outside the primary constructor so code built against older kits keeps Title() and copy().
484
+ // onBack shows a back button: the sheet closes (onDismiss fires), then it runs.
485
+ var onBack: (() -> Unit)? = null
486
+ private set
487
+ var backIcon: String = "arrow-back"
488
+ private set
489
+
490
+ constructor(data: String, onBack: (() -> Unit)?, backIcon: String = "arrow-back") : this(data) {
491
+ this.onBack = onBack
492
+ this.backIcon = backIcon
493
+ }
494
+ }
334
495
 
335
496
  data class Custom(
336
497
  val content: @Composable () -> Unit
@@ -8,8 +8,9 @@ import androidx.compose.foundation.border
8
8
  import androidx.compose.foundation.clickable
9
9
  import androidx.compose.foundation.interaction.MutableInteractionSource
10
10
  import androidx.compose.foundation.layout.Box
11
+ import androidx.compose.foundation.layout.absolutePadding
11
12
  import androidx.compose.foundation.layout.fillMaxSize
12
- import androidx.compose.foundation.layout.padding
13
+ import androidx.compose.foundation.layout.sizeIn
13
14
  import androidx.compose.foundation.layout.wrapContentSize
14
15
  import androidx.compose.runtime.*
15
16
  import androidx.compose.ui.Alignment
@@ -26,6 +27,8 @@ import vn.momo.kits.modifier.conditional
26
27
  import vn.momo.kits.modifier.noFeedbackClickable
27
28
  import vn.momo.kits.navigation.tracking.ScreenTracker
28
29
  import vn.momo.kits.platform.BackHandler
30
+ import vn.momo.kits.layout.duoModalMaxHeight
31
+ import vn.momo.kits.layout.duoModalMaxWidth
29
32
  import vn.momo.kits.layout.getScreenLayout
30
33
 
31
34
  @Composable
@@ -111,9 +114,14 @@ internal fun ModalScreen(
111
114
  .noFeedbackClickable {
112
115
  if(barrierDismissible) closeEvent()
113
116
  }
114
- // center below the side status bar and above the home indicator
117
+ // center in the safe area, clear of the side toolbar strip and the home indicator
115
118
  .conditional(layout.isIPhoneDuoDevice) {
116
- padding(top = layout.duoTopInset, bottom = layout.insetBottom)
119
+ absolutePadding(
120
+ left = layout.insetLeft,
121
+ top = layout.insetTop,
122
+ right = layout.insetRight,
123
+ bottom = layout.insetBottom,
124
+ )
117
125
  },
118
126
  contentAlignment = Alignment.Center
119
127
  ) {
@@ -121,6 +129,9 @@ internal fun ModalScreen(
121
129
  modifier = Modifier
122
130
  .scale(scale.value)
123
131
  .alpha(alpha.value)
132
+ .conditional(layout.isIPhoneDuoDevice) {
133
+ sizeIn(maxWidth = layout.duoModalMaxWidth, maxHeight = layout.duoModalMaxHeight)
134
+ }
124
135
  .wrapContentSize()
125
136
  .conditional(IsShowBaseLineDebug) {
126
137
  border(1.dp, Colors.blue_03)
@@ -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) {
@@ -1,9 +1,11 @@
1
1
  package vn.momo.kits.navigation
2
2
 
3
3
  import androidx.compose.foundation.layout.Box
4
+ import androidx.compose.foundation.layout.absolutePadding
4
5
  import androidx.compose.foundation.layout.fillMaxSize
5
6
  import androidx.compose.runtime.Composable
6
7
  import androidx.compose.runtime.MutableState
8
+ import androidx.compose.runtime.key
7
9
  import androidx.compose.runtime.mutableStateOf
8
10
  import androidx.compose.runtime.staticCompositionLocalOf
9
11
  import androidx.compose.ui.Alignment
@@ -19,6 +21,8 @@ import kotlinx.coroutines.launch
19
21
  import kotlinx.serialization.Serializable
20
22
  import vn.momo.kits.components.PopupAction
21
23
  import vn.momo.kits.components.PopupNotify
24
+ import vn.momo.kits.layout.getScreenLayout
25
+ import vn.momo.kits.modifier.conditional
22
26
  import vn.momo.kits.navigation.BottomHeader.*
23
27
  import vn.momo.kits.navigation.component.SnackBar
24
28
  import vn.momo.maxapi.IMaxApi
@@ -113,9 +117,10 @@ class Navigator(
113
117
  }
114
118
  }
115
119
  private suspend fun dismissOverplay(isDelay: Boolean = true) {
120
+ val dismissing = OverplayComponentRegistry.current()
116
121
  OverplayComponentRegistry.clear()
117
122
  if (isDelay) delay(300L.milliseconds)
118
- OverplayComponentRegistry.hardClearAfterDismiss()
123
+ OverplayComponentRegistry.hardClearAfterDismiss(dismissing)
119
124
  }
120
125
 
121
126
  private suspend fun dismissScreen() {
@@ -182,9 +187,10 @@ class Navigator(
182
187
 
183
188
  fun hideSnackBar() {
184
189
  scope.launch {
190
+ val dismissing = OverplayComponentRegistry.current()
185
191
  OverplayComponentRegistry.clear()
186
192
  delay(250L.milliseconds)
187
- OverplayComponentRegistry.hardClearAfterDismiss()
193
+ OverplayComponentRegistry.hardClearAfterDismiss(dismissing)
188
194
  }
189
195
  }
190
196
 
@@ -358,42 +364,59 @@ object OverplayComponentRegistry {
358
364
  requestClose = null
359
365
  }
360
366
 
367
+ internal fun current(): OverplayComponent? = currentOverlayComponent.value
368
+
369
+ // An overlay shown from the dismissed one's onDismiss, before the delayed clear, stays.
370
+ internal fun hardClearAfterDismiss(dismissing: OverplayComponent?) {
371
+ if (currentOverlayComponent.value === dismissing) hardClearAfterDismiss()
372
+ }
373
+
361
374
  @Composable
362
375
  internal fun OverlayComponent(){
363
376
  val overplay = currentOverlayComponent.value ?: return
364
377
 
365
- when (val params = overplay.params) {
366
- is OverplayComponentParams.BottomSheet -> {
367
- BottomSheet(
368
- content = overplay.content,
369
- header = params.bottomSheetHeader ?: Title(),
370
- isSurface = params.isSurface,
371
- barrierDismissible = params.barrierDismissible,
372
- draggable = params.draggable,
373
- useBottomInset = params.useBottomInset,
374
- footerComponent = params.footerComponent,
375
- onDismiss = params.onDismiss
376
- )
377
- }
378
+ // every registration is its own composition (params are new instances), as SwiftUI's `.id(item.id)`:
379
+ // a sheet shown from another one's close must not inherit its finished animation and effects
380
+ key(overplay) {
381
+ when (val params = overplay.params) {
382
+ is OverplayComponentParams.BottomSheet -> {
383
+ BottomSheet(
384
+ content = overplay.content,
385
+ header = params.bottomSheetHeader ?: Title(),
386
+ isSurface = params.isSurface,
387
+ barrierDismissible = params.barrierDismissible,
388
+ draggable = params.draggable,
389
+ useBottomInset = params.useBottomInset,
390
+ footerComponent = params.footerComponent,
391
+ onDismiss = params.onDismiss
392
+ )
393
+ }
378
394
 
379
- is OverplayComponentParams.Modal -> {
380
- ModalScreen(
381
- content = overplay.content,
382
- barrierDismissible = params.barrierDismissible,
383
- onDismiss = params.onDismiss
384
- )
385
- }
395
+ is OverplayComponentParams.Modal -> {
396
+ ModalScreen(
397
+ content = overplay.content,
398
+ barrierDismissible = params.barrierDismissible,
399
+ onDismiss = params.onDismiss
400
+ )
401
+ }
386
402
 
387
- is OverplayComponentParams.SnackBar -> {
388
- Box(
389
- modifier = Modifier.fillMaxSize(),
390
- contentAlignment = Alignment.BottomCenter
391
- ) {
392
- overplay.content()
403
+ is OverplayComponentParams.SnackBar -> {
404
+ val layout = getScreenLayout()
405
+ Box(
406
+ modifier = Modifier
407
+ .fillMaxSize()
408
+ // iPhone Duo: clear of the side toolbar strip, like the screen content
409
+ .conditional(layout.isIPhoneDuo) {
410
+ absolutePadding(left = layout.insetLeft, right = layout.insetRight)
411
+ },
412
+ contentAlignment = Alignment.BottomCenter
413
+ ) {
414
+ overplay.content()
415
+ }
393
416
  }
394
- }
395
417
 
396
- null -> {}
418
+ null -> {}
419
+ }
397
420
  }
398
421
  }
399
422
  }
@@ -39,8 +39,6 @@ internal val DUO_GROUP_GAP = Spacing.M
39
39
  private val DEFAULT_TOOLBAR_HEIGHT = DuoMetrics.controlSize + Spacing.M + DuoMetrics.itemPitch * 2
40
40
  // Back, then the single button the toolbar folds into
41
41
  private val COMPRESSED_TOOLBAR_HEIGHT = DuoMetrics.controlSize * 2 + Spacing.M
42
- // Back alone, while a rail opened from the compressed state takes the toolbar slot
43
- private val BACK_ONLY_HEIGHT = DuoMetrics.controlSize
44
42
 
45
43
  // Port of RN DuoBarCompression (HIG, Designing for iPhone Duo: Vertical controls):
46
44
  // - both fit: both stay whole
@@ -78,7 +76,8 @@ internal fun rememberDuoBarCompression(
78
76
  expandTabBar = { expanded = DuoBar.TAB_BAR },
79
77
  setToolbarHeight = setToolbarHeight,
80
78
  )
81
- opened == DuoBar.TAB_BAR && fits(BACK_ONLY_HEIGHT, tabBar) -> DuoBarCompression(
79
+ // an opened rail always shows, even when only the group gap is short: it closes once a tab is picked
80
+ opened == DuoBar.TAB_BAR -> DuoBarCompression(
82
81
  toolbarCompressed = true,
83
82
  toolbarHidden = true,
84
83
  expandToolbar = { opened = DuoBar.TOOLBAR },