@momo-kits/native-kits 0.167.1-duo-search.3-debug → 0.167.1-duo-search.22-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 (64) hide show
  1. package/CLAUDE.md +12 -9
  2. package/README.md +2 -2
  3. package/compose/build.gradle.kts +1 -1
  4. package/compose/compose.podspec +1 -1
  5. package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +228 -73
  6. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +23 -13
  7. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Image.kt +1 -1
  8. package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +4 -0
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Theme.kt +10 -4
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +3 -17
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +95 -18
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +10 -0
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +6 -0
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +56 -37
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +28 -19
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +62 -15
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +17 -6
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +39 -6
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +4 -2
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +1 -1
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +33 -26
  22. package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +38 -0
  23. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +11 -3
  24. package/gradle.properties +1 -1
  25. package/ios/Application/MaxApi.swift +30 -3
  26. package/ios/Colors+Radius+Spacing/Radius.swift +6 -0
  27. package/ios/Input/InputSearch.swift +5 -2
  28. package/ios/Layout/GridContext.swift +67 -3
  29. package/ios/Layout/ScreenLayout.swift +101 -33
  30. package/ios/Navigation/BottomSheet.swift +83 -36
  31. package/ios/Navigation/BottomTab/BottomTab.swift +165 -20
  32. package/ios/Navigation/BottomTab/BottomTabBar.swift +3 -0
  33. package/ios/Navigation/Component/FloatingButton.swift +29 -51
  34. package/ios/Navigation/Component/Header.swift +70 -18
  35. package/ios/Navigation/Component/HeaderBackground.swift +0 -1
  36. package/ios/Navigation/Component/HeaderRight.swift +107 -68
  37. package/ios/Navigation/Component/SideBar.swift +15 -8
  38. package/ios/Navigation/Component/SnackBar.swift +3 -3
  39. package/ios/Navigation/HostBackGestureBridge.swift +27 -6
  40. package/ios/Navigation/ModalScreen.swift +2 -2
  41. package/ios/Navigation/Navigation.swift +14 -0
  42. package/ios/Navigation/NavigationContainer.swift +29 -58
  43. package/ios/Navigation/Navigator.swift +132 -181
  44. package/ios/Navigation/StackScreen.swift +217 -65
  45. package/ios/Navigation/StackTransition.swift +400 -0
  46. package/ios/Navigation/SwipeBackHaptic.swift +33 -0
  47. package/ios/native-kits.podspec +1 -1
  48. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  49. package/package.json +1 -1
  50. package/compose/src/commonMain/kotlin/vn/momo/kits/application/AnimationSearchInput.kt +0 -52
  51. package/compose/src/commonMain/kotlin/vn/momo/kits/application/FloatingButton.kt +0 -183
  52. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Header.kt +0 -207
  53. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderAnimated.kt +0 -39
  54. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderBackground.kt +0 -86
  55. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderDefault.kt +0 -76
  56. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderExtended.kt +0 -76
  57. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderRight.kt +0 -293
  58. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderTitle.kt +0 -33
  59. package/compose/src/commonMain/kotlin/vn/momo/kits/application/NavigationContainer.kt +0 -110
  60. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Screen.kt +0 -378
  61. package/compose/src/commonMain/kotlin/vn/momo/kits/application/useHeaderSearchAnimation.kt +0 -69
  62. package/ios/Application/ComposeApi.swift +0 -23
  63. package/ios/Application/LegacyHeader.swift +0 -212
  64. package/ios/Application/Screen.swift +0 -251
@@ -67,7 +67,7 @@ data class Theme(
67
67
  return !assets.headerBackground.isNullOrEmpty()
68
68
  }
69
69
  }
70
- val defaultTheme = Theme(
70
+ private val lightBaseTheme = Theme(
71
71
  dark = false,
72
72
  colors = ColorScheme(
73
73
  primary = Colors.pink_03,
@@ -117,14 +117,14 @@ val defaultTheme = Theme(
117
117
  container = Colors.blue_08
118
118
  )
119
119
  ),
120
- font = "MomoTrustSans",
120
+ font = "SFProText",
121
121
  assets = ThemeAssets(
122
122
  headerBackground = null
123
123
  )
124
124
  )
125
125
 
126
126
 
127
- val darkTheme = Theme(
127
+ private val darkBaseTheme = Theme(
128
128
  dark = true,
129
129
  colors = ColorScheme(
130
130
  primary = Colors.pink_04,
@@ -174,13 +174,19 @@ val darkTheme = Theme(
174
174
  container = Colors.blue_08
175
175
  )
176
176
  ),
177
- font = "MomoTrustSans",
177
+ font = "SFProText",
178
178
  assets = ThemeAssets(
179
179
  headerBackground = null
180
180
  )
181
181
  )
182
182
 
183
183
 
184
+ val defaultTheme: Theme
185
+ get() = lightBaseTheme.copy(font = defaultFontFamily)
186
+
187
+ val darkTheme: Theme
188
+ get() = darkBaseTheme.copy(font = defaultFontFamily)
189
+
184
190
  val AppTheme = staticCompositionLocalOf { defaultTheme }
185
191
 
186
192
  val AppStatusBar = staticCompositionLocalOf { 0.dp }
@@ -121,31 +121,17 @@ private val hostFontFiles: Map<String, String> = mapOf(
121
121
  )
122
122
  }
123
123
 
124
- /**
125
- * Font mặc định mới, chỉ áp cho user nằm trong whitelist design system ([KitDesignSystem]).
126
- * Ngoài whitelist thì quay về [LEGACY_DEFAULT_FONT_FAMILY] như trước.
127
- */
128
124
  private const val NEW_DEFAULT_FONT_FAMILY = "MomoTrustSans"
129
125
  private const val LEGACY_DEFAULT_FONT_FAMILY = "SFProText"
130
126
 
131
- /**
132
- * Font thuoc bo design system moi. MoMoTrustDisplay chi duoc header dung tu DS-791
133
- * (commit header title 5bfde6251); truoc do header lay font theo theme, nen ngoai
134
- * whitelist ca hai deu ha ve [LEGACY_DEFAULT_FONT_FAMILY].
135
- */
136
- private val newFontFamilies = setOf(NEW_DEFAULT_FONT_FAMILY, "MoMoTrustDisplay")
127
+ internal val defaultFontFamily: String
128
+ get() = if (KitDesignSystem.isWhiteList) NEW_DEFAULT_FONT_FAMILY else LEGACY_DEFAULT_FONT_FAMILY
137
129
 
138
130
  @Composable
139
131
  fun getFontFamily(fontFamily: String, fontWeight: FontWeight? = FontWeight.Normal): FontFamily {
140
132
  val isWhiteList = KitDesignSystem.isWhiteList
141
- val resolvedFamily =
142
- if (fontFamily in newFontFamilies && !isWhiteList) {
143
- LEGACY_DEFAULT_FONT_FAMILY
144
- } else {
145
- fontFamily
146
- }
147
133
  val defaultFile = if (isWhiteList) NEW_DEFAULT_FONT_FILE else LEGACY_DEFAULT_FONT_FILE
148
- val key = "$resolvedFamily-${fontWeight?.weight}"
134
+ val key = "$fontFamily-${fontWeight?.weight}"
149
135
  val fileName = hostFontFiles[key] ?: defaultFile
150
136
  return hostFontFamily(fileName)
151
137
  ?: hostFontFamily(defaultFile)
@@ -32,6 +32,10 @@ data class SideBarLayout(
32
32
  val bottom: Dp,
33
33
  )
34
34
 
35
+ // Room the header row keeps from each side for the status bar clock and battery clusters.
36
+ @Immutable
37
+ data class StatusBarClearance(val left: Dp, val right: Dp)
38
+
35
39
  @Immutable
36
40
  data class ScreenLayout(
37
41
  val width: Dp,
@@ -53,6 +57,8 @@ data class ScreenLayout(
53
57
  // Portrait iPhone Duo puts the status bar in the side strip, about twice as tall as the strip is wide.
54
58
  // Stored so it survives consumeHorizontalInsets().
55
59
  val duoTopInset: Dp = insetTop,
60
+ // iPhone Duo inner display in portrait: the header row shares the status bar band, between its clusters.
61
+ val statusBarClearance: StatusBarClearance? = null,
56
62
  ) {
57
63
  val headerBarHeight: Dp get() = headerHeight - headerStatusBarHeight
58
64
  }
@@ -76,6 +82,10 @@ internal object DuoMetrics {
76
82
  // Outer display in landscape has no status bar in the strip: controls keep this far from
77
83
  // both ends unless a reserved region (the camera at either end) asks for more.
78
84
  val edgeInset = 24.dp
85
+ // Inner display in portrait: the clock and connectivity cluster sits at the top right corner
86
+ // (x 550–643 of 669, clock digits centered on y 47); its reserved region is taller than the glyphs.
87
+ val innerStatusBarCenterY = 47.dp
88
+ val innerStatusBarWidth = 120.dp
79
89
  }
80
90
 
81
91
  // Widest short side of any single-display iPhone.
@@ -97,9 +107,19 @@ fun getScreenLayout(): ScreenLayout =
97
107
  internal fun calculateScreenLayout(topInset: Dp, parent: ScreenLayout? = null): ScreenLayout {
98
108
  val isPhone = isPhoneIdiom()
99
109
  if (parent != null) {
100
- val (headerHeight, headerStatusBarHeight) =
101
- headerGeometry(topInset, isPhone, parent.width, parent.height, parent.sideBar != null)
102
- return parent.copy(headerHeight = headerHeight, headerStatusBarHeight = headerStatusBarHeight)
110
+ // the parent's row center reproduces its own centered geometry
111
+ val titleCenterY = parent.headerStatusBarHeight + parent.headerBarHeight / 2
112
+ val statusBarClearance = parent.statusBarClearance?.takeIf { topInset >= STATUS_BAR_MIN_INSET }
113
+ val (headerHeight, headerStatusBarHeight) = if (statusBarClearance != null) {
114
+ statusBarRowGeometry()
115
+ } else {
116
+ headerGeometry(topInset, isPhone, parent.width, parent.height, parent.sideBar != null, titleCenterY)
117
+ }
118
+ return parent.copy(
119
+ headerHeight = headerHeight,
120
+ headerStatusBarHeight = headerStatusBarHeight,
121
+ statusBarClearance = statusBarClearance,
122
+ )
103
123
  }
104
124
 
105
125
  val density = LocalDensity.current
@@ -139,32 +159,72 @@ internal fun calculateScreenLayout(topInset: Dp, parent: ScreenLayout? = null):
139
159
  internal fun ScreenLayout.consumeHorizontalInsets(): ScreenLayout =
140
160
  copy(width = contentWidth, insetLeft = 0.dp, insetRight = 0.dp, sideBar = null)
141
161
 
142
- private fun headerGeometry(topInset: Dp, isPhone: Boolean, width: Dp, height: Dp, hasSideBar: Boolean): Pair<Dp, Dp> {
162
+ // titleCenterY: window y the title row centers on for the outer display in landscape.
163
+ private fun headerGeometry(
164
+ topInset: Dp,
165
+ isPhone: Boolean,
166
+ width: Dp,
167
+ height: Dp,
168
+ hasSideBar: Boolean,
169
+ titleCenterY: Dp,
170
+ ): Pair<Dp, Dp> {
143
171
  val headerHeight = topInset + HEADER_HEIGHT.dp
144
172
  if (!isPhone) return headerHeight to topInset
145
- if (hasSideBar && topInset < STATUS_BAR_MIN_INSET && height > width && width < DuoMetrics.innerMinHeight) {
146
- // outer display: the row starts below the top so the title centers on the camera
147
- return DuoMetrics.outerHeaderHeight to DuoMetrics.outerCameraCenterY * 2 - DuoMetrics.outerHeaderHeight
173
+ if (hasSideBar && topInset < STATUS_BAR_MIN_INSET) {
174
+ if (height > width && width < DuoMetrics.innerMinHeight) {
175
+ // outer display: the row starts below the top so the title centers on the camera
176
+ return DuoMetrics.outerHeaderHeight to DuoMetrics.outerCameraCenterY * 2 - DuoMetrics.outerHeaderHeight
177
+ }
178
+ if (width > height && height < DuoMetrics.innerMinHeight) {
179
+ // reaches 8pt below a side bar control centered on the row
180
+ val centered = maxOf(IPHONE_HEADER_MIN_HEIGHT, titleCenterY + DuoMetrics.controlSize / 2 + Spacing.S)
181
+ return centered to maxOf(0.dp, titleCenterY * 2 - centered)
182
+ }
148
183
  }
149
184
  return maxOf(headerHeight, IPHONE_HEADER_MIN_HEIGHT) to if (topInset < STATUS_BAR_MIN_INSET) 0.dp else topInset
150
185
  }
151
186
 
152
- private data class StripOcclusion(val top: Dp, val bottom: Dp)
187
+ // Centered on the clock digits; the cluster's region center sits above them.
188
+ private fun statusBarRowGeometry(): Pair<Dp, Dp> = DuoMetrics.innerStatusBarCenterY * 2 to 0.dp
189
+
190
+ private data class StripOcclusion(val top: Dp, val topCenter: Dp, val bottom: Dp)
153
191
 
154
192
  // How far system regions reach into a side strip from its top and from its bottom end
155
- // (e.g. the camera, which sits at either end depending on the rotation).
193
+ // (e.g. the camera, which sits at either end depending on the rotation), and the center y
194
+ // of the region reaching furthest from the top.
156
195
  private fun stripOcclusion(regions: List<ReservedRegion>, stripLeft: Dp, stripRight: Dp, windowHeight: Dp): StripOcclusion {
157
196
  var top = 0.dp
197
+ var topCenter = 0.dp
158
198
  var bottom = 0.dp
159
199
  regions.forEach { region ->
160
200
  if (region.x >= stripRight || region.x + region.width <= stripLeft) return@forEach
161
- if (region.y + region.height / 2 < windowHeight / 2) {
162
- top = maxOf(top, region.y + region.height)
201
+ val centerY = region.y + region.height / 2
202
+ if (centerY < windowHeight / 2) {
203
+ if (region.y + region.height > top) {
204
+ top = region.y + region.height
205
+ topCenter = centerY
206
+ }
163
207
  } else {
164
208
  bottom = maxOf(bottom, windowHeight - region.y)
165
209
  }
166
210
  }
167
- return StripOcclusion(top, bottom)
211
+ return StripOcclusion(top, topCenter, bottom)
212
+ }
213
+
214
+ // How far the clock and battery clusters of a status bar on the top edge reach in from each side;
215
+ // null without reserved regions there.
216
+ private fun statusBarClusters(regions: List<ReservedRegion>, windowWidth: Dp, topInset: Dp): StatusBarClearance? {
217
+ val midX = windowWidth / 2
218
+ val clusters = regions.filter {
219
+ it.y < topInset && it.y + it.height <= topInset && (it.x + it.width <= midX || it.x >= midX)
220
+ }
221
+ if (clusters.isEmpty()) return null
222
+ var left = 0.dp
223
+ var right = 0.dp
224
+ clusters.forEach {
225
+ if (it.x < midX) left = maxOf(left, it.x + it.width) else right = maxOf(right, windowWidth - it.x)
226
+ }
227
+ return StatusBarClearance(left, right)
168
228
  }
169
229
 
170
230
  // Slab iPhones report equal side insets in landscape; iPhone Duo moves the status bar to one side.
@@ -187,24 +247,36 @@ private fun screenLayoutOf(
187
247
  reservedRegions: List<ReservedRegion>,
188
248
  ): ScreenLayout {
189
249
  val isIPhoneDuo = isPhone && isIPhoneDuoWindow(width, height, asymmetricInsets)
250
+ val isInnerPortrait = height > width && width >= DuoMetrics.innerMinHeight
251
+ val statusBarClearance = if (isIPhoneDuo && isInnerPortrait && topInset >= STATUS_BAR_MIN_INSET) {
252
+ statusBarClusters(reservedRegions, width, topInset)
253
+ ?: StatusBarClearance(left = 0.dp, right = DuoMetrics.innerStatusBarWidth + Spacing.S)
254
+ } else {
255
+ null
256
+ }
190
257
 
191
258
  var sideBar: SideBarLayout? = null
259
+ var titleCenterY = 0.dp
192
260
  val sideInset = maxOf(insetLeft, insetRight)
193
261
  // narrower strips cannot host a control 12pt from the inner edge
194
262
  if (isIPhoneDuo && sideInset >= DuoMetrics.controlSize + Spacing.M) {
263
+ val edge = if (insetRight >= insetLeft) SideBarEdge.RIGHT else SideBarEdge.LEFT
264
+ val stripLeft = if (edge == SideBarEdge.RIGHT) width - sideInset else 0.dp
265
+ // clear of the camera and of the status bar cluster, which grows for calls and Live
266
+ // Activities; region frames already include the system's margin
267
+ val occlusion = stripOcclusion(reservedRegions, stripLeft, stripLeft + sideInset, height)
195
268
  // UIKit reports no usable status bar frame here (a 2pt top strip), so the spot is inferred like RN
196
269
  val top = when {
270
+ statusBarClearance != null -> statusBarRowGeometry().first + Spacing.M
197
271
  topInset >= STATUS_BAR_MIN_INSET -> topInset + HEADER_HEIGHT.dp + Spacing.M
198
272
  width <= height -> DuoMetrics.firstItemTop
199
273
  height >= DuoMetrics.innerMinHeight -> DuoMetrics.firstItemTopLandscape
200
274
  // outer display in landscape: no status bar in the strip
201
275
  else -> DuoMetrics.edgeInset
202
276
  }
203
- val edge = if (insetRight >= insetLeft) SideBarEdge.RIGHT else SideBarEdge.LEFT
204
- val stripLeft = if (edge == SideBarEdge.RIGHT) width - sideInset else 0.dp
205
- // clear of the camera and of the status bar cluster, which grows for calls and Live
206
- // Activities; region frames already include the system's margin
207
- val occlusion = stripOcclusion(reservedRegions, stripLeft, stripLeft + sideInset, height)
277
+ // outer display in landscape: the title row centers on the camera when it sits at the
278
+ // top end, otherwise on the back button
279
+ titleCenterY = if (occlusion.top > top) occlusion.topCenter else top + DuoMetrics.controlSize / 2
208
280
  sideBar = SideBarLayout(
209
281
  edge = edge,
210
282
  width = sideInset,
@@ -212,7 +284,11 @@ private fun screenLayoutOf(
212
284
  bottom = maxOf(DuoMetrics.edgeInset, occlusion.bottom),
213
285
  )
214
286
  }
215
- val (headerHeight, headerStatusBarHeight) = headerGeometry(topInset, isPhone, width, height, sideBar != null)
287
+ val (headerHeight, headerStatusBarHeight) = if (statusBarClearance != null) {
288
+ statusBarRowGeometry()
289
+ } else {
290
+ headerGeometry(topInset, isPhone, width, height, sideBar != null, titleCenterY)
291
+ }
216
292
 
217
293
  return ScreenLayout(
218
294
  width = width,
@@ -228,5 +304,6 @@ private fun screenLayoutOf(
228
304
  sideBar = sideBar,
229
305
  isIPhoneDuoDevice = isDuoDevice,
230
306
  duoTopInset = maxOf(topInset, if (height > width) sideInset * 2 else 0.dp),
307
+ statusBarClearance = statusBarClearance,
231
308
  )
232
309
  }
@@ -30,6 +30,9 @@ class Navigation(
30
30
  headerType: HeaderType? = null,
31
31
  scrollData: ScrollData? = null,
32
32
  backgroundColor: Color? = null,
33
+ // AI-GENERATED START: custom screen background
34
+ backgroundComponent: (@Composable () -> Unit)? = null,
35
+ // AI-GENERATED END: custom screen background
33
36
  tintColor: Color? = null,
34
37
  footerComponent: (@Composable () -> Unit)? = null,
35
38
  footerTransparent: Boolean? = null,
@@ -48,6 +51,9 @@ class Navigation(
48
51
  headerType = headerType ?: options.headerType,
49
52
  scrollData = scrollData ?: options.scrollData,
50
53
  backgroundColor = backgroundColor ?: options.backgroundColor,
54
+ // AI-GENERATED START: custom screen background
55
+ backgroundComponent = backgroundComponent ?: options.backgroundComponent,
56
+ // AI-GENERATED END: custom screen background
51
57
  tintColor = tintColor ?: options.tintColor,
52
58
  footerComponent = footerComponent ?: options.footerComponent,
53
59
  footerTransparent = footerTransparent ?: options.footerTransparent,
@@ -85,6 +91,10 @@ data class NavigationOptions(
85
91
  val headerType: HeaderType = HeaderType.Default(),
86
92
  val scrollData: ScrollData = ScrollData(),
87
93
  val backgroundColor: Color? = null,
94
+ // AI-GENERATED START: custom screen background (SwiftUI `NavigationOptions.background`)
95
+ /** Drawn instead of `backgroundColor` over the whole screen: under the header, outside the keyboard padding. */
96
+ val backgroundComponent: @Composable (() -> Unit)? = null,
97
+ // AI-GENERATED END: custom screen background (SwiftUI `NavigationOptions.background`)
88
98
  val tintColor: Color? = null,
89
99
  val footerComponent: @Composable (() -> Unit)? = null,
90
100
  val footerTransparent: Boolean = false,
@@ -55,6 +55,12 @@ class Navigator(
55
55
  }
56
56
 
57
57
  fun onBackSafe(callBack: (() -> Unit)? = null) {
58
+ // An open modal or bottom sheet takes the back first, as its BackHandler does for gestures.
59
+ val overplayType = OverplayComponentRegistry.getOverplayType()
60
+ if (overplayType != null && overplayType != OverplayComponentType.SNACK_BAR) {
61
+ pop(1, callBack)
62
+ return
63
+ }
58
64
  tabBackHandler?.let {
59
65
  it()
60
66
  return
@@ -83,6 +83,7 @@ import vn.momo.kits.utils.horizontalSafeAreaPadding
83
83
  import vn.momo.kits.layout.LocalScreenLayout
84
84
  import vn.momo.kits.layout.ScreenLayout
85
85
  import vn.momo.kits.layout.consumeHorizontalInsets
86
+ import vn.momo.kits.layout.DuoMetrics
86
87
  import vn.momo.kits.layout.getScreenLayout
87
88
  import kotlin.time.Clock
88
89
  import kotlin.time.Duration.Companion.milliseconds
@@ -213,48 +214,62 @@ internal fun StackScreen(
213
214
  LocalFooterHeightPx provides footerHeightPx,
214
215
  LocalHeaderRightWidthPx provides headerRightWidthPx
215
216
  ) {
216
- Box(
217
- Modifier
218
- .fillMaxSize()
219
- .background(options.backgroundColor ?: AppTheme.current.colors.background.default)
220
- .conditional(options.keyboardOptions.keyboardShouldPersistTaps) {
221
- hideKeyboardOnTap()
217
+ // AI-GENERATED START: custom screen background, full window and outside the keyboard padding
218
+ Box(Modifier.fillMaxSize()) {
219
+ options.backgroundComponent?.let { background ->
220
+ Box(Modifier.fillMaxSize()) { background() }
221
+ }
222
+ // AI-GENERATED END: custom screen background, full window and outside the keyboard padding
223
+ Box(
224
+ Modifier
225
+ .fillMaxSize()
226
+ // AI-GENERATED START: custom background replaces the colour
227
+ .background(
228
+ if (options.backgroundComponent != null) Color.Transparent
229
+ else options.backgroundColor ?: AppTheme.current.colors.background.default
230
+ )
231
+ // AI-GENERATED END: custom background replaces the colour
232
+ .conditional(options.keyboardOptions.keyboardShouldPersistTaps) {
233
+ hideKeyboardOnTap()
234
+ }
235
+ .conditional(options.keyboardOptions.useAvoidKeyboard && supportsImePadding()) {
236
+ imePadding()
237
+ }
238
+ ) {
239
+ Box(Modifier.zIndex(1f)) {
240
+ HeaderBackground()
222
241
  }
223
- .conditional(options.keyboardOptions.useAvoidKeyboard && supportsImePadding()) {
224
- imePadding()
242
+
243
+ Box(Modifier.zIndex(4f).graphicsLayer { translationY = -headerHide.value }) {
244
+ Header(onBackHandler)
225
245
  }
226
- ) {
227
- Box(Modifier.zIndex(1f)) {
228
- HeaderBackground()
229
- }
230
246
 
231
- Box(Modifier.zIndex(4f).graphicsLayer { translationY = -headerHide.value }) {
232
- Header(onBackHandler)
233
- }
247
+ HeaderSideBar(
248
+ bottomInset = maxOf(
249
+ bottomBarsHeight(isBottomTabChild) + Spacing.XL,
250
+ if (isBottomTabChild) LocalDuoRailReserve.current else 0.dp,
251
+ ),
252
+ modifier = Modifier.zIndex(4f),
253
+ )
234
254
 
235
- HeaderSideBar(
236
- bottomInset = maxOf(
237
- bottomBarsHeight(isBottomTabChild) + Spacing.XL,
238
- if (isBottomTabChild) LocalDuoRailReserve.current else 0.dp,
239
- ),
240
- modifier = Modifier.zIndex(4f),
241
- )
255
+ Column(Modifier.zIndex(5f).horizontalSafeAreaPadding()) {
256
+ SearchAnimated(snapState = snapState)
257
+ }
242
258
 
243
- Column(Modifier.zIndex(5f).horizontalSafeAreaPadding()) {
244
- SearchAnimated(snapState = snapState)
245
- }
259
+ Column(Modifier.zIndex(2f).fillMaxSize()) {
260
+ MainContent(content = content, isBottomTabRoot = isBottomTabRoot, isBottomTabChild = isBottomTabChild)
261
+ FooterContent()
262
+ }
246
263
 
247
- Column(Modifier.zIndex(2f).fillMaxSize()) {
248
- MainContent(content = content, isBottomTabRoot = isBottomTabRoot, isBottomTabChild = isBottomTabChild)
249
- FooterContent()
250
- }
264
+ Box(Modifier.zIndex(6f).horizontalSafeAreaPadding()) {
265
+ FloatingContent(isBottomTabChild = isBottomTabChild)
266
+ }
251
267
 
252
- Box(Modifier.zIndex(6f).horizontalSafeAreaPadding()) {
253
- FloatingContent(isBottomTabChild = isBottomTabChild)
268
+ OverplayView(isBottomTabChild = isBottomTabChild, id = id)
254
269
  }
255
-
256
- OverplayView(isBottomTabChild = isBottomTabChild, id = id)
270
+ // AI-GENERATED START: custom screen background, full window and outside the keyboard padding
257
271
  }
272
+ // AI-GENERATED END: custom screen background, full window and outside the keyboard padding
258
273
  }
259
274
  }
260
275
 
@@ -431,9 +446,10 @@ fun Footer(
431
446
 
432
447
  val backgroundColor = if (transparent) Color.Transparent else AppTheme.current.colors.background.surface
433
448
 
449
+ // inset as a whole, not padded inside, so the surface and its shadow stay off the iPhone Duo side bar
434
450
  Box(Modifier.onGloballyPositioned {
435
451
  if (footerHeightPx.intValue != it.size.height) footerHeightPx.intValue = it.size.height
436
- }) {
452
+ }.horizontalSafeAreaPadding()) {
437
453
  Box(
438
454
  Modifier
439
455
  .fillMaxWidth()
@@ -441,7 +457,6 @@ fun Footer(
441
457
  .conditional(IsShowBaseLineDebug) {
442
458
  border(1.dp, Colors.blue_03)
443
459
  }
444
- .horizontalSafeAreaPadding()
445
460
  .padding(
446
461
  top = if (useMargin) Spacing.S else 0.dp,
447
462
  start = if (useMargin) Spacing.M else 0.dp,
@@ -494,10 +509,14 @@ internal fun SearchAnimated(
494
509
  // collapsed, the search is centered in the bar row
495
510
  val minTopPadding = screenLayout.headerStatusBarHeight + (screenLayout.headerBarHeight - HEADER_HEIGHT.dp) / 2
496
511
  val maxTopPadding = screenLayout.headerHeight
512
+ // collapsed on the iPhone Duo status bar row, the search stays between its clusters (48pt back there)
513
+ val clearance = screenLayout.statusBarClearance
514
+ val backWidth = if (clearance != null && !useSideBar) DuoMetrics.controlSize else 28.dp
497
515
  val minStartPadding = Spacing.M
498
- val maxStartPadding = if (options.hiddenBack || useSideBar) Spacing.M else 48.dp // button back: size + left +right = 28 + 12 + 8 = 48
516
+ val maxStartPadding = (clearance?.left ?: 0.dp) +
517
+ if (options.hiddenBack || useSideBar) Spacing.M else backWidth + Spacing.M + Spacing.S
499
518
  val minEndPadding = Spacing.M
500
- val maxEndPadding = when {
519
+ val maxEndPadding = (clearance?.right ?: 0.dp) + when {
501
520
  options.headerRight is HeaderRight.None -> Spacing.M
502
521
  useSideBar && options.headerRight is HeaderRight.Toolkit -> Spacing.M
503
522
  else -> headerRightWidthDp + Spacing.M * 2
@@ -28,6 +28,7 @@ import vn.momo.kits.navigation.LocalNavigator
28
28
  import vn.momo.kits.navigation.NavigationOptions
29
29
  import vn.momo.kits.navigation.StackScreen
30
30
  import vn.momo.kits.navigation.component.HeaderType
31
+ import vn.momo.kits.platform.BackHandler
31
32
  import vn.momo.kits.platform.reportFloatingArrowBack
32
33
  import vn.momo.kits.layout.getScreenLayout
33
34
 
@@ -50,10 +51,16 @@ fun BottomTab(
50
51
  // iPhone Duo: when the side bar can't hold both bars, which one starts compressed;
51
52
  // tapping the compressed one swaps them
52
53
  compressionBehavior: DuoBarCompressionBehavior = DuoBarCompressionBehavior.TOOLBAR,
54
+ initialRouteName: String? = null,
55
+ onTabPress: ((index: Int, switchTab: () -> Unit) -> Unit)? = null,
56
+ backToInitialTab: Boolean = true,
53
57
  ) {
54
58
  val navigation = LocalNavigation.current
55
59
  val navigator = LocalNavigator.current
56
60
  val navController = rememberNavController()
61
+ val startRoute = remember {
62
+ "option${items.indexOfFirst { it.name == initialRouteName }.coerceAtLeast(0)}"
63
+ }
57
64
 
58
65
  // Only on new items: this recomposes on every tab switch and would drop options a tab set at runtime.
59
66
  remember(items) {
@@ -68,15 +75,14 @@ fun BottomTab(
68
75
  )
69
76
  }
70
77
 
71
- // Tab 0 keeps the seeked transition; other tabs go to the host's floating arrow. Matches RN.
72
78
  val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
73
- val isFirstTab = currentRoute == null || currentRoute == "option0"
79
+ val onStartTab = currentRoute == null || currentRoute == startRoute
80
+ val isFirstTab = onStartTab || !backToInitialTab
81
+ val backToStartTab = { navController.popBackStack(startRoute, inclusive = false) }
74
82
 
75
83
  DisposableEffect(isFirstTab) {
76
84
  reportFloatingArrowBack(!isFirstTab)
77
- navigator.tabBackHandler = if (isFirstTab) null else ({
78
- navController.popBackStack(navController.graph.startDestinationRoute!!, false)
79
- })
85
+ navigator.tabBackHandler = if (isFirstTab) null else ({ backToStartTab() })
80
86
  // both live outside this composition
81
87
  onDispose {
82
88
  reportFloatingArrowBack(false)
@@ -90,16 +96,19 @@ fun BottomTab(
90
96
  val railReserve = layout.sideBar?.takeIf { layout.isIPhoneDuo }?.let {
91
97
  it.bottom + duoRailHeight(items.size, hasQr, compressed = compression.tabBarCompressed) + DUO_GROUP_GAP
92
98
  } ?: 0.dp
93
- val onTabSelected: (Int) -> Unit = {
94
- val targetRoute = "option$it"
95
- if (currentRoute != targetRoute) {
96
- // one entry per tab above the first, so switching never grows the back stack
97
- navController.navigate(targetRoute) {
98
- popUpTo("option0") { saveState = true }
99
- launchSingleTop = true
100
- restoreState = true
99
+ val onTabSelected: (Int) -> Unit = { index ->
100
+ val switchTab = {
101
+ val targetRoute = "option$index"
102
+ if (navController.currentBackStackEntry?.destination?.route != targetRoute) {
103
+ // one entry per tab above the start tab, so switching never grows the back stack
104
+ navController.navigate(targetRoute) {
105
+ popUpTo(startRoute) { saveState = true }
106
+ launchSingleTop = true
107
+ restoreState = true
108
+ }
101
109
  }
102
110
  }
111
+ onTabPress?.invoke(index, switchTab) ?: switchTab()
103
112
  }
104
113
 
105
114
  Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) {
@@ -112,7 +121,7 @@ fun BottomTab(
112
121
  ) {
113
122
  NavHost(
114
123
  navController = navController,
115
- startDestination = "option0"
124
+ startDestination = startRoute
116
125
  ) {
117
126
  items.forEachIndexed { index, item ->
118
127
  composable(
@@ -153,11 +162,8 @@ fun BottomTab(
153
162
  navigationOptions = option,
154
163
  bottomTabIndex = index,
155
164
  onBackHandler = {
156
- if (index != 0) {
157
- navController.popBackStack(
158
- navController.graph.startDestinationRoute!!,
159
- inclusive = false
160
- )
165
+ if (backToInitialTab && "option$index" != startRoute) {
166
+ backToStartTab()
161
167
  } else {
162
168
  navigator.pop()
163
169
  }
@@ -168,6 +174,9 @@ fun BottomTab(
168
174
  }
169
175
  }
170
176
  }
177
+ BackHandler(enabled = !onStartTab) {
178
+ if (backToInitialTab) backToStartTab() else navigator.pop()
179
+ }
171
180
  if (layout.isIPhoneDuo) {
172
181
  DuoTabBar(
173
182
  layout = layout,