@momo-kits/native-kits 0.164.1-trustsans-12-debug → 0.165.1-beta.10-debug

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/.codegraph/codegraph.db +0 -0
  2. package/.codegraph/daemon.log +100 -0
  3. package/CLAUDE.md +1 -0
  4. package/compose/build.gradle.kts +1 -1
  5. package/compose/compose.podspec +1 -1
  6. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/BackGesture.android.kt +12 -0
  7. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +18 -0
  8. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +30 -4
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +104 -22
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +3 -9
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Title.kt +8 -4
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TrustBanner.kt +2 -1
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +3 -0
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +66 -31
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +22 -1
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +25 -5
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackTransition.kt +148 -0
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/SwipeBackHaptic.kt +35 -0
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +19 -0
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +13 -2
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +106 -0
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +75 -0
  23. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/BackGesture.kt +12 -0
  24. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/HostBackGesture.ios.kt +98 -0
  25. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/BackGesture.ios.kt +38 -0
  26. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +13 -3
  27. package/gradle.properties +1 -1
  28. package/ios/native-kits.podspec +1 -1
  29. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  30. package/package.json +1 -1
  31. package/.codegraph/.gitignore +0 -5
@@ -31,6 +31,9 @@ class Navigator(
31
31
  ) {
32
32
  private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
33
33
 
34
+ // currentScreen() reads the root back stack, so it never sees the nested tab NavHost.
35
+ internal var tabBackHandler: (() -> Unit)? = null
36
+
34
37
  private fun currentScreen(): DynamicScreen? {
35
38
  val entry = navController.currentBackStackEntry ?: return null
36
39
  val id = runCatching { entry.toRoute<DynamicScreenRoute>().id }.getOrNull()
@@ -52,6 +55,10 @@ class Navigator(
52
55
  }
53
56
 
54
57
  fun onBackSafe(callBack: (() -> Unit)? = null) {
58
+ tabBackHandler?.let {
59
+ it()
60
+ return
61
+ }
55
62
  val currentScreen = currentScreen()
56
63
  val options = currentScreen?.options
57
64
  if (options?.onBackHandler != null) {
@@ -78,6 +85,11 @@ class Navigator(
78
85
  }
79
86
  }
80
87
 
88
+ // The swipe-back opt-out predicate in StackScreen needs this: the root screen must keep custom
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
92
+
81
93
  fun pop(count: Int = 1, callBack: (() -> Unit)? = null) {
82
94
  scope.launch {
83
95
  repeat(count) {
@@ -104,7 +116,7 @@ class Navigator(
104
116
  if (navController.previousBackStackEntry != null){
105
117
  val dismissing = currentScreen()
106
118
  navController.popBackStack()
107
- delay(300L.milliseconds)
119
+ delay(STACK_TRANSITION_DURATION.milliseconds)
108
120
  dismissing?.let { registry.unregisterScreen(it.id) }
109
121
  } else {
110
122
  maxApi?.dismiss { }
@@ -222,6 +234,15 @@ class DynamicScreenRegistry {
222
234
  screens.remove(id)
223
235
  }
224
236
 
237
+ // Gesture pops go through composeNavigator.popBackStack() inside NavHost and never reach
238
+ // Navigator.dismissScreen(), so screens would leak without a stack-driven prune.
239
+ fun retainOnly(ids: Set<Int>) {
240
+ val stale = screens.keys.filter { it !in ids }
241
+ for (id in stale) {
242
+ screens.remove(id)
243
+ }
244
+ }
245
+
225
246
  fun unregisterAll(){
226
247
  val ids = screens.keys.toList()
227
248
  for (id in ids) {
@@ -169,7 +169,13 @@ internal fun StackScreen(
169
169
  RegisterScrollToTop(navigation.options.scrollData.scrollToTopCallback)
170
170
  }
171
171
 
172
- BackHandler(true) { navigator.onBackSafe() }
172
+ val canPop = navigator.canPop()
173
+
174
+ val hasCustomBackHandling = !SwipeBackConfig.enabled ||
175
+ OverplayComponentRegistry.isOverplayShowing() ||
176
+ !canPop
177
+
178
+ BackHandler(hasCustomBackHandling) { navigator.onBackSafe() }
173
179
 
174
180
  CompositionLocalProvider(
175
181
  StackScreenScrollableState provides options.scrollData.scrollState,
@@ -298,7 +304,10 @@ internal fun ScreenContent(content: @Composable () -> Unit) {
298
304
  if (options.headerType is HeaderType.Animated) {
299
305
  val animatedHeader = options.headerType
300
306
  Box {
301
- 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) {
302
311
  animatedHeader.composable.invoke(scrollState.value)
303
312
  }
304
313
  Box(Modifier.padding(top = AppStatusBar.current + HEADER_HEIGHT.dp + animatedHeader.layoutOffSet)) {
@@ -319,7 +328,8 @@ internal fun FooterContent() {
319
328
  Footer(
320
329
  footerComponent = options.footerComponent,
321
330
  bottomPadding = bottomPadding,
322
- transparent = options.footerTransparent
331
+ transparent = options.footerTransparent,
332
+ useMargin = options.footerUseMargin
323
333
  )
324
334
  }
325
335
  }
@@ -344,7 +354,12 @@ internal fun OverplayView(isBottomTabChild: Boolean, id: Int) {
344
354
  }
345
355
 
346
356
  @Composable
347
- 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
+ ) {
348
363
  if (footerComponent == null) return
349
364
 
350
365
  val footerHeightPx = LocalFooterHeightPx.current
@@ -367,7 +382,12 @@ fun Footer(footerComponent: @Composable (() -> Unit)?, bottomPadding: Dp, transp
367
382
  .conditional(IsShowBaseLineDebug) {
368
383
  border(1.dp, Colors.blue_03)
369
384
  }
370
- .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
+ )
371
391
  ) {
372
392
  footerComponent.invoke()
373
393
  }
@@ -0,0 +1,148 @@
1
+ package vn.momo.kits.navigation
2
+
3
+ import androidx.compose.animation.AnimatedContentTransitionScope
4
+ import androidx.compose.animation.AnimatedVisibilityScope
5
+ import androidx.compose.animation.EnterExitState
6
+ import androidx.compose.animation.EnterTransition
7
+ import androidx.compose.animation.ExitTransition
8
+ import androidx.compose.animation.core.LinearEasing
9
+ import androidx.compose.animation.core.animateFloat
10
+ import androidx.compose.animation.core.tween
11
+ import androidx.compose.animation.slideInHorizontally
12
+ import androidx.compose.animation.slideOutHorizontally
13
+ import androidx.compose.foundation.layout.Box
14
+ import androidx.compose.foundation.layout.fillMaxSize
15
+ import androidx.compose.runtime.Composable
16
+ import androidx.compose.runtime.DisposableEffect
17
+ import androidx.compose.runtime.Stable
18
+ import androidx.compose.runtime.derivedStateOf
19
+ import androidx.compose.runtime.getValue
20
+ import androidx.compose.runtime.mutableStateMapOf
21
+ import androidx.compose.runtime.remember
22
+ import androidx.compose.runtime.staticCompositionLocalOf
23
+ import androidx.compose.ui.Modifier
24
+ import androidx.compose.ui.draw.drawBehind
25
+ import androidx.compose.ui.geometry.Offset
26
+ import androidx.compose.ui.geometry.Size
27
+ import androidx.compose.ui.graphics.Brush
28
+ import androidx.compose.ui.graphics.Color
29
+ import androidx.compose.ui.platform.LocalDensity
30
+ import androidx.compose.ui.unit.IntOffset
31
+ import androidx.compose.ui.unit.dp
32
+ import androidx.navigation.NavBackStackEntry
33
+ import androidx.navigation.NavDestination.Companion.hasRoute
34
+ import vn.momo.kits.const.Colors
35
+ import kotlin.math.roundToInt
36
+
37
+ internal const val STACK_TRANSITION_DURATION = 300
38
+
39
+ // Ported 1:1 from the RN kit's `forStackTransitionWithOverlay` so both kits look identical.
40
+ private const val PARALLAX_RATIO = 0.3f
41
+ private const val SCRIM_MAX_ALPHA = 0.6f
42
+ private const val EDGE_SHADOW_MAX_ALPHA = 0.3f
43
+ private val EDGE_SHADOW_WIDTH = 8.dp
44
+
45
+ /** Kill switch: `false` restores pre-DS-760 back handling without a kit release. Transitions
46
+ * stay on the new curve either way. */
47
+ object SwipeBackConfig {
48
+ var enabled: Boolean = true
49
+ }
50
+
51
+ // LinearEasing is mandatory: seekTo() maps drag fraction onto the timeline, so a curve would
52
+ // decouple the card from the finger.
53
+ private val stackSpec = tween<IntOffset>(STACK_TRANSITION_DURATION, easing = LinearEasing)
54
+
55
+ internal fun AnimatedContentTransitionScope<NavBackStackEntry>.stackEnter(): EnterTransition =
56
+ slideInHorizontally(stackSpec) { it }
57
+
58
+ internal fun AnimatedContentTransitionScope<NavBackStackEntry>.stackExit(): ExitTransition =
59
+ if (targetState.destination.hasRoute<DynamicDialogRoute>()) ExitTransition.None
60
+ else slideOutHorizontally(stackSpec) { -(it * PARALLAX_RATIO).roundToInt() }
61
+
62
+ internal fun AnimatedContentTransitionScope<NavBackStackEntry>.stackPopEnter(): EnterTransition =
63
+ if (initialState.destination.hasRoute<DynamicDialogRoute>()) EnterTransition.None
64
+ else slideInHorizontally(stackSpec) { -(it * PARALLAX_RATIO).roundToInt() }
65
+
66
+ internal fun AnimatedContentTransitionScope<NavBackStackEntry>.stackPopExit(): ExitTransition =
67
+ slideOutHorizontally(stackSpec) { it }
68
+
69
+ /**
70
+ * Which composed screen is the top card. Ids are monotonic and NavHost keeps both entries composed
71
+ * for the whole segment, so "highest id" is stable both ways. `currentBackStackEntry` is not: it
72
+ * flips at frame 0 of a pop and the scrim would vanish instead of fading.
73
+ */
74
+ @Stable
75
+ internal class StackTransitionCoordinator {
76
+ private val composedRoutes = mutableStateMapOf<Int, Boolean>()
77
+
78
+ val topStackId: Int by derivedStateOf {
79
+ composedRoutes.entries.filter { it.value }.maxOfOrNull { it.key } ?: -1
80
+ }
81
+
82
+ fun attach(id: Int, isStackRoute: Boolean) {
83
+ composedRoutes[id] = isStackRoute
84
+ }
85
+
86
+ fun detach(id: Int) {
87
+ composedRoutes.remove(id)
88
+ }
89
+ }
90
+
91
+ internal val LocalStackTransition = staticCompositionLocalOf { StackTransitionCoordinator() }
92
+
93
+ /** Draws the covered-screen scrim and the top card's left-edge shadow around a stack destination. */
94
+ @Composable
95
+ internal fun AnimatedVisibilityScope.StackTransitionDecor(
96
+ id: Int,
97
+ isStackRoute: Boolean,
98
+ content: @Composable () -> Unit,
99
+ ) {
100
+ val coordinator = LocalStackTransition.current
101
+ coordinator.attach(id, isStackRoute)
102
+ DisposableEffect(coordinator, id) {
103
+ onDispose { coordinator.detach(id) }
104
+ }
105
+
106
+ val isTopCard = isStackRoute && id == coordinator.topStackId
107
+ val isCovered = isStackRoute && !isTopCard
108
+
109
+ val scrimAlpha = transition.animateFloat(
110
+ transitionSpec = { tween(STACK_TRANSITION_DURATION, easing = LinearEasing) },
111
+ label = "stackScrimAlpha",
112
+ ) { state -> if (isCovered && state != EnterExitState.Visible) SCRIM_MAX_ALPHA else 0f }
113
+
114
+ val shadowAlpha = transition.animateFloat(
115
+ transitionSpec = { tween(STACK_TRANSITION_DURATION, easing = LinearEasing) },
116
+ label = "stackEdgeShadowAlpha",
117
+ ) { state -> if (isTopCard && state == EnterExitState.Visible) EDGE_SHADOW_MAX_ALPHA else 0f }
118
+
119
+ val shadowWidthPx = with(LocalDensity.current) { EDGE_SHADOW_WIDTH.toPx() }
120
+ val shadowBrush = remember(shadowWidthPx) {
121
+ Brush.horizontalGradient(
122
+ colors = listOf(Color.Transparent, Colors.black_20),
123
+ startX = -shadowWidthPx,
124
+ endX = 0f,
125
+ )
126
+ }
127
+
128
+ Box(Modifier.fillMaxSize()) {
129
+ content()
130
+
131
+ if (isStackRoute) {
132
+ Box(
133
+ Modifier
134
+ .matchParentSize()
135
+ // read inside drawBehind: in the body this would recompose every gesture frame
136
+ .drawBehind {
137
+ drawRect(
138
+ brush = shadowBrush,
139
+ topLeft = Offset(-shadowWidthPx, 0f),
140
+ size = Size(shadowWidthPx, size.height),
141
+ alpha = shadowAlpha.value,
142
+ )
143
+ drawRect(Colors.pink_09, alpha = scrimAlpha.value)
144
+ }
145
+ )
146
+ }
147
+ }
148
+ }
@@ -0,0 +1,35 @@
1
+ package vn.momo.kits.navigation
2
+
3
+ import androidx.compose.runtime.Composable
4
+ import androidx.compose.runtime.LaunchedEffect
5
+ import androidx.compose.runtime.snapshotFlow
6
+ import vn.momo.kits.platform.getScreenDimensions
7
+ import vn.momo.kits.platform.rememberBackGestureProgress
8
+ import vn.momo.maxapi.IMaxApi
9
+
10
+ // Unscaled, so the tick lands at the same physical distance on every device.
11
+ private const val SWIPE_BACK_HAPTIC_THRESHOLD_DP = 50f
12
+
13
+ /** One light tick per crossing, re-armed below the threshold. iOS only. */
14
+ @Composable
15
+ internal fun SwipeBackHaptic(maxApi: IMaxApi?) {
16
+ if (maxApi == null) return
17
+
18
+ val progress = rememberBackGestureProgress()
19
+ val screenWidth = getScreenDimensions().width.toFloat()
20
+
21
+ LaunchedEffect(maxApi, screenWidth) {
22
+ if (screenWidth <= 0f) return@LaunchedEffect
23
+ val threshold = SWIPE_BACK_HAPTIC_THRESHOLD_DP / screenWidth
24
+ var isSendHaptic = false
25
+ snapshotFlow { progress.value }.collect { fraction ->
26
+ val travelled = fraction ?: 0f
27
+ if (travelled >= threshold && !isSendHaptic) {
28
+ runCatching { maxApi.triggerEventVibration("light", callback = null) }
29
+ isSendHaptic = true
30
+ } else if (travelled < threshold && isSendHaptic) {
31
+ isSendHaptic = false
32
+ }
33
+ }
34
+ }
35
+ }
@@ -8,12 +8,14 @@ import androidx.compose.animation.scaleOut
8
8
  import androidx.compose.foundation.background
9
9
  import androidx.compose.foundation.layout.*
10
10
  import androidx.compose.runtime.Composable
11
+ import androidx.compose.runtime.DisposableEffect
11
12
  import androidx.compose.runtime.LaunchedEffect
12
13
  import androidx.compose.ui.Alignment
13
14
  import androidx.compose.ui.Modifier
14
15
  import androidx.compose.ui.unit.dp
15
16
  import androidx.navigation.compose.NavHost
16
17
  import androidx.navigation.compose.composable
18
+ import androidx.navigation.compose.currentBackStackEntryAsState
17
19
  import androidx.navigation.compose.rememberNavController
18
20
  import vn.momo.kits.const.AppNavigationBar
19
21
  import vn.momo.kits.const.AppTheme
@@ -24,6 +26,7 @@ import vn.momo.kits.navigation.LocalNavigator
24
26
  import vn.momo.kits.navigation.NavigationOptions
25
27
  import vn.momo.kits.navigation.StackScreen
26
28
  import vn.momo.kits.navigation.component.HeaderType
29
+ import vn.momo.kits.platform.reportFloatingArrowBack
27
30
 
28
31
  private var bottomTabOptionItems : MutableList<NavigationOptions?> = mutableListOf()
29
32
  internal fun setBottomTabOption(index: Int, options: NavigationOptions){
@@ -59,6 +62,22 @@ fun BottomTab(
59
62
  )
60
63
  }
61
64
 
65
+ // Tab 0 keeps the seeked transition; other tabs go to the host's floating arrow. Matches RN.
66
+ val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
67
+ val isFirstTab = currentRoute == null || currentRoute == "option0"
68
+
69
+ DisposableEffect(isFirstTab) {
70
+ reportFloatingArrowBack(!isFirstTab)
71
+ navigator.tabBackHandler = if (isFirstTab) null else ({
72
+ navController.popBackStack(navController.graph.startDestinationRoute!!, false)
73
+ })
74
+ // both live outside this composition
75
+ onDispose {
76
+ reportFloatingArrowBack(false)
77
+ navigator.tabBackHandler = null
78
+ }
79
+ }
80
+
62
81
  Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) {
63
82
  Box(modifier = Modifier
64
83
  .fillMaxSize()
@@ -191,8 +191,18 @@ internal fun HeaderContent(
191
191
  textAlign = if (titlePosition == TitlePosition.CENTER) TextAlign.Center else TextAlign.Start
192
192
  )
193
193
  }
194
- is HeaderTitle.Journey -> {}
195
- is HeaderTitle.Location -> {}
194
+ is HeaderTitle.Journey -> {
195
+ HeaderJourney(
196
+ data = headerTitle,
197
+ tintIconColor = tintIconColor
198
+ )
199
+ }
200
+ is HeaderTitle.Location -> {
201
+ HeaderLocation(
202
+ data = headerTitle,
203
+ tintIconColor = tintIconColor
204
+ )
205
+ }
196
206
  is HeaderTitle.User -> {
197
207
  HeaderUser(
198
208
  data = headerTitle
@@ -274,6 +284,7 @@ sealed class HeaderType {
274
284
  val aspectRatio: AnimatedHeaderRatio = AnimatedHeaderRatio.RATIO_16_9,
275
285
  val isSurface: Boolean = true,
276
286
  val layoutOffSet: Dp = 56.dp,
287
+ val height: Dp? = null,
277
288
  val composable: @Composable (scrollState: Int) -> Unit = {}
278
289
  ) : HeaderType()
279
290
 
@@ -0,0 +1,106 @@
1
+ package vn.momo.kits.navigation.component
2
+
3
+ import androidx.compose.foundation.layout.Arrangement
4
+ import androidx.compose.foundation.layout.Box
5
+ import androidx.compose.foundation.layout.Column
6
+ import androidx.compose.foundation.layout.Row
7
+ import androidx.compose.foundation.layout.Spacer
8
+ import androidx.compose.foundation.layout.fillMaxWidth
9
+ import androidx.compose.foundation.layout.height
10
+ import androidx.compose.foundation.layout.width
11
+ import androidx.compose.foundation.layout.widthIn
12
+ import androidx.compose.foundation.shape.RoundedCornerShape
13
+ import androidx.compose.runtime.Composable
14
+ import androidx.compose.ui.Alignment
15
+ import androidx.compose.ui.Modifier
16
+ import androidx.compose.ui.draw.clip
17
+ import androidx.compose.ui.graphics.Color
18
+ import androidx.compose.ui.text.style.TextOverflow
19
+ import androidx.compose.ui.unit.dp
20
+ import vn.momo.kits.components.Icon
21
+ import vn.momo.kits.components.Skeleton
22
+ import vn.momo.kits.components.Text
23
+ import vn.momo.kits.const.Spacing
24
+ import vn.momo.kits.const.Typography
25
+ import vn.momo.kits.modifier.activeOpacityClickable
26
+ import vn.momo.kits.platform.getScreenDimensions
27
+
28
+ @Composable
29
+ internal fun HeaderJourney(
30
+ data: HeaderTitle,
31
+ tintIconColor: Color,
32
+ ) {
33
+ if (data !is HeaderTitle.Journey) return
34
+ if (data.isLoading) return TitleJourneyShimmer()
35
+
36
+ val maxWidth = getScreenDimensions().width.dp - 172.dp
37
+ val tint = parseHeaderTint(data.tintColor) ?: tintIconColor
38
+ val iconTint = parseHeaderTint(data.iconColor) ?: tint
39
+
40
+ Column(
41
+ modifier = Modifier
42
+ .widthIn(max = maxWidth)
43
+ .activeOpacityClickable(enabled = data.onPress != null, onClick = { data.onPress?.invoke() }),
44
+ verticalArrangement = Arrangement.Center,
45
+ ) {
46
+ Row(verticalAlignment = Alignment.CenterVertically) {
47
+ Text(
48
+ text = data.start,
49
+ color = tint,
50
+ style = Typography.actionXsBold,
51
+ maxLines = 1,
52
+ overflow = TextOverflow.Ellipsis,
53
+ modifier = Modifier.weight(1f, fill = false),
54
+ )
55
+ Spacer(Modifier.width(Spacing.S))
56
+ if (data.icon.isNotBlank()) {
57
+ Icon(source = data.icon, size = 16.dp, color = iconTint)
58
+ } else {
59
+ Spacer(Modifier.width(16.dp))
60
+ }
61
+ Spacer(Modifier.width(Spacing.S))
62
+ if (!data.end.isNullOrEmpty()) {
63
+ Text(
64
+ text = data.end,
65
+ color = tint,
66
+ style = Typography.actionXsBold,
67
+ maxLines = 1,
68
+ overflow = TextOverflow.Ellipsis,
69
+ modifier = Modifier.weight(1f, fill = false),
70
+ )
71
+ }
72
+ Spacer(Modifier.width(Spacing.XS))
73
+ Icon(source = "16_arrow_chevron_down_small", size = 16.dp, color = tint)
74
+ }
75
+ if (!data.description.isNullOrEmpty()) {
76
+ Text(
77
+ text = data.description,
78
+ color = tint,
79
+ style = Typography.descriptionXsRegular,
80
+ maxLines = 1,
81
+ overflow = TextOverflow.Ellipsis,
82
+ )
83
+ }
84
+ }
85
+ }
86
+
87
+ @Composable
88
+ private fun TitleJourneyShimmer() {
89
+ Column(modifier = Modifier.fillMaxWidth()) {
90
+ Box(modifier = Modifier.height(18.dp).width(120.dp).clip(RoundedCornerShape(8.dp))) { Skeleton() }
91
+ Spacer(Modifier.height(Spacing.XXS))
92
+ Box(modifier = Modifier.height(12.dp).width(120.dp).clip(RoundedCornerShape(8.dp))) { Skeleton() }
93
+ }
94
+ }
95
+
96
+ internal fun parseHeaderTint(value: String?): Color? {
97
+ val raw = value?.trim()?.removePrefix("#") ?: return null
98
+ val hex = when (raw.length) {
99
+ 3 -> "FF" + raw.map { "$it$it" }.joinToString("")
100
+ 6 -> "FF$raw"
101
+ 8 -> raw
102
+ else -> return null
103
+ }
104
+ val v = hex.toLongOrNull(16) ?: return null
105
+ return Color(v)
106
+ }
@@ -0,0 +1,75 @@
1
+ package vn.momo.kits.navigation.component
2
+
3
+ import androidx.compose.foundation.layout.Box
4
+ import androidx.compose.foundation.layout.Column
5
+ import androidx.compose.foundation.layout.Row
6
+ import androidx.compose.foundation.layout.Spacer
7
+ import androidx.compose.foundation.layout.fillMaxWidth
8
+ import androidx.compose.foundation.layout.height
9
+ import androidx.compose.foundation.layout.width
10
+ import androidx.compose.foundation.layout.widthIn
11
+ import androidx.compose.foundation.shape.RoundedCornerShape
12
+ import androidx.compose.runtime.Composable
13
+ import androidx.compose.ui.Alignment
14
+ import androidx.compose.ui.Modifier
15
+ import androidx.compose.ui.draw.clip
16
+ import androidx.compose.ui.graphics.Color
17
+ import androidx.compose.ui.text.style.TextOverflow
18
+ import androidx.compose.ui.unit.dp
19
+ import vn.momo.kits.components.Icon
20
+ import vn.momo.kits.components.Skeleton
21
+ import vn.momo.kits.components.Text
22
+ import vn.momo.kits.const.Spacing
23
+ import vn.momo.kits.const.Typography
24
+ import vn.momo.kits.modifier.activeOpacityClickable
25
+ import vn.momo.kits.platform.getScreenDimensions
26
+
27
+ @Composable
28
+ internal fun HeaderLocation(
29
+ data: HeaderTitle,
30
+ tintIconColor: Color,
31
+ ) {
32
+ if (data !is HeaderTitle.Location) return
33
+ if (data.isLoading) return TitleLocationShimmer()
34
+
35
+ val maxWidth = getScreenDimensions().width.dp - 172.dp
36
+ val tint = parseHeaderTint(data.tintColor) ?: tintIconColor
37
+
38
+ Column(
39
+ modifier = Modifier
40
+ .widthIn(max = maxWidth)
41
+ .activeOpacityClickable(enabled = data.onPress != null, onClick = { data.onPress?.invoke() }),
42
+ horizontalAlignment = Alignment.Start,
43
+ ) {
44
+ if (!data.description.isNullOrEmpty()) {
45
+ Text(
46
+ text = data.description,
47
+ color = tint,
48
+ style = Typography.descriptionXsRegular,
49
+ maxLines = 1,
50
+ overflow = TextOverflow.Ellipsis,
51
+ )
52
+ }
53
+ Row(verticalAlignment = Alignment.CenterVertically) {
54
+ Text(
55
+ text = data.location,
56
+ color = tint,
57
+ style = Typography.actionXsBold,
58
+ maxLines = 1,
59
+ overflow = TextOverflow.Ellipsis,
60
+ modifier = Modifier.weight(1f, fill = false),
61
+ )
62
+ Spacer(Modifier.width(Spacing.XS))
63
+ Icon(source = "16_arrow_chevron_down_small", size = 16.dp, color = tint)
64
+ }
65
+ }
66
+ }
67
+
68
+ @Composable
69
+ private fun TitleLocationShimmer() {
70
+ Column(modifier = Modifier.fillMaxWidth()) {
71
+ Box(modifier = Modifier.height(12.dp).width(120.dp).clip(RoundedCornerShape(8.dp))) { Skeleton() }
72
+ Spacer(Modifier.height(Spacing.XXS))
73
+ Box(modifier = Modifier.height(18.dp).width(120.dp).clip(RoundedCornerShape(8.dp))) { Skeleton() }
74
+ }
75
+ }
@@ -0,0 +1,12 @@
1
+ package vn.momo.kits.platform
2
+
3
+ import androidx.compose.runtime.Composable
4
+ import androidx.compose.runtime.State
5
+
6
+ /** Fraction of container width, `null` when idle. Observe only — consuming would steal the
7
+ * gesture from NavHost. Always `null` on Android. */
8
+ @Composable
9
+ expect fun rememberBackGestureProgress(): State<Float?>
10
+
11
+ /** Android is a no-op: no host-owned recogniser to hand the gesture to. */
12
+ expect fun reportFloatingArrowBack(shouldUse: Boolean)
@@ -0,0 +1,98 @@
1
+ package vn.momo.kits.navigation
2
+
3
+ import androidx.compose.runtime.Composable
4
+ import androidx.compose.runtime.DisposableEffect
5
+ import androidx.compose.runtime.remember
6
+ import androidx.navigationevent.NavigationEvent
7
+ import androidx.navigationevent.NavigationEventDispatcher
8
+ import androidx.navigationevent.NavigationEventInput
9
+
10
+ /**
11
+ * Kit end of the host's edge-pan recogniser. The host owns the only recogniser on iOS and replays
12
+ * its drag here; events reach NavHost's `PredictiveBackHandler` and seek the stack transition.
13
+ *
14
+ * **Call every method from the main thread** — nothing here is synchronised, and [attachedInputs]
15
+ * is also written from composition.
16
+ */
17
+ object HostBackGestureBridge {
18
+ private val attachedInputs = mutableListOf<HostNavigationEventInput>()
19
+ private var gestureInput: HostNavigationEventInput? = null
20
+
21
+ /** `false` means no container is mounted, or it opted out — host falls back to its own nav. */
22
+ val hasEnabledHandlers: Boolean
23
+ get() = attachedInputs.lastOrNull()?.hasEnabledHandlers == true
24
+
25
+ /** Set on tabs other than the first; host pulls it when its recogniser begins. */
26
+ var shouldUseFloatingArrowBack: Boolean = false
27
+ internal set
28
+
29
+ /** Begins a drag. Pairs with exactly one [completed] or [cancelled]. */
30
+ fun started() {
31
+ val input = attachedInputs.lastOrNull() ?: return
32
+ gestureInput = input
33
+ input.backStarted()
34
+ }
35
+
36
+ /** Drag progress in `0f..1f` — the fraction of the screen width travelled. */
37
+ fun progressed(progress: Float) {
38
+ gestureInput?.backProgressed(progress.coerceIn(0f, 1f))
39
+ }
40
+
41
+ /** Falls back to the attached input when no drag was started: a host that commits without
42
+ * ever seeking still needs its back. */
43
+ fun completed() {
44
+ (gestureInput ?: attachedInputs.lastOrNull())?.backCompleted()
45
+ gestureInput = null
46
+ }
47
+
48
+ /** Released below the commit threshold: the screen springs back. */
49
+ fun cancelled() {
50
+ gestureInput?.backCancelled()
51
+ gestureInput = null
52
+ }
53
+
54
+ internal fun attach(input: HostNavigationEventInput) {
55
+ attachedInputs += input
56
+ }
57
+
58
+ internal fun detach(input: HostNavigationEventInput) {
59
+ attachedInputs -= input
60
+ // else a container unmounted mid-drag hands its terminal event to whoever is on top now
61
+ if (gestureInput === input) gestureInput = null
62
+ }
63
+ }
64
+
65
+ /** Wires one [NavigationContainer]'s dispatcher to [HostBackGestureBridge] while it is composed. */
66
+ @Composable
67
+ internal fun AttachHostBackGesture(dispatcher: NavigationEventDispatcher) {
68
+ val input = remember(dispatcher) { HostNavigationEventInput() }
69
+
70
+ DisposableEffect(dispatcher, input) {
71
+ dispatcher.addInput(input)
72
+ HostBackGestureBridge.attach(input)
73
+ onDispose {
74
+ HostBackGestureBridge.detach(input)
75
+ dispatcher.removeInput(input)
76
+ }
77
+ }
78
+ }
79
+
80
+ internal class HostNavigationEventInput : NavigationEventInput() {
81
+ var hasEnabledHandlers: Boolean = false
82
+ private set
83
+
84
+ override fun onHasEnabledHandlersChanged(hasEnabledHandlers: Boolean) {
85
+ this.hasEnabledHandlers = hasEnabledHandlers
86
+ }
87
+
88
+ // Touch coordinates are not forwarded: progress alone seeks the transition.
89
+ fun backStarted() = dispatchOnBackStarted(NavigationEvent(swipeEdge = NavigationEvent.EDGE_LEFT))
90
+
91
+ fun backProgressed(progress: Float) = dispatchOnBackProgressed(
92
+ NavigationEvent(swipeEdge = NavigationEvent.EDGE_LEFT, progress = progress)
93
+ )
94
+
95
+ fun backCancelled() = dispatchOnBackCancelled()
96
+
97
+ fun backCompleted() = dispatchOnBackCompleted()
98
+ }