@momo-kits/native-kits 0.164.1-beta.9-debug → 0.164.1-headerconfig.1-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 (53) hide show
  1. package/.claude/settings.local.json +7 -178
  2. package/CLAUDE.md +2 -0
  3. package/compose/build.gradle.kts +1 -1
  4. package/compose/compose.podspec +1 -1
  5. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/BackGesture.android.kt +3 -3
  6. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +2 -1
  7. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +18 -0
  8. package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +6 -1
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +30 -4
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +1 -1
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +3 -1
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +8 -1
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +9 -4
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +4 -1
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Stepper.kt +3 -0
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +104 -22
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +3 -9
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Title.kt +8 -4
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TrustBanner.kt +2 -1
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitDesignSystem.kt +18 -0
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Theme.kt +2 -2
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +38 -5
  23. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/HostBackGestureBridge.kt +58 -0
  24. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +3 -0
  25. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +20 -11
  26. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +10 -23
  27. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +20 -23
  28. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackTransition.kt +11 -27
  29. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/SwipeBackHaptic.kt +4 -10
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +19 -0
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +41 -3
  32. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +3 -1
  33. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +106 -0
  34. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +75 -0
  35. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +3 -0
  36. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +5 -1
  37. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/BackGesture.kt +5 -8
  38. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +3 -1
  39. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/HostBackGesture.ios.kt +45 -0
  40. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/BackGesture.ios.kt +6 -3
  41. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +8 -23
  42. package/gradle/libs.versions.toml +1 -1
  43. package/gradle.properties +1 -1
  44. package/ios/Application/Navigation/NavigatorManager.swift +0 -27
  45. package/ios/Colors+Radius+Spacing/Theme.swift +2 -2
  46. package/ios/Typography/KitDesignSystem.swift +17 -0
  47. package/ios/Typography/Text.swift +25 -1
  48. package/ios/native-kits.podspec +1 -1
  49. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  50. package/local.properties +2 -2
  51. package/package.json +1 -1
  52. package/publish.sh +25 -0
  53. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/ComposeBackGesture.ios.kt +0 -65
@@ -11,6 +11,9 @@ import androidx.compose.runtime.*
11
11
  import androidx.compose.ui.Alignment
12
12
  import androidx.compose.ui.Modifier
13
13
  import androidx.compose.ui.graphics.Color
14
+ import androidx.compose.ui.graphics.drawscope.withTransform
15
+ import androidx.compose.ui.graphics.drawscope.DrawScope
16
+ import androidx.compose.ui.graphics.Path
14
17
  import androidx.compose.ui.layout.onGloballyPositioned
15
18
  import androidx.compose.ui.layout.positionInParent
16
19
  import androidx.compose.ui.platform.LocalDensity
@@ -304,47 +307,126 @@ private fun CardTabBar(
304
307
  onPressTabItem: (Int) -> Unit,
305
308
  ) {
306
309
  val theme = AppTheme.current
307
-
308
- Row(
310
+ val surface = theme.colors.background.surface
311
+
312
+ // react-kits `CardTabBar` draws the bar in TWO layers: a flat strip where every
313
+ // tab looks unselected, and one raised card laid OVER it for the active tab.
314
+ // The card is 4dp taller, sits `Spacing.XS` higher, and is joined to the strip
315
+ // on each side by a curved "ear" — the browser-tab silhouette that gives this
316
+ // variant its name. Rendering the active tab in place (just taller) loses both
317
+ // the overlap and the ears, and the strip used the theme's `tonal` pink where
318
+ // react-kits uses `Colors.black_02`, so the bar came out pink and flat.
319
+ val pathWidth = if (tabs.size > 2) 22.dp else 28.dp
320
+ val isFirst = selectedIndex == 0
321
+ val isLast = selectedIndex == tabs.lastIndex
322
+
323
+ // The bar is exactly as tall as the FLAT strip — react-kits' raised card is
324
+ // `position: absolute`, so it never adds height; it just overflows upward into
325
+ // the 4dp margin. Letting the 44dp card size this box instead left a 4dp band of
326
+ // background showing between the tabs and the panel they sit on.
327
+ BoxWithConstraints(
309
328
  modifier = Modifier
310
329
  .fillMaxWidth()
311
- .padding(top = Spacing.XS),
312
- verticalAlignment = Alignment.Bottom,
330
+ .padding(top = Spacing.XS)
331
+ .height(CARD_TAB_HEIGHT),
313
332
  ) {
314
- tabs.forEachIndexed { index, tab ->
315
- val isActive = selectedIndex == index
316
- val iconColor = if (isActive) selectedColor else unselectedColor
333
+ val itemWidth = maxWidth / tabs.size
334
+
335
+ Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom) {
336
+ tabs.forEachIndexed { index, tab ->
337
+ Box(
338
+ modifier = Modifier
339
+ .width(itemWidth)
340
+ .height(CARD_TAB_HEIGHT)
341
+ .background(
342
+ color = Colors.black_02,
343
+ shape = RoundedCornerShape(topStart = Radius.M, topEnd = Radius.M),
344
+ )
345
+ .activeOpacityClickable { onPressTabItem(index) }
346
+ .padding(horizontal = Spacing.S),
347
+ contentAlignment = Alignment.Center,
348
+ ) {
349
+ Row(verticalAlignment = Alignment.CenterVertically) {
350
+ TabLeadingIcon(tab = tab, active = false, color = unselectedColor)
351
+ Text(
352
+ text = tab.title,
353
+ style = Typography.bodyDefaultRegular,
354
+ color = unselectedColor,
355
+ maxLines = 1,
356
+ overflow = TextOverflow.Ellipsis,
357
+ modifier = Modifier.weight(1f, fill = false),
358
+ )
359
+ TabTrailingBadge(tab)
360
+ }
361
+ }
362
+ }
363
+ }
317
364
 
365
+ val active = tabs[selectedIndex]
366
+ Row(
367
+ modifier = Modifier
368
+ // Pinned to the strip's BOTTOM edge so the two always end on the same
369
+ // pixel; `requiredHeight` then grows it upward into the 4dp margin.
370
+ // Offsetting it up by hand instead left a sub-dp sliver of strip
371
+ // showing under the card once rounding got involved.
372
+ .align(Alignment.BottomStart)
373
+ .offset(x = itemWidth * selectedIndex - (if (isFirst) 0.dp else pathWidth))
374
+ // `requiredHeight`, not `height`: the bar box is only as tall as the
375
+ // flat strip, so a plain height would be coerced down to 40 and the
376
+ // raised card would stop 4dp short of the panel below, leaving a grey
377
+ // sliver. react-kits gets this free — its card is absolutely
378
+ // positioned and ignores the parent box entirely.
379
+ .requiredHeight(CARD_TAB_ACTIVE_HEIGHT),
380
+ verticalAlignment = Alignment.Bottom,
381
+ ) {
382
+ // react-kits nudges each ear 8pt UNDER the card so the join has no seam.
383
+ if (!isFirst) CardTabEar(pathWidth, mirrored = true, color = surface, nudge = 8.dp)
318
384
  Box(
319
385
  modifier = Modifier
320
- .weight(1f)
321
- .height(if (isActive) 44.dp else 40.dp)
386
+ .width(itemWidth)
387
+ .fillMaxHeight()
322
388
  .background(
323
- color = if (isActive) theme.colors.background.surface
324
- else theme.colors.background.tonal,
325
- shape = RoundedCornerShape(
326
- topStart = Radius.M,
327
- topEnd = Radius.M,
328
- ),
389
+ color = surface,
390
+ shape = RoundedCornerShape(topStart = Radius.M, topEnd = Radius.M),
329
391
  )
330
- .activeOpacityClickable { onPressTabItem(index) }
331
- .padding(horizontal = Spacing.S),
392
+ .activeOpacityClickable { onPressTabItem(selectedIndex) }
393
+ .padding(horizontal = if (tabs.size == 3) Spacing.S else Spacing.M),
332
394
  contentAlignment = Alignment.Center,
333
395
  ) {
334
396
  Row(verticalAlignment = Alignment.CenterVertically) {
335
- TabLeadingIcon(tab = tab, active = isActive, color = iconColor)
397
+ TabLeadingIcon(tab = active, active = true, color = selectedColor)
336
398
  Text(
337
- text = tab.title,
338
- style = if (isActive) Typography.headerSSemibold else Typography.bodyDefaultRegular,
339
- color = if (isActive) selectedColor else unselectedColor,
399
+ text = active.title,
400
+ style = Typography.headerSSemibold,
401
+ color = selectedColor,
340
402
  maxLines = 1,
341
403
  overflow = TextOverflow.Ellipsis,
342
404
  modifier = Modifier.weight(1f, fill = false),
343
405
  )
344
- TabTrailingBadge(tab)
406
+ TabTrailingBadge(active)
345
407
  }
346
408
  }
409
+ if (!isLast) CardTabEar(pathWidth, mirrored = false, color = surface, nudge = -8.dp)
410
+ }
411
+ }
412
+ }
413
+
414
+ private val CARD_TAB_HEIGHT = 40.dp
415
+ private val CARD_TAB_ACTIVE_HEIGHT = 44.dp
416
+ @Composable
417
+ private fun CardTabEar(width: Dp, mirrored: Boolean, color: Color, nudge: Dp) {
418
+ Canvas(modifier = Modifier.offset(x = nudge).width(width).height(CARD_TAB_ACTIVE_HEIGHT)) {
419
+ val path = Path().apply {
420
+ moveTo(0f, 0f)
421
+ lineTo(0.60765f, 0f)
422
+ cubicTo(6.47582f, 0f, 11.4819f, 4.23831f, 12.4602f, 10.0244f)
423
+ cubicTo(13.9248f, 18.6872f, 15.9294f, 30.37f, 16.6925f, 34f)
424
+ cubicTo(17.9536f, 40f, 22.259f, 44f, 27.9996f, 44f)
425
+ lineTo(0f, 44f)
426
+ close()
347
427
  }
428
+ val draw: DrawScope.() -> Unit = { drawPath(path = path, color = color) }
429
+ if (mirrored) withTransform({ scale(-1f, 1f) }) { draw() } else draw()
348
430
  }
349
431
  }
350
432
 
@@ -78,15 +78,9 @@ fun Text(
78
78
  // Cache theme access to avoid repeated lookups
79
79
  val theme = AppTheme.current
80
80
 
81
- // Call @Composable functions directly in composable context
82
- val scaledFontSize = scaleSize(style.fontSize)
83
- val scaledLineHeight = scaleSize(style.lineHeight)
84
- val fontFamilyResult = getFontFamily(fontFamily ?: theme.font, style.fontWeight)
85
-
86
- // Now memoize the results
87
- val fontSize = remember(scaledFontSize) { scaledFontSize }
88
- val lineHeight = remember(scaledLineHeight) { scaledLineHeight }
89
- val font = remember(fontFamilyResult) { fontFamilyResult }
81
+ val fontSize = scaleSize(style.fontSize)
82
+ val lineHeight = scaleSize(style.lineHeight)
83
+ val font = getFontFamily(fontFamily ?: theme.font, style.fontWeight)
90
84
 
91
85
  // Memoize color calculation
92
86
  val textColor = remember(color, theme) {
@@ -47,9 +47,9 @@ fun Title(
47
47
  val theme = AppTheme.current
48
48
  val textStyle: TextStyle = when (type) {
49
49
  TitleType.Card -> when (size) {
50
- TitleSize.Small -> TextStyle(fontSize = 14.sp, lineHeight = 20.sp)
51
- TitleSize.Medium -> TextStyle(fontSize = 16.sp, lineHeight = 22.sp)
52
- TitleSize.Large -> TextStyle(fontSize = 18.sp, lineHeight = 26.sp)
50
+ TitleSize.Small -> TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Bold)
51
+ TitleSize.Medium -> TextStyle(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.Bold)
52
+ TitleSize.Large -> TextStyle(fontSize = 18.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold)
53
53
  }
54
54
 
55
55
  TitleType.Section -> when (size) {
@@ -89,7 +89,11 @@ fun Title(
89
89
  .conditional(IsShowBaseLineDebug) {
90
90
  border(1.dp, Colors.blue_03)
91
91
  }
92
- .padding(8.dp),
92
+ // react-kits' Title root is `styles.wrapper` = `{flexDirection:'row'}`
93
+ // plus, for a SECTION only, `marginTop: Spacing.S` (foundations/Title
94
+ // index.tsx). It has no padding at all, so the 8dp box here inset every
95
+ // title by 8dp against the web render.
96
+ .padding(top = if (type == TitleType.Section) Spacing.S else 0.dp),
93
97
  verticalAlignment = Alignment.CenterVertically
94
98
  ) {
95
99
  icon?.let {
@@ -18,6 +18,7 @@ import androidx.compose.ui.draw.clip
18
18
  import androidx.compose.ui.graphics.Color
19
19
  import androidx.compose.ui.layout.ContentScale
20
20
  import androidx.compose.ui.unit.dp
21
+ import vn.momo.kits.application.LocalDesignSystemConfig
21
22
  import vn.momo.kits.application.LocalLocalize
22
23
  import vn.momo.kits.application.IsShowBaseLineDebug
23
24
  import vn.momo.kits.const.Colors
@@ -68,7 +69,7 @@ fun TrustBanner(
68
69
  trackEvent: ((String, Map<String, String>) -> Unit)? = null
69
70
  ) {
70
71
  val localize = LocalLocalize.current
71
- val trustBanner = defaultBanner
72
+ val trustBanner = LocalDesignSystemConfig.current?.trustBanner ?: defaultBanner
72
73
  val trackParams = mapOf(
73
74
  "service_name" to serviceName,
74
75
  "screen_name" to screenName,
@@ -0,0 +1,18 @@
1
+ package vn.momo.kits.const
2
+
3
+ import androidx.compose.runtime.getValue
4
+ import androidx.compose.runtime.mutableStateOf
5
+ import androidx.compose.runtime.setValue
6
+
7
+ // Design-system rollout holder, same shape as KitFontScale: the kit is sandboxed and can't read the
8
+ // host config, so the host seeds this once at startup from DesignSystemConfig. getFontFamily() reads
9
+ // it to decide whether the new default font applies, so every screen follows the flag with no prop
10
+ // threading. Backed by snapshot state: writes recompose text automatically.
11
+ object KitDesignSystem {
12
+ var isWhiteList by mutableStateOf(false)
13
+ private set
14
+
15
+ fun update(isWhiteList: Boolean) {
16
+ this.isWhiteList = isWhiteList
17
+ }
18
+ }
@@ -117,7 +117,7 @@ val defaultTheme = Theme(
117
117
  container = Colors.blue_08
118
118
  )
119
119
  ),
120
- font = "SFProText",
120
+ font = "MomoTrustSans",
121
121
  assets = ThemeAssets(
122
122
  headerBackground = null
123
123
  )
@@ -174,7 +174,7 @@ val darkTheme = Theme(
174
174
  container = Colors.blue_08
175
175
  )
176
176
  ),
177
- font = "SFProText",
177
+ font = "MomoTrustSans",
178
178
  assets = ThemeAssets(
179
179
  headerBackground = null
180
180
  )
@@ -89,18 +89,29 @@ fun scaleSize(textStyle: TextStyle): TextStyle {
89
89
  * SDK không đóng gói font riêng nữa: app host đã bundle sẵn toàn bộ font từ `font-source`
90
90
  * cho cả React Native lẫn native, nên bản copy trong `composeResources` chỉ làm phình app.
91
91
  */
92
- private const val DEFAULT_FONT_FILE = "SFProText-Regular.ttf"
92
+ private const val LEGACY_DEFAULT_FONT_FILE = "SFProText-Regular.ttf"
93
+ private const val NEW_DEFAULT_FONT_FILE = "MomoTrustSans-Regular.ttf"
93
94
 
94
95
  private val hostFontFiles: Map<String, String> = mapOf(
95
96
  "SFProText-100" to "SFProText-Thin.ttf",
96
97
  "SFProText-200" to "SFProText-Ultralight.ttf",
97
98
  "SFProText-300" to "SFProText-Light.ttf",
98
- "SFProText-400" to DEFAULT_FONT_FILE,
99
+ "SFProText-400" to LEGACY_DEFAULT_FONT_FILE,
99
100
  "SFProText-500" to "SFProText-Medium.ttf",
100
101
  "SFProText-600" to "SFProText-Semibold.ttf",
101
102
  "SFProText-700" to "SFProText-Bold.ttf",
102
103
  "SFProText-800" to "SFProText-Heavy.ttf",
103
104
  "SFProText-900" to "SFProText-Black.ttf",
105
+
106
+ "MomoTrustSans-100" to "MomoTrustSans-ExtraLight.ttf",
107
+ "MomoTrustSans-200" to "MomoTrustSans-ExtraLight.ttf",
108
+ "MomoTrustSans-300" to "MomoTrustSans-Light.ttf",
109
+ "MomoTrustSans-400" to "MomoTrustSans-Regular.ttf",
110
+ "MomoTrustSans-500" to "MomoTrustSans-Medium.ttf",
111
+ "MomoTrustSans-600" to "MomoTrustSans-SemiBold.ttf",
112
+ "MomoTrustSans-700" to "MomoTrustSans-Bold.ttf",
113
+ "MomoTrustSans-800" to "MomoTrustSans-ExtraBold.ttf",
114
+ "MomoTrustSans-900" to "MomoTrustSans-ExtraBold.ttf",
104
115
  ) + (1..9).flatMap { step ->
105
116
  val weight = step * 100
106
117
  listOf(
@@ -109,12 +120,34 @@ private val hostFontFiles: Map<String, String> = mapOf(
109
120
  )
110
121
  }
111
122
 
123
+ /**
124
+ * Font mặc định mới, chỉ áp cho user nằm trong whitelist design system ([KitDesignSystem]).
125
+ * Ngoài whitelist thì quay về [LEGACY_DEFAULT_FONT_FAMILY] như trước.
126
+ */
127
+ private const val NEW_DEFAULT_FONT_FAMILY = "MomoTrustSans"
128
+ private const val LEGACY_DEFAULT_FONT_FAMILY = "SFProText"
129
+
130
+ /**
131
+ * Font thuoc bo design system moi. MoMoTrustDisplay chi duoc header dung tu DS-791
132
+ * (commit header title 5bfde6251); truoc do header lay font theo theme, nen ngoai
133
+ * whitelist ca hai deu ha ve [LEGACY_DEFAULT_FONT_FAMILY].
134
+ */
135
+ private val newFontFamilies = setOf(NEW_DEFAULT_FONT_FAMILY, "MoMoTrustDisplay")
136
+
112
137
  @Composable
113
138
  fun getFontFamily(fontFamily: String, fontWeight: FontWeight? = FontWeight.Normal): FontFamily {
114
- val key = "$fontFamily-${fontWeight?.weight}"
115
- val fileName = hostFontFiles[key] ?: DEFAULT_FONT_FILE
139
+ val isWhiteList = KitDesignSystem.isWhiteList
140
+ val resolvedFamily =
141
+ if (fontFamily in newFontFamilies && !isWhiteList) {
142
+ LEGACY_DEFAULT_FONT_FAMILY
143
+ } else {
144
+ fontFamily
145
+ }
146
+ val defaultFile = if (isWhiteList) NEW_DEFAULT_FONT_FILE else LEGACY_DEFAULT_FONT_FILE
147
+ val key = "$resolvedFamily-${fontWeight?.weight}"
148
+ val fileName = hostFontFiles[key] ?: defaultFile
116
149
  return hostFontFamily(fileName)
117
- ?: hostFontFamily(DEFAULT_FONT_FILE)
150
+ ?: hostFontFamily(defaultFile)
118
151
  ?: FontFamily.Default
119
152
  }
120
153
 
@@ -0,0 +1,58 @@
1
+ package vn.momo.kits.navigation
2
+
3
+ interface HostBackTarget {
4
+ fun canPop(): Boolean
5
+
6
+ fun onBack()
7
+
8
+ fun backStarted()
9
+ fun backProgressed(progress: Float)
10
+ fun backCompleted()
11
+ fun backCancelled()
12
+ }
13
+
14
+ object HostBackGestureBridge {
15
+ private val targets = mutableListOf<HostBackTarget>()
16
+ private var gestureTarget: HostBackTarget? = null
17
+
18
+ var shouldUseFloatingArrowBack: Boolean = false
19
+
20
+ fun checkCanPop(): Boolean = targets.isNotEmpty()
21
+
22
+ fun canPopInner(): Boolean = top()?.canPop() == true
23
+
24
+ fun pop() {
25
+ top()?.onBack()
26
+ }
27
+
28
+ fun started() {
29
+ val target = top() ?: return
30
+ gestureTarget = target
31
+ target.backStarted()
32
+ }
33
+
34
+ fun progressed(progress: Float) {
35
+ gestureTarget?.backProgressed(progress.coerceIn(0f, 1f))
36
+ }
37
+
38
+ fun completed() {
39
+ (gestureTarget ?: top())?.backCompleted()
40
+ gestureTarget = null
41
+ }
42
+
43
+ fun cancelled() {
44
+ gestureTarget?.backCancelled()
45
+ gestureTarget = null
46
+ }
47
+
48
+ fun attach(target: HostBackTarget) {
49
+ targets += target
50
+ }
51
+
52
+ fun detach(target: HostBackTarget) {
53
+ targets -= target
54
+ if (gestureTarget === target) gestureTarget = null
55
+ }
56
+
57
+ private fun top(): HostBackTarget? = targets.lastOrNull()
58
+ }
@@ -33,6 +33,7 @@ class Navigation(
33
33
  tintColor: Color? = null,
34
34
  footerComponent: (@Composable () -> Unit)? = null,
35
35
  footerTransparent: Boolean? = null,
36
+ footerUseMargin: Boolean? = null,
36
37
  floatingButtonProps: FloatingButtonProps? = null,
37
38
  keyboardOptions: KeyboardOptions? = null
38
39
  ) {
@@ -50,6 +51,7 @@ class Navigation(
50
51
  tintColor = tintColor ?: options.tintColor,
51
52
  footerComponent = footerComponent ?: options.footerComponent,
52
53
  footerTransparent = footerTransparent ?: options.footerTransparent,
54
+ footerUseMargin = footerUseMargin ?: options.footerUseMargin,
53
55
  floatingButtonProps = floatingButtonProps ?: options.floatingButtonProps,
54
56
  keyboardOptions = keyboardOptions ?: options.keyboardOptions,
55
57
  )
@@ -86,6 +88,7 @@ data class NavigationOptions(
86
88
  val tintColor: Color? = null,
87
89
  val footerComponent: @Composable (() -> Unit)? = null,
88
90
  val footerTransparent: Boolean = false,
91
+ val footerUseMargin: Boolean = true,
89
92
  val floatingButtonProps: FloatingButtonProps? = null,
90
93
  val keyboardOptions: KeyboardOptions = KeyboardOptions()
91
94
  ) {
@@ -19,7 +19,10 @@ import androidx.navigation.compose.rememberNavController
19
19
  import androidx.navigation.toRoute
20
20
  import kotlinx.coroutines.delay
21
21
  import kotlinx.coroutines.flow.collectLatest
22
+ import vn.momo.kits.application.DesignSystemConfig
23
+ import vn.momo.kits.application.KitDesignSystemConfig
22
24
  import vn.momo.kits.application.LocalContext
25
+ import vn.momo.kits.application.LocalDesignSystemConfig
23
26
  import vn.momo.kits.application.LocalLocalize
24
27
  import vn.momo.kits.application.Localize
25
28
  import vn.momo.kits.application.MiniAppContext
@@ -39,13 +42,14 @@ import kotlin.time.Duration.Companion.milliseconds
39
42
  fun NavigationContainer(
40
43
  initialScreenName: String,
41
44
  initialScreen: @Composable () -> Unit,
45
+ maxApi: IMaxApi,
42
46
  options: NavigationOptions? = null,
43
47
  initialTheme: Theme = defaultTheme,
44
48
  miniAppContext: MiniAppContext? = null,
45
- maxApi: IMaxApi? = null,
46
49
  setNavigator: ((Navigator) -> Unit)? = null,
47
50
  statusBarHeight: Dp? = null,
48
51
  localize: Localize? = null,
52
+ designSystemConfig: DesignSystemConfig? = null,
49
53
  ) {
50
54
  val navController = rememberNavController()
51
55
  val registry = remember { DynamicScreenRegistry() }
@@ -58,6 +62,17 @@ fun NavigationContainer(
58
62
 
59
63
  val stackTransition = remember { StackTransitionCoordinator() }
60
64
  val theme = remember { mutableStateOf(initialTheme) }
65
+ val resolvedDesignSystemConfig =
66
+ designSystemConfig ?: LocalDesignSystemConfig.current ?: KitDesignSystemConfig.config
67
+ val headerBar = resolvedDesignSystemConfig?.headerBar
68
+ // RN parity: the theme's own header image wins; config headerBar is only the fallback.
69
+ val resolvedTheme = remember(theme.value, headerBar) {
70
+ if (theme.value.isHeaderImage() || headerBar.isNullOrEmpty()) {
71
+ theme.value
72
+ } else {
73
+ theme.value.copy(assets = theme.value.assets.copy(headerBackground = headerBar))
74
+ }
75
+ }
61
76
  val fallbackLocalize = remember { Localize() }
62
77
 
63
78
  val resolvedLocalize = localize ?: fallbackLocalize
@@ -78,15 +93,16 @@ fun NavigationContainer(
78
93
  val startDestination = remember(screenId) { DynamicScreenRoute(screenId) }
79
94
 
80
95
  ProvideOsFontScale {
81
- ProvideNavigationEventDispatcherOwner {
96
+ ProvideNavigationEventDispatcherOwner(navigator) {
82
97
  CompositionLocalProvider(
83
98
  LocalNavigator provides navigator,
84
99
  LocalDynamicScreenRegistry provides registry,
85
100
  LocalMaxApi provides maxApi,
86
- AppTheme provides theme.value,
101
+ AppTheme provides resolvedTheme,
87
102
  AppStatusBar provides statusBarHeight,
88
103
  AppNavigationBar provides navigationBarHeight,
89
104
  LocalContext provides mergedContext,
105
+ LocalDesignSystemConfig provides resolvedDesignSystemConfig,
90
106
  LocalLocalize provides resolvedLocalize,
91
107
  LocalStackTransition provides stackTransition,
92
108
  ) {
@@ -96,8 +112,6 @@ fun NavigationContainer(
96
112
 
97
113
  SwipeBackHaptic(maxApi)
98
114
 
99
- // Gesture pops never reach Navigator.dismissScreen(), so prune the registry from
100
- // the back stack itself. collectLatest lets a rapid re-push cancel a pending prune.
101
115
  LaunchedEffect(navController, registry) {
102
116
  navController.currentBackStack.collectLatest { entries ->
103
117
  delay(STACK_TRANSITION_DURATION.milliseconds)
@@ -110,10 +124,6 @@ fun NavigationContainer(
110
124
  }
111
125
  }
112
126
 
113
- // The transitions must live on the NavHost call site, not on the destinations:
114
- // NavHost.uikit computes isDefaultTransition from these arguments alone, and while
115
- // it is true the library paints its own hard-coded black iOS blackout that would
116
- // sit over the pink scrim.
117
127
  NavHost(
118
128
  navController,
119
129
  startDestination = startDestination,
@@ -163,8 +173,7 @@ fun NavigationContainer(
163
173
  id = route.id,
164
174
  name = screen.name,
165
175
  content = screen.content,
166
- navigationOptions = screen.options,
167
- isDialogRoute = true
176
+ navigationOptions = screen.options
168
177
  )
169
178
  }
170
179
  }
@@ -4,15 +4,11 @@ import androidx.compose.foundation.layout.Box
4
4
  import androidx.compose.foundation.layout.fillMaxSize
5
5
  import androidx.compose.runtime.Composable
6
6
  import androidx.compose.runtime.MutableState
7
- import androidx.compose.runtime.collectAsState
8
- import androidx.compose.runtime.getValue
9
7
  import androidx.compose.runtime.mutableStateOf
10
8
  import androidx.compose.runtime.staticCompositionLocalOf
11
9
  import androidx.compose.ui.Alignment
12
10
  import androidx.compose.ui.Modifier
13
- import androidx.navigation.NavBackStackEntry
14
11
  import androidx.navigation.NavController
15
- import androidx.navigation.NavGraph
16
12
  import androidx.navigation.toRoute
17
13
  import kotlinx.coroutines.CoroutineScope
18
14
  import kotlinx.coroutines.Dispatchers
@@ -35,6 +31,9 @@ class Navigator(
35
31
  ) {
36
32
  private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
37
33
 
34
+ // currentScreen() reads the root back stack, so it never sees the nested tab NavHost.
35
+ internal var tabBackHandler: (() -> Unit)? = null
36
+
38
37
  private fun currentScreen(): DynamicScreen? {
39
38
  val entry = navController.currentBackStackEntry ?: return null
40
39
  val id = runCatching { entry.toRoute<DynamicScreenRoute>().id }.getOrNull()
@@ -56,6 +55,10 @@ class Navigator(
56
55
  }
57
56
 
58
57
  fun onBackSafe(callBack: (() -> Unit)? = null) {
58
+ tabBackHandler?.let {
59
+ it()
60
+ return
61
+ }
59
62
  val currentScreen = currentScreen()
60
63
  val options = currentScreen?.options
61
64
  if (options?.onBackHandler != null) {
@@ -83,21 +86,9 @@ class Navigator(
83
86
  }
84
87
 
85
88
  // The swipe-back opt-out predicate in StackScreen needs this: the root screen must keep custom
86
- // back handling so it still reaches maxApi.dismiss. It has to read currentBackStack rather than
87
- // previousBackStackEntry — the latter walks a plain ArrayDeque, so a composable reading it never
88
- // recomposes and the predicate would freeze at the value it had on first composition.
89
- @Composable
90
- internal fun rememberCanPop(): Boolean {
91
- val backStack by navController.currentBackStack.collectAsState()
92
- return backStack.hasPoppableEntry()
93
- }
94
-
95
- /**
96
- * Non-composable twin of [rememberCanPop] for the iOS host: its own back check is mount-based
97
- * and so answers `true` even on the mini-app root, where a swipe must dismiss rather than pop.
98
- * Reads the StateFlow value directly — correct here precisely because nothing recomposes on it.
99
- */
100
- fun canPop(): Boolean = navController.currentBackStack.value.hasPoppableEntry()
89
+ // back handling so it still reaches maxApi.dismiss.
90
+ // Public: the iOS host asks this to decide whether the kit owns the swipe on this screen.
91
+ fun canPop(): Boolean = navController.previousBackStackEntry != null
101
92
 
102
93
  fun pop(count: Int = 1, callBack: (() -> Unit)? = null) {
103
94
  scope.launch {
@@ -196,10 +187,6 @@ class Navigator(
196
187
  }
197
188
  }
198
189
 
199
- // The NavGraph itself sits in the back stack as an entry; only real destinations can be popped.
200
- private fun List<NavBackStackEntry>.hasPoppableEntry(): Boolean =
201
- count { it.destination !is NavGraph } > 1
202
-
203
190
  val LocalNavigator = staticCompositionLocalOf<Navigator> {
204
191
  error("No NavigationStack provided")
205
192
  }
@@ -93,7 +93,6 @@ internal fun StackScreen(
93
93
  id: Int = -1,
94
94
  name: String = "",
95
95
  bottomTabIndex: Int = -1,
96
- isDialogRoute: Boolean = false,
97
96
  onBackHandler: (() -> Unit)? = null,
98
97
  ) {
99
98
  val navigator = LocalNavigator.current
@@ -170,29 +169,13 @@ internal fun StackScreen(
170
169
  RegisterScrollToTop(navigation.options.scrollData.scrollToTopCallback)
171
170
  }
172
171
 
173
- // Hoisted out of the `||` chain below: short-circuiting would drop the back-stack subscription
174
- // whenever an earlier term is true.
175
- val canPop = navigator.rememberCanPop()
172
+ val canPop = navigator.canPop()
176
173
 
177
- // Port of RN's `hasCustomBackHandling`: this handler is registered inside the NavHost
178
- // destination, so while it is enabled it beats NavHost's own predictive-back handler and
179
- // swallows the swipe. Enable it only when a plain pop is the wrong answer.
180
- //
181
- // A presented route is one of those cases: NavHost arms predictive back on back-stack size
182
- // alone, so a horizontal edge drag over a dialog would seek its *vertical* slide.
183
174
  val hasCustomBackHandling = !SwipeBackConfig.enabled ||
184
- isBottomTabChild ||
185
- isDialogRoute ||
186
- options.onBackHandler != null ||
187
- options.headerBackProps.preventBack != null ||
188
175
  OverplayComponentRegistry.isOverplayShowing() ||
189
176
  !canPop
190
177
 
191
- // BottomTab supplies a tab-aware handler; falling through to onBackSafe() there would dismiss
192
- // the whole mini-app instead of returning to tab 0.
193
- val onBack = onBackHandler ?: { navigator.onBackSafe() }
194
-
195
- BackHandler(hasCustomBackHandling, onBack)
178
+ BackHandler(hasCustomBackHandling) { navigator.onBackSafe() }
196
179
 
197
180
  CompositionLocalProvider(
198
181
  StackScreenScrollableState provides options.scrollData.scrollState,
@@ -321,7 +304,10 @@ internal fun ScreenContent(content: @Composable () -> Unit) {
321
304
  if (options.headerType is HeaderType.Animated) {
322
305
  val animatedHeader = options.headerType
323
306
  Box {
324
- Box(Modifier.fillMaxWidth().aspectRatio(animatedHeader.aspectRatio.value)) {
307
+ val bannerModifier = animatedHeader.height
308
+ ?.let { Modifier.fillMaxWidth().height(it) }
309
+ ?: Modifier.fillMaxWidth().aspectRatio(animatedHeader.aspectRatio.value)
310
+ Box(bannerModifier) {
325
311
  animatedHeader.composable.invoke(scrollState.value)
326
312
  }
327
313
  Box(Modifier.padding(top = AppStatusBar.current + HEADER_HEIGHT.dp + animatedHeader.layoutOffSet)) {
@@ -342,7 +328,8 @@ internal fun FooterContent() {
342
328
  Footer(
343
329
  footerComponent = options.footerComponent,
344
330
  bottomPadding = bottomPadding,
345
- transparent = options.footerTransparent
331
+ transparent = options.footerTransparent,
332
+ useMargin = options.footerUseMargin
346
333
  )
347
334
  }
348
335
  }
@@ -367,7 +354,12 @@ internal fun OverplayView(isBottomTabChild: Boolean, id: Int) {
367
354
  }
368
355
 
369
356
  @Composable
370
- fun Footer(footerComponent: @Composable (() -> Unit)?, bottomPadding: Dp, transparent: Boolean = false) {
357
+ fun Footer(
358
+ footerComponent: @Composable (() -> Unit)?,
359
+ bottomPadding: Dp,
360
+ transparent: Boolean = false,
361
+ useMargin: Boolean = true,
362
+ ) {
371
363
  if (footerComponent == null) return
372
364
 
373
365
  val footerHeightPx = LocalFooterHeightPx.current
@@ -390,7 +382,12 @@ fun Footer(footerComponent: @Composable (() -> Unit)?, bottomPadding: Dp, transp
390
382
  .conditional(IsShowBaseLineDebug) {
391
383
  border(1.dp, Colors.blue_03)
392
384
  }
393
- .padding(top = Spacing.S, start = Spacing.M, end = Spacing.M, bottom = bottomPadding + Spacing.S)
385
+ .padding(
386
+ top = if (useMargin) Spacing.S else 0.dp,
387
+ start = if (useMargin) Spacing.M else 0.dp,
388
+ end = if (useMargin) Spacing.M else 0.dp,
389
+ bottom = bottomPadding + if (useMargin) Spacing.S else 0.dp,
390
+ )
394
391
  ) {
395
392
  footerComponent.invoke()
396
393
  }