@momo-kits/native-kits 0.167.1-duo.2-debug → 0.167.1-duo.3-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 (48) hide show
  1. package/.claude/scheduled_tasks.lock +1 -0
  2. package/.claude/settings.local.json +185 -0
  3. package/CLAUDE.md +8 -3
  4. package/compose/build.gradle.kts +15 -1
  5. package/compose/build.gradle.kts.backup +14 -0
  6. package/compose/compose.podspec +1 -1
  7. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +2 -0
  8. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +52 -25
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +12 -2
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +45 -15
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +43 -4
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +8 -0
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +34 -8
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +81 -62
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +281 -0
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +31 -4
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +27 -2
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +17 -10
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +3 -0
  20. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +4 -0
  21. package/gradle.properties +1 -1
  22. package/ios/Application/HeaderRight.swift +25 -12
  23. package/ios/Application/Layout/ScreenLayout.swift +196 -0
  24. package/ios/Application/Navigation/BottomTab/BottomTab.swift +30 -9
  25. package/ios/Application/Navigation/BottomTab/BottomTabBar.swift +5 -2
  26. package/ios/Application/Navigation/BottomTab/DuoTabBar.swift +275 -0
  27. package/ios/Application/Navigation/HeaderUser.swift +4 -1
  28. package/ios/Application/Navigation/NavigationContainer.swift +3 -0
  29. package/ios/Application/Navigation/Overplay/BottomSheet.swift +41 -4
  30. package/ios/Application/Navigation/Overplay/ModalScreen.swift +5 -1
  31. package/ios/Application/Navigation/component/DuoScrollGradient.swift +55 -0
  32. package/ios/Application/Navigation/component/Header.swift +9 -3
  33. package/ios/Application/Navigation/component/HeaderBackground.swift +3 -2
  34. package/ios/Application/Navigation/component/NavigationFooter.swift +1 -1
  35. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +61 -9
  36. package/ios/Application/Navigation/component/SideBar.swift +155 -0
  37. package/ios/Application/StackScreen.swift +135 -18
  38. package/ios/Popup/PopupDisplay.swift +84 -68
  39. package/ios/Popup/PopupLayout.swift +45 -0
  40. package/ios/Popup/PopupNotify.swift +43 -32
  41. package/ios/Popup/PopupPromotion.swift +17 -5
  42. package/ios/native-kits.podspec +1 -1
  43. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  44. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +76 -0
  45. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +1 -0
  46. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +170 -0
  47. package/local.properties +8 -0
  48. package/package.json +1 -1
@@ -0,0 +1,281 @@
1
+ package vn.momo.kits.navigation.bottomtab
2
+
3
+ import androidx.compose.animation.core.Spring
4
+ import androidx.compose.animation.core.animateDpAsState
5
+ import androidx.compose.animation.core.spring
6
+ import androidx.compose.foundation.background
7
+ import androidx.compose.foundation.border
8
+ import androidx.compose.foundation.layout.Arrangement
9
+ import androidx.compose.foundation.layout.Box
10
+ import androidx.compose.foundation.layout.BoxScope
11
+ import androidx.compose.foundation.layout.BoxWithConstraints
12
+ import androidx.compose.foundation.layout.Column
13
+ import androidx.compose.foundation.layout.Row
14
+ import androidx.compose.foundation.layout.RowScope
15
+ import androidx.compose.foundation.layout.absolutePadding
16
+ import androidx.compose.foundation.layout.fillMaxHeight
17
+ import androidx.compose.foundation.layout.fillMaxSize
18
+ import androidx.compose.foundation.layout.height
19
+ import androidx.compose.foundation.layout.offset
20
+ import androidx.compose.foundation.layout.padding
21
+ import androidx.compose.foundation.layout.size
22
+ import androidx.compose.foundation.layout.width
23
+ import androidx.compose.foundation.shape.CircleShape
24
+ import androidx.compose.foundation.shape.RoundedCornerShape
25
+ import androidx.compose.runtime.Composable
26
+ import androidx.compose.runtime.compositionLocalOf
27
+ import androidx.compose.runtime.getValue
28
+ import androidx.compose.ui.AbsoluteAlignment
29
+ import androidx.compose.ui.Alignment
30
+ import androidx.compose.ui.Modifier
31
+ import androidx.compose.ui.draw.clip
32
+ import androidx.compose.ui.draw.shadow
33
+ import androidx.compose.ui.graphics.Color
34
+ import androidx.compose.ui.graphics.Shape
35
+ import androidx.compose.ui.text.style.TextOverflow
36
+ import androidx.compose.ui.unit.Dp
37
+ import androidx.compose.ui.unit.dp
38
+ import vn.momo.kits.components.Badge
39
+ import vn.momo.kits.components.BadgeDot
40
+ import vn.momo.kits.components.DotSize
41
+ import vn.momo.kits.components.Icon
42
+ import vn.momo.kits.components.Text
43
+ import vn.momo.kits.const.AppTheme
44
+ import vn.momo.kits.const.Radius
45
+ import vn.momo.kits.const.Spacing
46
+ import vn.momo.kits.const.Typography
47
+ import vn.momo.kits.layout.DuoMetrics
48
+ import vn.momo.kits.layout.ScreenLayout
49
+ import vn.momo.kits.layout.SideBarEdge
50
+ import vn.momo.kits.modifier.noFeedbackClickable
51
+ import vn.momo.kits.modifier.setAutomationId
52
+
53
+ // Figma Fold-Design-System MT1/MT2, mirrored from RN DuoTabBar.tsx.
54
+ private val ITEM_SIZE = DuoMetrics.controlSize
55
+ private val ICON_SIZE = 28.dp
56
+ private val QR_ICON_SIZE = 24.dp
57
+ private val RAIL_PADDING = 6.dp
58
+ private val RAIL_GAP = Spacing.XS
59
+ // selection bubble overflows the 48 rail cell by 5 above and below
60
+ private val RAIL_BUBBLE_WIDTH = 44.dp
61
+ private val RAIL_BUBBLE_HEIGHT = 58.dp
62
+ private val FLOATING_HEIGHT = 62.dp
63
+ private val FLOATING_PADDING = Spacing.XS
64
+ private val FLOATING_QR_SIZE = 44.dp
65
+ // floating bar spans 6 of the 8 portrait grid columns, like the content
66
+ private const val GRID_COLUMNS = 8
67
+ private const val GRID_SPAN = 6
68
+ // RN reanimated spring { damping: 18, stiffness: 220, mass: 0.8 }
69
+ private val BUBBLE_SPRING = spring<Dp>(dampingRatio = 0.68f, stiffness = 275f, visibilityThreshold = Dp(0.5f))
70
+
71
+ // Space from the window bottom the rail stack takes, so the header side bar stays above it.
72
+ internal val LocalDuoRailHeight = compositionLocalOf { 0.dp }
73
+
74
+ internal fun duoRailHeight(itemCount: Int, hasQr: Boolean): Dp =
75
+ Spacing.XL + RAIL_PADDING * 2 + ITEM_SIZE * itemCount + RAIL_GAP * (itemCount - 1).coerceAtLeast(0) +
76
+ if (hasQr) Spacing.M + ITEM_SIZE else 0.dp
77
+
78
+ // Space tab content leaves under the floating bar; 0 when the rail sits in the side strip or off Duo.
79
+ internal fun duoFloatingTabBarInset(layout: ScreenLayout): Dp =
80
+ if (layout.isIPhoneDuo && layout.sideBar == null) FLOATING_HEIGHT + Spacing.M + layout.insetBottom else 0.dp
81
+
82
+ // iPhone Duo tab bar: glass rail in the side bar, or a floating glass bar when unfolded portrait.
83
+ @Composable
84
+ internal fun BoxScope.DuoTabBar(
85
+ layout: ScreenLayout,
86
+ items: List<BottomTabItem>,
87
+ floatingButton: BottomTabFloatingButton?,
88
+ selectedIndex: Int,
89
+ onTabSelected: (Int) -> Unit,
90
+ ) {
91
+ if (layout.sideBar != null) {
92
+ DuoRailTabBar(layout, items, floatingButton, selectedIndex, onTabSelected)
93
+ } else {
94
+ DuoFloatingTabBar(layout, items, floatingButton, selectedIndex, onTabSelected)
95
+ }
96
+ }
97
+
98
+ @Composable
99
+ private fun BoxScope.DuoRailTabBar(
100
+ layout: ScreenLayout,
101
+ items: List<BottomTabItem>,
102
+ floatingButton: BottomTabFloatingButton?,
103
+ selectedIndex: Int,
104
+ onTabSelected: (Int) -> Unit,
105
+ ) {
106
+ val sideBar = layout.sideBar ?: return
107
+ val isRight = sideBar.edge == SideBarEdge.RIGHT
108
+ val bubbleOffset by animateDpAsState(
109
+ targetValue = (ITEM_SIZE + RAIL_GAP) * selectedIndex.coerceAtLeast(0),
110
+ animationSpec = BUBBLE_SPRING,
111
+ label = "DuoRailBubble",
112
+ )
113
+
114
+ Column(
115
+ modifier = Modifier
116
+ .align(if (isRight) AbsoluteAlignment.BottomRight else AbsoluteAlignment.BottomLeft)
117
+ .width(sideBar.width)
118
+ .padding(bottom = Spacing.XL)
119
+ .absolutePadding(left = if (isRight) Spacing.M else 0.dp, right = if (isRight) 0.dp else Spacing.M),
120
+ horizontalAlignment = if (isRight) AbsoluteAlignment.Left else AbsoluteAlignment.Right,
121
+ verticalArrangement = Arrangement.spacedBy(Spacing.M),
122
+ ) {
123
+ Box(
124
+ Modifier
125
+ .width(ITEM_SIZE)
126
+ .tabBarMaterial(RoundedCornerShape(Radius.XL))
127
+ .setAutomationId("bottom_tab_bar")
128
+ ) {
129
+ Box(
130
+ Modifier
131
+ .offset(
132
+ x = (ITEM_SIZE - RAIL_BUBBLE_WIDTH) / 2,
133
+ y = RAIL_PADDING - (RAIL_BUBBLE_HEIGHT - ITEM_SIZE) / 2 + bubbleOffset,
134
+ )
135
+ .size(RAIL_BUBBLE_WIDTH, RAIL_BUBBLE_HEIGHT)
136
+ .background(AppTheme.current.colors.background.selected, CircleShape)
137
+ )
138
+ Column(
139
+ modifier = Modifier.padding(vertical = RAIL_PADDING),
140
+ verticalArrangement = Arrangement.spacedBy(RAIL_GAP),
141
+ ) {
142
+ items.forEachIndexed { index, item ->
143
+ Box(
144
+ modifier = Modifier
145
+ .size(ITEM_SIZE)
146
+ .noFeedbackClickable { onTabSelected(index) },
147
+ contentAlignment = Alignment.Center,
148
+ ) {
149
+ DuoTabIcon(item, selected = index == selectedIndex)
150
+ }
151
+ }
152
+ }
153
+ }
154
+ if (floatingButton != null) {
155
+ QrButton(floatingButton, size = ITEM_SIZE)
156
+ }
157
+ }
158
+ }
159
+
160
+ @Composable
161
+ private fun BoxScope.DuoFloatingTabBar(
162
+ layout: ScreenLayout,
163
+ items: List<BottomTabItem>,
164
+ floatingButton: BottomTabFloatingButton?,
165
+ selectedIndex: Int,
166
+ onTabSelected: (Int) -> Unit,
167
+ ) {
168
+ val columnWidth = (layout.width - Spacing.M * 2 - Spacing.M * (GRID_COLUMNS - 1)) / GRID_COLUMNS
169
+ val barWidth = columnWidth * GRID_SPAN + Spacing.M * (GRID_SPAN - 1)
170
+ val useQr = floatingButton != null && items.size % 2 == 0
171
+ val middle = items.size / 2
172
+ val cellCount = items.size + if (useQr) 1 else 0
173
+ val visualIndex = if (useQr && selectedIndex >= middle) selectedIndex + 1 else selectedIndex
174
+
175
+ Box(
176
+ modifier = Modifier
177
+ .align(Alignment.BottomCenter)
178
+ .padding(bottom = layout.insetBottom + Spacing.M)
179
+ .width(barWidth)
180
+ .height(FLOATING_HEIGHT)
181
+ .tabBarMaterial(RoundedCornerShape(FLOATING_HEIGHT / 2))
182
+ .padding(FLOATING_PADDING)
183
+ .setAutomationId("bottom_tab_bar")
184
+ ) {
185
+ BoxWithConstraints(Modifier.fillMaxSize()) {
186
+ val cellWidth = maxWidth / cellCount
187
+ val bubbleOffset by animateDpAsState(
188
+ targetValue = cellWidth * visualIndex.coerceAtLeast(0),
189
+ animationSpec = BUBBLE_SPRING,
190
+ label = "DuoFloatingBubble",
191
+ )
192
+ Box(
193
+ Modifier
194
+ .offset(x = bubbleOffset)
195
+ .width(cellWidth)
196
+ .fillMaxHeight()
197
+ .background(AppTheme.current.colors.background.selected, CircleShape)
198
+ )
199
+ Row(Modifier.fillMaxSize()) {
200
+ items.forEachIndexed { index, item ->
201
+ if (useQr && index == middle) {
202
+ Box(Modifier.weight(1f).fillMaxHeight(), contentAlignment = Alignment.Center) {
203
+ QrButton(floatingButton, size = FLOATING_QR_SIZE)
204
+ }
205
+ }
206
+ FloatingCell(item, selected = index == selectedIndex) { onTabSelected(index) }
207
+ }
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ @Composable
214
+ private fun RowScope.FloatingCell(item: BottomTabItem, selected: Boolean, onClick: () -> Unit) {
215
+ val colors = AppTheme.current.colors
216
+ Column(
217
+ modifier = Modifier
218
+ .weight(1f)
219
+ .fillMaxHeight()
220
+ .noFeedbackClickable(onClick = onClick),
221
+ horizontalAlignment = Alignment.CenterHorizontally,
222
+ verticalArrangement = Arrangement.spacedBy(Spacing.XXS, Alignment.CenterVertically),
223
+ ) {
224
+ DuoTabIcon(item, selected)
225
+ Text(
226
+ text = item.label,
227
+ style = Typography.labelXsMedium,
228
+ color = if (selected) colors.primary else colors.text.default,
229
+ maxLines = 1,
230
+ overflow = TextOverflow.Ellipsis,
231
+ )
232
+ }
233
+ }
234
+
235
+ @Composable
236
+ private fun DuoTabIcon(item: BottomTabItem, selected: Boolean) {
237
+ val colors = AppTheme.current.colors
238
+ val badgeLabel = item.badgeLabel?.takeUnless { it.toIntOrNull() == 0 }
239
+ Box {
240
+ Icon(
241
+ source = item.icon,
242
+ size = ICON_SIZE,
243
+ color = if (selected) colors.primary else colors.text.default,
244
+ )
245
+ when {
246
+ badgeLabel == null && !item.showDot -> Unit
247
+ badgeLabel.isNullOrEmpty() -> BadgeDot(
248
+ size = DotSize.Small,
249
+ modifier = Modifier.align(Alignment.TopEnd),
250
+ )
251
+ else -> Badge(
252
+ badgeLabel,
253
+ modifier = Modifier.align(Alignment.TopStart).offset(x = ICON_SIZE / 2, y = -Spacing.XS),
254
+ )
255
+ }
256
+ }
257
+ }
258
+
259
+ @Composable
260
+ private fun QrButton(data: BottomTabFloatingButton, size: Dp) {
261
+ Box(
262
+ modifier = Modifier
263
+ .size(size)
264
+ .clip(CircleShape)
265
+ .background(AppTheme.current.colors.primary)
266
+ .noFeedbackClickable(onClick = data.onPress)
267
+ .setAutomationId(data.label),
268
+ contentAlignment = Alignment.Center,
269
+ ) {
270
+ Icon(source = data.icon, color = Color.White, size = QR_ICON_SIZE)
271
+ }
272
+ }
273
+
274
+ // RN falls back to this when native glass is missing; Compose has no backdrop blur, so it always does.
275
+ @Composable
276
+ private fun Modifier.tabBarMaterial(shape: Shape): Modifier {
277
+ val colors = AppTheme.current.colors
278
+ return shadow(3.dp, shape)
279
+ .background(colors.background.surface)
280
+ .border(1.dp, colors.border.default, shape)
281
+ }
@@ -22,6 +22,8 @@ import androidx.compose.runtime.getValue
22
22
  import androidx.compose.ui.Alignment
23
23
  import androidx.compose.ui.Modifier
24
24
  import androidx.compose.ui.draw.clipToBounds
25
+ import androidx.compose.ui.draw.drawBehind
26
+ import androidx.compose.ui.geometry.Offset
25
27
  import androidx.compose.ui.graphics.graphicsLayer
26
28
  import androidx.compose.ui.graphics.Brush
27
29
  import androidx.compose.ui.graphics.Color
@@ -47,7 +49,7 @@ import vn.momo.kits.navigation.LocalNavigator
47
49
  import vn.momo.kits.navigation.LocalOptions
48
50
  import vn.momo.kits.navigation.LocalScrollState
49
51
  import vn.momo.kits.navigation.getInputSearchType
50
- import vn.momo.kits.platform.getScreenDimensions
52
+ import vn.momo.kits.navigation.usesDuoGlassHeader
51
53
  import vn.momo.kits.utils.horizontalSafeAreaPadding
52
54
 
53
55
  @InternalApi
@@ -81,7 +83,30 @@ internal fun headerOpacity(): Float {
81
83
  internal fun headerTitleMaxWidth(): Dp {
82
84
  val layout = getScreenLayout()
83
85
  if (layout.sideBar != null) return layout.contentWidth - Spacing.M * 2
84
- return getScreenDimensions().width.dp - HEADER_ACTIONS_WIDTH
86
+ return layout.width - HEADER_ACTIONS_WIDTH
87
+ }
88
+
89
+ // Scroll distance over which the Duo glass bar fades in.
90
+ private val DUO_GLASS_FADE_DISTANCE = 16.dp
91
+
92
+ // RN DuoGlassBackground fallback: translucent surface with a hairline once content scrolls under it.
93
+ @Suppress("FrequentlyChangingValue")
94
+ @Composable
95
+ private fun Modifier.duoGlassBar(): Modifier {
96
+ val scrollState = LocalScrollState.current
97
+ val fadePx = with(LocalDensity.current) { DUO_GLASS_FADE_DISTANCE.toPx() }
98
+ val alpha by animateFloatAsState((scrollState.value / fadePx).coerceIn(0f, 1f), label = "DuoGlassAlpha")
99
+ val colors = AppTheme.current.colors
100
+ return background(colors.background.surface.copy(alpha = 0.8f * alpha))
101
+ .drawBehind {
102
+ val stroke = 0.5.dp.toPx()
103
+ drawLine(
104
+ color = colors.border.default.copy(alpha = alpha),
105
+ start = Offset(0f, size.height - stroke / 2),
106
+ end = Offset(size.width, size.height - stroke / 2),
107
+ strokeWidth = stroke,
108
+ )
109
+ }
85
110
  }
86
111
 
87
112
  @Composable
@@ -111,13 +136,15 @@ internal fun Header(onBackHandler: (() -> Unit)? = null) {
111
136
  // Centered title needs a full-width overlay to stay screen-centered, not boxed between back button and header right.
112
137
  val centerTitle = titlePosition == TitlePosition.CENTER && options.headerTitle is HeaderTitle.Default
113
138
 
139
+ val useDuoGlass = options.usesDuoGlassHeader(layout)
114
140
  val headerImage = AppTheme.current.assets.headerBackground
115
- .takeUnless { options.headerType is HeaderType.Transparent }
141
+ .takeUnless { options.headerType is HeaderType.Transparent || useDuoGlass }
116
142
 
117
143
  Box(
118
144
  Modifier.height(layout.headerHeight)
119
145
  .fillMaxWidth()
120
- .conditional(headerImage.isNullOrEmpty()) {
146
+ .then(if (useDuoGlass) Modifier.duoGlassBar() else Modifier)
147
+ .conditional(headerImage.isNullOrEmpty() && !useDuoGlass) {
121
148
  background(background.copy(alpha = animatedAlpha))
122
149
  }
123
150
  .conditional(IsShowBaseLineDebug) {
@@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.height
9
9
  import androidx.compose.runtime.Composable
10
10
  import androidx.compose.runtime.getValue
11
11
  import androidx.compose.ui.Modifier
12
+ import androidx.compose.ui.draw.clipToBounds
13
+ import androidx.compose.ui.graphics.graphicsLayer
12
14
  import androidx.compose.ui.geometry.Offset
13
15
  import androidx.compose.ui.graphics.Brush
14
16
  import androidx.compose.ui.graphics.Color
@@ -20,6 +22,7 @@ import vn.momo.kits.layout.getScreenLayout
20
22
  import vn.momo.kits.modifier.conditional
21
23
  import vn.momo.kits.navigation.LocalOptions
22
24
  import vn.momo.kits.navigation.LocalScrollState
25
+ import vn.momo.kits.navigation.usesDuoGlassHeader
23
26
 
24
27
  enum class AnimatedHeaderRatio(val value: Float){
25
28
  RATIO_16_9(16f / 9f),
@@ -29,6 +32,10 @@ enum class AnimatedHeaderRatio(val value: Float){
29
32
 
30
33
  internal val HEADER_BACKGROUND_HEIGHT = 154.dp
31
34
 
35
+ // RN DuoScrollGradient: extended gradient runs this far below the header and collapses over this scroll
36
+ private val DUO_GRADIENT_EXTRA = 52.dp
37
+ private val DUO_COLLAPSE_DISTANCE = 100.dp
38
+
32
39
  @Suppress("FrequentlyChangingValue")
33
40
  @Composable
34
41
  internal fun HeaderBackground() {
@@ -38,7 +45,8 @@ internal fun HeaderBackground() {
38
45
  val scrollState = LocalScrollState.current
39
46
  val backgroundColor = options.backgroundColor ?: AppTheme.current.colors.background.default
40
47
 
41
- val minHeight = getScreenLayout().headerHeight
48
+ val layout = getScreenLayout()
49
+ val minHeight = layout.headerHeight
42
50
  val maxHeight = HEADER_BACKGROUND_HEIGHT
43
51
  val opacity by animateFloatAsState(
44
52
  targetValue = (1 - (scrollState.value * 1f / HEADER_HEIGHT * 1f)).coerceIn(0f, 1f),
@@ -57,7 +65,24 @@ internal fun HeaderBackground() {
57
65
  else -> 0.dp
58
66
  }
59
67
 
60
- Box(Modifier.fillMaxWidth().height(height).background(backgroundColor)) {
68
+ // iPhone Duo glass header: default gradient scrolls away, extended collapses to the header and fades
69
+ val useDuoGlass = options.usesDuoGlassHeader(layout)
70
+ val scrollPx = scrollState.value.toFloat()
71
+ val duoModifier = when {
72
+ !useDuoGlass -> Modifier.height(height)
73
+ options.headerType is HeaderType.Extended -> {
74
+ val collapse = with(density) {
75
+ (scrollPx / minOf(DUO_COLLAPSE_DISTANCE, minHeight).toPx()).coerceIn(0f, 1f)
76
+ }
77
+ val fade = with(density) { (scrollPx / DUO_GRADIENT_EXTRA.toPx()).coerceIn(0f, 1f) }
78
+ Modifier
79
+ .height(minHeight + DUO_GRADIENT_EXTRA * (1f - collapse))
80
+ .graphicsLayer { alpha = 1f - fade }
81
+ }
82
+ else -> Modifier.height(minHeight).graphicsLayer { translationY = -scrollPx }
83
+ }
84
+
85
+ Box(Modifier.fillMaxWidth().then(duoModifier).clipToBounds().background(backgroundColor)) {
61
86
  Box(Modifier
62
87
  .height(maxHeight)
63
88
  .fillMaxWidth()
@@ -60,6 +60,8 @@ fun HeaderRight(
60
60
  }
61
61
  }
62
62
 
63
+ private const val HELP_CENTER_APP_ID = "vn.momo.helpcenter"
64
+
63
65
  @Composable
64
66
  internal fun Toolkit(
65
67
  headerRight: HeaderRight.Toolkit,
@@ -118,10 +120,13 @@ internal fun Toolkit(
118
120
  ) {}
119
121
  }
120
122
 
121
- fun onPressHome() {
122
- api?.dismissAll { }
123
+ fun onPressClose() {
124
+ if (headerRight.useCloseIcon) api?.dismiss { } else api?.dismissAll { }
123
125
  }
124
126
 
127
+ val useHelpCenter = context != null && context.appId != HELP_CENTER_APP_ID
128
+ val closeIcon = if (headerRight.useCloseIcon) "16_navigation_close_circle" else "16_basic_home"
129
+
125
130
  val navButtonConfig = getNavigationButtonConfig(
126
131
  headerRight,
127
132
  ::onPressShortcut,
@@ -157,8 +162,8 @@ internal fun Toolkit(
157
162
  onClick = navButtonConfig.onPress,
158
163
  )
159
164
  }
160
- SideBarGroup(headerColor = headerColor, itemCount = if (context != null) 2 else 1) {
161
- if (context != null) {
165
+ SideBarGroup(headerColor = headerColor, itemCount = if (useHelpCenter) 2 else 1) {
166
+ if (useHelpCenter) {
162
167
  SideBarGroupItem(
163
168
  icon = "help_center",
164
169
  headerColor = headerColor,
@@ -167,10 +172,10 @@ internal fun Toolkit(
167
172
  )
168
173
  }
169
174
  SideBarGroupItem(
170
- icon = "16_basic_home",
175
+ icon = closeIcon,
171
176
  headerColor = headerColor,
172
177
  automationId = "btn_navigation_close",
173
- onClick = ::onPressHome,
178
+ onClick = ::onPressClose,
174
179
  )
175
180
  }
176
181
  }
@@ -200,7 +205,7 @@ internal fun Toolkit(
200
205
  .clip(shape = RoundedCornerShape(14.dp))
201
206
  .background(headerColor.backgroundButton)
202
207
  ) {
203
- if (context != null) {
208
+ if (useHelpCenter) {
204
209
  Icon(
205
210
  source = "help_center",
206
211
  size = 20.dp,
@@ -215,10 +220,10 @@ internal fun Toolkit(
215
220
  )
216
221
  }
217
222
  Icon(
218
- source = "16_basic_home",
223
+ source = closeIcon,
219
224
  size = 20.dp,
220
225
  color = headerColor.tintIconColor,
221
- modifier = Modifier.padding(4.dp).activeOpacityClickable(onClick = ::onPressHome)
226
+ modifier = Modifier.padding(4.dp).activeOpacityClickable(onClick = ::onPressClose)
222
227
  )
223
228
  }
224
229
  }
@@ -308,7 +313,9 @@ sealed interface HeaderRight {
308
313
  val useMore: Boolean = false,
309
314
  val useSystemTools: Boolean = true,
310
315
  val tools: List<ToolGroup> = emptyList(),
311
- val toolCallback: ((String) -> Unit)? = { _ -> }
316
+ val toolCallback: ((String) -> Unit)? = { _ -> },
317
+ // Close dismisses only this mini app instead of returning home.
318
+ val useCloseIcon: Boolean = false,
312
319
  ) : HeaderRight
313
320
  }
314
321
 
@@ -14,6 +14,9 @@ data class ScreenDimension(
14
14
 
15
15
  expect fun getPlatformName(): String
16
16
 
17
+ // iPhone idiom only; iPad windows and Android never get the iPhone / iPhone Duo layout.
18
+ expect fun isPhoneIdiom(): Boolean
19
+
17
20
  @Composable
18
21
  expect fun getScreenDimensions(): ScreenDimension
19
22
 
@@ -39,11 +39,15 @@ import platform.Foundation.NSBundle
39
39
  import platform.UIKit.UIColor
40
40
  import platform.UIKit.UIDevice
41
41
  import platform.UIKit.UIScreen
42
+ import platform.UIKit.UIUserInterfaceIdiomPhone
42
43
  import vn.momo.kits.navigation.AttachHostBackGesture
43
44
  import vn.momo.kits.navigation.Navigator
44
45
 
45
46
  actual fun getPlatformName(): String = "iOS"
46
47
 
48
+ actual fun isPhoneIdiom(): Boolean =
49
+ UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPhone
50
+
47
51
  @OptIn(ExperimentalForeignApi::class)
48
52
  @Composable
49
53
  actual fun getScreenDimensions(): ScreenDimension = memScoped {
package/gradle.properties CHANGED
@@ -18,7 +18,7 @@ kotlin.apple.xcodeCompatibility.nowarn=true
18
18
  name="ComposeKits"
19
19
  group=vn.momo.kits
20
20
  artifact.id=kits
21
- version=0.167.1-beta.4
21
+ version=0.167.1-duo.3
22
22
 
23
23
  repo=GitLab
24
24
  url=https://gitlab.mservice.com.vn/api/v4/projects/5400/packages/maven
@@ -7,20 +7,31 @@ public struct HeaderRightData {
7
7
  var tools: [ToolGroup]
8
8
  var toolCallback: ((String) -> Void)?
9
9
  var useSystemTools: Bool
10
+ var useCloseIcon: Bool
10
11
 
11
12
  public init(
12
13
  useShortcut: Bool = false,
13
14
  useMore: Bool = false,
14
15
  tools: [ToolGroup] = [],
15
16
  toolCallback: ((String) -> Void)? = nil,
16
- useSystemTools: Bool = true
17
+ useSystemTools: Bool = true,
18
+ useCloseIcon: Bool = false
17
19
  ) {
18
20
  self.useShortcut = useShortcut
19
21
  self.useMore = useMore
20
22
  self.tools = tools
21
23
  self.toolCallback = toolCallback
22
24
  self.useSystemTools = useSystemTools
25
+ self.useCloseIcon = useCloseIcon
23
26
  }
27
+
28
+ /// Close-circle instead of home, as RN `headerRight.useCloseIcon`.
29
+ var closeIcon: String { useCloseIcon ? "16_navigation_close_circle" : "16_basic_home" }
30
+ }
31
+
32
+ /// The Help Center mini-app hides its own help-center button.
33
+ func showsHelpCenter(_ context: MiniAppContext?) -> Bool {
34
+ context?.appId != "vn.momo.helpcenter"
24
35
  }
25
36
 
26
37
  public struct Tool {
@@ -238,17 +249,19 @@ struct ToolkitHeaderRight: View {
238
249
  }
239
250
 
240
251
  HStack(alignment: .center, spacing: 0) {
241
- Icon(source: "help_center", size: 20, color: tintColor ?? Colors.black17)
242
- .padding(4)
243
- .onTapGesture {
244
- onPressHelpCenter()
245
- }
246
-
247
- Rectangle()
248
- .fill(tintColor ?? Colors.black20)
249
- .frame(width: 0.5, height: 12)
250
-
251
- Icon(source: "16_basic_home", size: 20, color: tintColor ?? Colors.black17)
252
+ if showsHelpCenter(environment.applicationContext) {
253
+ Icon(source: "help_center", size: 20, color: tintColor ?? Colors.black17)
254
+ .padding(4)
255
+ .onTapGesture {
256
+ onPressHelpCenter()
257
+ }
258
+
259
+ Rectangle()
260
+ .fill(tintColor ?? Colors.black20)
261
+ .frame(width: 0.5, height: 12)
262
+ }
263
+
264
+ Icon(source: headerRight?.closeIcon ?? "16_basic_home", size: 20, color: tintColor ?? Colors.black17)
252
265
  .padding(4)
253
266
  .onTapGesture {
254
267
  onPressClose()