@momo-kits/native-kits 0.167.1-duo.1-debug → 0.167.1-duo.2-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 (25) hide show
  1. package/CLAUDE.md +7 -0
  2. package/compose/build.gradle.kts +1 -1
  3. package/compose/compose.podspec +1 -1
  4. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +1 -1
  5. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Card.kt +13 -11
  6. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +21 -9
  7. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +157 -0
  8. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Section.kt +11 -9
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +4 -0
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +47 -21
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +14 -7
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +37 -16
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +2 -2
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +1 -2
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +1 -2
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +53 -15
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +1 -2
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +169 -0
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +4 -1
  20. package/gradle.properties +1 -1
  21. package/ios/native-kits.podspec +1 -1
  22. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  23. package/package.json +1 -1
  24. package/.claude/settings.local.json +0 -185
  25. package/local.properties +0 -8
package/CLAUDE.md CHANGED
@@ -47,6 +47,13 @@ Publishing is automated (`publish.sh` + GitLab CI on `[ci build]` / `main`). Do
47
47
  - Navigation uses a runtime `DynamicScreenRegistry`; overlays (modal/bottom-sheet/snackbar) go through `Navigator` + `OverplayComponentRegistry`.
48
48
  - **iOS swipe-back:** the host owns the only edge-pan recogniser and replays it through `HostBackGestureBridge` (`navigation/HostBackGestureBridge.kt`; the kit framework is never exported, so the host reaches it through its own Koin-provided facade): `checkCanPop` / `canPopInner` / `pop` route the back into the kit stack, `started` / `progressed` / `completed` / `cancelled` seek the NavHost transition, `shouldUseFloatingArrowBack` is set by bottom tabs. `NavigationContainer` attaches itself automatically (`AttachHostBackGesture`, iOS only, detached by identity on dispose) — hosts need no wrapper and no `setNavigator`. `maxApi` is a required parameter of `NavigationContainer` — every container, nested or root, is handed its host bridge explicitly.
49
49
 
50
+ ## Screen layout / iPhone Duo (port of RN `useScreenLayout`)
51
+
52
+ - `layout/ScreenLayout.kt`: `getScreenLayout()` returns window size, side safe-area insets, `contentWidth`, header geometry and the iPhone Duo `sideBar`. `NavigationContainer` computes it once and provides `LocalScreenLayout`; reactive to fold/unfold and rotation (window size + `WindowInsets`). Metrics (`DuoMetrics`, 72pt iPhone header floor, 20pt status-bar threshold) mirror RN `Layout/utils.ts` — change both together.
53
+ - Header height is `headerHeight` (not `AppStatusBar + HEADER_HEIGHT`); the bar row is `headerBarHeight`. When the status bar is off the top edge (iPhone landscape, iPhone Duo) `headerStatusBarHeight` is 0 so the title stays centered.
54
+ - iPhone Duo with a side strip ≥ 60pt: back + toolkit leave the bar for `HeaderSideBar` (`navigation/component/SideBar.kt`, 48pt glass controls); custom header-right stays in the bar. The column starts below the camera / status-bar cluster: 170pt portrait, 120pt in any landscape (folded too — RN still uses 170 there), below the header when the status bar is on top; UIKit's `statusBarFrame` is useless on Duo. Unlike RN it is clamped 24pt above footer / tab bar, so a long toolkit (shortcut + group) never covers the footer.
55
+ - `StackScreen` pads content/footer/search/FAB by the side insets and provides an inset-consumed layout to its content; a bottom-tab root stays full width (tab bar + each tab's StackScreen handle insets). Grid columns use `contentWidth`.
56
+
50
57
  ## i18n / Localize (port of RN `Localize`)
51
58
 
52
59
  - `application/Localize.kt`: everything i18n. `LocalizationObject` is a data class with required `vi` / `en` maps (`LocalizationObject(vi = mapOf(…), en = mapOf(…))`). `Localize` constructors take a `LocalizationObject` or a `(vi, en)` map pair; the constructor **merges** the kit defaults with the host data (host wins on key clash). Methods: `translate(key)` (missing/empty → returns the key), `translateData(map)` / `translateData(vi, en)`, `addTranslations` (existing keys win), `changeLanguage(lang)`. Default language `"vi"`, `"en"` only on exact match; language is snapshot state so a switch recomposes consumers.
@@ -40,7 +40,7 @@ kotlin {
40
40
  }
41
41
 
42
42
  cocoapods {
43
- version = "0.167.1-duo.1-debug"
43
+ version = "0.167.1-duo.2-debug"
44
44
  summary = "IOS Shared module"
45
45
  homepage = "https://momo.vn"
46
46
  ios.deploymentTarget = "15.0"
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |spec|
2
2
  spec.name = 'compose'
3
- spec.version = '0.167.1-duo.1-debug'
3
+ spec.version = '0.167.1-beta.4-debug'
4
4
  spec.homepage = 'https://momo.vn'
5
5
  spec.source = { :http=> ''}
6
6
  spec.authors = ''
@@ -18,7 +18,7 @@ import vn.momo.kits.const.Spacing
18
18
  enum class DividerType { DEFAULT, DASH }
19
19
 
20
20
  @Composable
21
- fun Divider(useMargin: Boolean = true, type: DividerType = DividerType.DEFAULT) {
21
+ fun Divider(useMargin: Boolean = false, type: DividerType = DividerType.DEFAULT) {
22
22
  val color = AppTheme.current.colors.border.default
23
23
  val margin = if (useMargin) Spacing.XS else 0.dp
24
24
  if (type == DividerType.DASH) {
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
4
4
  import androidx.compose.foundation.layout.Arrangement
5
5
  import androidx.compose.foundation.layout.Box
6
6
  import androidx.compose.foundation.layout.BoxWithConstraints
7
+ import androidx.compose.foundation.layout.ExperimentalLayoutApi
7
8
  import androidx.compose.foundation.layout.FlowRow
8
9
  import androidx.compose.foundation.layout.PaddingValues
9
10
  import androidx.compose.foundation.layout.fillMaxWidth
@@ -14,6 +15,7 @@ import androidx.compose.runtime.CompositionLocalProvider
14
15
  import androidx.compose.ui.Modifier
15
16
  import androidx.compose.ui.draw.clip
16
17
  import androidx.compose.ui.draw.shadow
18
+ import androidx.compose.ui.graphics.Color
17
19
  import androidx.compose.ui.unit.dp
18
20
  import vn.momo.kits.components.Image
19
21
  import vn.momo.kits.const.AppTheme
@@ -21,30 +23,30 @@ import vn.momo.kits.const.Radius
21
23
  import vn.momo.kits.const.Spacing
22
24
 
23
25
  @Composable
24
- internal fun Card(
26
+ @OptIn(ExperimentalLayoutApi::class)
27
+ fun Card(
25
28
  useShadow: Boolean = false,
26
29
  backgroundImage: String? = null,
30
+ backgroundColor: Color? = null,
27
31
  contentPadding: PaddingValues = PaddingValues(Spacing.M),
28
32
  modifier: Modifier = Modifier,
29
33
  content: @Composable () -> Unit,
30
34
  ) {
31
- val columns = 12
32
- val gutter = 8.dp
35
+ val columns = gridColumns()
36
+ val gap = Spacing.S
33
37
  val shape = RoundedCornerShape(Radius.M)
34
- var surface = modifier.fillMaxWidth().padding(horizontal = 12.dp)
35
- if (useShadow) surface = surface.shadow(4.dp, shape)
36
- surface = surface.clip(shape)
37
- if (backgroundImage == null) surface = surface.background(AppTheme.current.colors.background.surface)
38
+ var surface = modifier.fillMaxWidth().padding(horizontal = Spacing.M)
39
+ if (useShadow) surface = surface.shadow(6.dp, shape)
40
+ surface = surface.clip(shape).background(backgroundColor ?: AppTheme.current.colors.background.surface)
38
41
  Box(modifier = surface) {
39
42
  if (backgroundImage != null) {
40
43
  Image(source = backgroundImage, modifier = Modifier.matchParentSize())
41
44
  }
42
45
  BoxWithConstraints(modifier = Modifier.fillMaxWidth().padding(contentPadding)) {
43
- val sizePerSpan = (maxWidth - gutter * (columns - 1)) / columns - 1.dp / columns
44
- CompositionLocalProvider(LocalGridContext provides GridContext(columns, gutter, sizePerSpan)) {
46
+ CompositionLocalProvider(LocalGridContext provides gridContext(constraints.maxWidth, columns, gap)) {
45
47
  FlowRow(
46
- horizontalArrangement = Arrangement.spacedBy(gutter),
47
- verticalArrangement = Arrangement.spacedBy(gutter),
48
+ horizontalArrangement = Arrangement.spacedBy(gap),
49
+ verticalArrangement = Arrangement.spacedBy(gap),
48
50
  ) { content() }
49
51
  }
50
52
  }
@@ -6,33 +6,45 @@ import androidx.compose.foundation.layout.width
6
6
  import androidx.compose.runtime.Composable
7
7
  import androidx.compose.runtime.compositionLocalOf
8
8
  import androidx.compose.ui.Modifier
9
+ import androidx.compose.ui.platform.LocalDensity
9
10
  import androidx.compose.ui.unit.Dp
10
11
  import androidx.compose.ui.unit.dp
12
+ import kotlin.math.floor
11
13
 
12
14
  internal data class GridContext(
13
15
  val numberOfColumns: Int = 12,
14
- val gutter: Dp = 12.dp,
15
- val sizePerSpan: Dp = 0.dp,
16
+ val gutterPx: Int = 0,
17
+ val columnPx: Float = 0f,
18
+ val density: Float = 1f,
16
19
  ) {
17
- fun sizeForSpan(span: Int): Dp = sizePerSpan * span + gutter * (span - 1)
20
+ fun sizeForSpan(span: Int): Dp = (floor(columnPx * span + gutterPx * (span - 1)) / density).dp
18
21
  }
19
22
 
20
23
  internal val LocalGridContext = compositionLocalOf<GridContext?> { null }
21
24
 
22
25
  @Composable
23
- internal fun Item(
24
- widthSpan: Int = 12,
26
+ internal fun gridColumns(): Int = if (getScreenLayout().contentWidth > 600.dp) 24 else 12
27
+
28
+ @Composable
29
+ internal fun gridContext(maxWidthPx: Int, columns: Int, gap: Dp): GridContext {
30
+ val density = LocalDensity.current
31
+ val gutterPx = with(density) { gap.roundToPx() }
32
+ return GridContext(columns, gutterPx, (maxWidthPx - gutterPx * (columns - 1)).toFloat() / columns, density.density)
33
+ }
34
+
35
+ @Composable
36
+ fun Item(
37
+ widthSpan: Int? = null,
25
38
  heightSpan: Int = 0,
26
39
  modifier: Modifier = Modifier,
40
+ contentModifier: Modifier = Modifier,
27
41
  content: @Composable () -> Unit,
28
42
  ) {
29
43
  val grid = LocalGridContext.current
30
44
  var m = modifier
31
45
  if (grid != null) {
32
- m = m.width(grid.sizeForSpan(widthSpan))
46
+ m = m.width(grid.sizeForSpan(widthSpan ?: grid.numberOfColumns))
33
47
  if (heightSpan > 0) m = m.height(grid.sizeForSpan(heightSpan))
34
48
  }
35
- // A Column (not a Box) so multiple children flow VERTICALLY instead of stacking
36
- // on top of each other — matches the React Item's flex-column content.
37
- Column(modifier = m) { content() }
49
+ Column(modifier = m.then(contentModifier)) { content() }
38
50
  }
@@ -0,0 +1,157 @@
1
+ package vn.momo.kits.layout
2
+
3
+ import androidx.compose.runtime.Composable
4
+ import androidx.compose.runtime.Immutable
5
+ import androidx.compose.runtime.compositionLocalOf
6
+ import androidx.compose.runtime.remember
7
+ import androidx.compose.ui.platform.LocalDensity
8
+ import androidx.compose.ui.platform.LocalLayoutDirection
9
+ import androidx.compose.ui.platform.LocalWindowInfo
10
+ import androidx.compose.ui.unit.Dp
11
+ import androidx.compose.ui.unit.dp
12
+ import vn.momo.kits.const.Spacing
13
+ import vn.momo.kits.navigation.component.HEADER_HEIGHT
14
+ import vn.momo.kits.platform.getPlatformName
15
+ import vn.momo.kits.utils.getAppStatusBarHeight
16
+ import vn.momo.kits.utils.horizontalSafeAreaInsets
17
+
18
+ enum class SideBarEdge { LEFT, RIGHT }
19
+
20
+ @Immutable
21
+ data class SideBarLayout(
22
+ val edge: SideBarEdge,
23
+ val width: Dp,
24
+ // Preferred window y of the first control; the column is still kept above footer and bottom bars.
25
+ val top: Dp,
26
+ )
27
+
28
+ @Immutable
29
+ data class ScreenLayout(
30
+ val width: Dp,
31
+ val height: Dp,
32
+ val isIPhoneDuo: Boolean,
33
+ val insetLeft: Dp,
34
+ val insetRight: Dp,
35
+ val contentWidth: Dp,
36
+ val headerHeight: Dp,
37
+ // 0 when the status bar is off the top edge, so the bar row fills the header and keeps the title centered.
38
+ val headerStatusBarHeight: Dp,
39
+ // iPhone Duo moves back and toolkit into a column in the side safe area; null while they stay in the bar.
40
+ val sideBar: SideBarLayout? = null,
41
+ ) {
42
+ val headerBarHeight: Dp get() = headerHeight - headerStatusBarHeight
43
+ }
44
+
45
+ // Measured by momo-kits RN (Layout/utils.ts) on the iOS 27.1 iPhone Duo simulator; keep both in sync.
46
+ internal object DuoMetrics {
47
+ // Inner display is ~1.42, outer ~1.45; every slab iPhone is 1.77 or taller.
48
+ const val MAX_ASPECT_RATIO = 1.6f
49
+ val controlSize = 48.dp
50
+ val itemPitch = 50.dp
51
+ // Portrait: below the camera/clock cluster in the strip (ends near y 151).
52
+ val firstItemTop = 170.dp
53
+ // Landscape: below the inner display's status cluster (ends near y 98); folded, it clears the camera
54
+ // (ends near y 60) and leaves the longest toolkit ~36pt above the footer.
55
+ val firstItemTopLandscape = 120.dp
56
+ }
57
+
58
+ private val IPHONE_HEADER_MIN_HEIGHT = 72.dp
59
+
60
+ // A smaller top inset means the status bar is not on the top edge.
61
+ private val STATUS_BAR_MIN_INSET = 20.dp
62
+
63
+ internal val LocalScreenLayout = compositionLocalOf<ScreenLayout?> { null }
64
+
65
+ // Port of RN `useScreenLayout`; follows fold/unfold and rotation through the window size and safe area.
66
+ @Composable
67
+ fun getScreenLayout(): ScreenLayout =
68
+ LocalScreenLayout.current ?: calculateScreenLayout(getAppStatusBarHeight())
69
+
70
+ @Composable
71
+ internal fun calculateScreenLayout(topInset: Dp, parent: ScreenLayout? = null): ScreenLayout {
72
+ val isIOS = getPlatformName() == "iOS"
73
+ if (parent != null) {
74
+ val (headerHeight, headerStatusBarHeight) = headerGeometry(topInset, isIOS)
75
+ return parent.copy(headerHeight = headerHeight, headerStatusBarHeight = headerStatusBarHeight)
76
+ }
77
+
78
+ val density = LocalDensity.current
79
+ val layoutDirection = LocalLayoutDirection.current
80
+ val size = LocalWindowInfo.current.containerSize
81
+ val insets = horizontalSafeAreaInsets
82
+ val leftPx = insets.getLeft(density, layoutDirection)
83
+ val rightPx = insets.getRight(density, layoutDirection)
84
+
85
+ return remember(size, leftPx, rightPx, topInset, density) {
86
+ with(density) {
87
+ screenLayoutOf(
88
+ width = size.width.toDp(),
89
+ height = size.height.toDp(),
90
+ insetLeft = leftPx.toDp(),
91
+ insetRight = rightPx.toDp(),
92
+ topInset = topInset,
93
+ isIOS = isIOS,
94
+ asymmetricInsets = leftPx != rightPx,
95
+ )
96
+ }
97
+ }
98
+ }
99
+
100
+ // Content inside the safe-area padding: the insets are spent and the side bar belongs to the screen.
101
+ internal fun ScreenLayout.consumeHorizontalInsets(): ScreenLayout =
102
+ copy(width = contentWidth, insetLeft = 0.dp, insetRight = 0.dp, sideBar = null)
103
+
104
+ private fun headerGeometry(topInset: Dp, isIOS: Boolean): Pair<Dp, Dp> {
105
+ val height = topInset + HEADER_HEIGHT.dp
106
+ if (!isIOS) return height to topInset
107
+ return maxOf(height, IPHONE_HEADER_MIN_HEIGHT) to if (topInset < STATUS_BAR_MIN_INSET) 0.dp else topInset
108
+ }
109
+
110
+ // Slab iPhones report equal side insets in landscape; iPhone Duo moves the status bar to one side.
111
+ private fun isIPhoneDuoWindow(width: Dp, height: Dp, asymmetricInsets: Boolean): Boolean {
112
+ val shortSide = minOf(width, height)
113
+ if (shortSide <= 0.dp) return asymmetricInsets
114
+ return maxOf(width, height) / shortSide < DuoMetrics.MAX_ASPECT_RATIO || asymmetricInsets
115
+ }
116
+
117
+ private fun screenLayoutOf(
118
+ width: Dp,
119
+ height: Dp,
120
+ insetLeft: Dp,
121
+ insetRight: Dp,
122
+ topInset: Dp,
123
+ isIOS: Boolean,
124
+ asymmetricInsets: Boolean,
125
+ ): ScreenLayout {
126
+ val (headerHeight, headerStatusBarHeight) = headerGeometry(topInset, isIOS)
127
+ val isIPhoneDuo = isIOS && isIPhoneDuoWindow(width, height, asymmetricInsets)
128
+
129
+ var sideBar: SideBarLayout? = null
130
+ val sideInset = maxOf(insetLeft, insetRight)
131
+ // narrower strips cannot host a control 12pt from the inner edge
132
+ if (isIPhoneDuo && sideInset >= DuoMetrics.controlSize + Spacing.M) {
133
+ // UIKit reports no usable status bar frame here (a 2pt top strip), so the spot is inferred like RN
134
+ val top = when {
135
+ topInset >= STATUS_BAR_MIN_INSET -> topInset + HEADER_HEIGHT.dp + Spacing.M
136
+ width > height -> DuoMetrics.firstItemTopLandscape
137
+ else -> DuoMetrics.firstItemTop
138
+ }
139
+ sideBar = SideBarLayout(
140
+ edge = if (insetRight >= insetLeft) SideBarEdge.RIGHT else SideBarEdge.LEFT,
141
+ width = sideInset,
142
+ top = top,
143
+ )
144
+ }
145
+
146
+ return ScreenLayout(
147
+ width = width,
148
+ height = height,
149
+ isIPhoneDuo = isIPhoneDuo,
150
+ insetLeft = insetLeft,
151
+ insetRight = insetRight,
152
+ contentWidth = width - insetLeft - insetRight,
153
+ headerHeight = headerHeight,
154
+ headerStatusBarHeight = headerStatusBarHeight,
155
+ sideBar = sideBar,
156
+ )
157
+ }
@@ -3,6 +3,7 @@ package vn.momo.kits.layout
3
3
  import androidx.compose.foundation.layout.Arrangement
4
4
  import androidx.compose.foundation.layout.Box
5
5
  import androidx.compose.foundation.layout.BoxWithConstraints
6
+ import androidx.compose.foundation.layout.ExperimentalLayoutApi
6
7
  import androidx.compose.foundation.layout.FlowRow
7
8
  import androidx.compose.foundation.layout.fillMaxWidth
8
9
  import androidx.compose.foundation.layout.padding
@@ -11,27 +12,28 @@ import androidx.compose.runtime.CompositionLocalProvider
11
12
  import androidx.compose.ui.Modifier
12
13
  import androidx.compose.ui.unit.dp
13
14
  import vn.momo.kits.components.Image
15
+ import vn.momo.kits.const.Spacing
14
16
 
15
17
  @Composable
16
- internal fun Section(
18
+ @OptIn(ExperimentalLayoutApi::class)
19
+ fun Section(
17
20
  useMargin: Boolean = true,
18
21
  backgroundImage: String? = null,
19
22
  modifier: Modifier = Modifier,
20
23
  content: @Composable () -> Unit,
21
24
  ) {
22
- val columns = 12
23
- val gutter = 12.dp
24
- val margin = if (useMargin) 12.dp else 0.dp
25
- Box(modifier = modifier.fillMaxWidth().padding(horizontal = margin)) {
25
+ val columns = gridColumns()
26
+ val gap = Spacing.M
27
+ val margin = if (useMargin) Spacing.M else 0.dp
28
+ Box(modifier = Modifier.fillMaxWidth().padding(horizontal = margin).then(modifier)) {
26
29
  if (backgroundImage != null) {
27
30
  Image(source = backgroundImage, modifier = Modifier.matchParentSize())
28
31
  }
29
32
  BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
30
- val sizePerSpan = (maxWidth - gutter * (columns - 1)) / columns - 1.dp / columns
31
- CompositionLocalProvider(LocalGridContext provides GridContext(columns, gutter, sizePerSpan)) {
33
+ CompositionLocalProvider(LocalGridContext provides gridContext(constraints.maxWidth, columns, gap)) {
32
34
  FlowRow(
33
- horizontalArrangement = Arrangement.spacedBy(gutter),
34
- verticalArrangement = Arrangement.spacedBy(gutter),
35
+ horizontalArrangement = Arrangement.spacedBy(gap),
36
+ verticalArrangement = Arrangement.spacedBy(gap),
35
37
  ) { content() }
36
38
  }
37
39
  }
@@ -31,6 +31,8 @@ import vn.momo.kits.const.AppStatusBar
31
31
  import vn.momo.kits.const.AppTheme
32
32
  import vn.momo.kits.const.Theme
33
33
  import vn.momo.kits.const.defaultTheme
34
+ import vn.momo.kits.layout.LocalScreenLayout
35
+ import vn.momo.kits.layout.calculateScreenLayout
34
36
  import vn.momo.kits.platform.ProvideNavigationEventDispatcherOwner
35
37
  import vn.momo.kits.platform.ProvideOsFontScale
36
38
  import vn.momo.kits.utils.getAppStatusBarHeight
@@ -56,6 +58,7 @@ fun NavigationContainer(
56
58
  val navigator = remember { Navigator(navController = navController, maxApi = maxApi, registry = registry) }
57
59
  val statusBarHeight = statusBarHeight ?: getAppStatusBarHeight()
58
60
  val navigationBarHeight = getNavigationBarHeight()
61
+ val screenLayout = calculateScreenLayout(statusBarHeight, parent = LocalScreenLayout.current)
59
62
 
60
63
  val parentContext = LocalContext.current
61
64
  val mergedContext = MiniAppContext.merge(parentContext, miniAppContext)
@@ -101,6 +104,7 @@ fun NavigationContainer(
101
104
  AppTheme provides resolvedTheme,
102
105
  AppStatusBar provides statusBarHeight,
103
106
  AppNavigationBar provides navigationBarHeight,
107
+ LocalScreenLayout provides screenLayout,
104
108
  LocalContext provides mergedContext,
105
109
  LocalDesignSystemConfig provides resolvedDesignSystemConfig,
106
110
  LocalLocalize provides resolvedLocalize,
@@ -51,7 +51,6 @@ import kotlinx.coroutines.flow.filter
51
51
  import kotlinx.coroutines.launch
52
52
  import vn.momo.kits.components.InputSearch
53
53
  import vn.momo.kits.const.AppNavigationBar
54
- import vn.momo.kits.const.AppStatusBar
55
54
  import vn.momo.kits.const.AppTheme
56
55
  import vn.momo.kits.application.IsShowBaseLineDebug
57
56
  import vn.momo.kits.const.Colors
@@ -65,6 +64,7 @@ import vn.momo.kits.navigation.component.HEADER_HEIGHT
65
64
  import vn.momo.kits.navigation.component.Header
66
65
  import vn.momo.kits.navigation.component.HeaderBackground
67
66
  import vn.momo.kits.navigation.component.HeaderRight
67
+ import vn.momo.kits.navigation.component.HeaderSideBar
68
68
  import vn.momo.kits.navigation.component.HeaderType
69
69
  import vn.momo.kits.navigation.component.InputSearchType
70
70
  import vn.momo.kits.platform.BackHandler
@@ -74,6 +74,9 @@ import vn.momo.kits.navigation.tracking.ScreenTrackingState
74
74
  import kotlinx.coroutines.delay
75
75
  import vn.momo.kits.application.LocalContext
76
76
  import vn.momo.kits.utils.horizontalSafeAreaPadding
77
+ import vn.momo.kits.layout.LocalScreenLayout
78
+ import vn.momo.kits.layout.consumeHorizontalInsets
79
+ import vn.momo.kits.layout.getScreenLayout
77
80
  import kotlin.time.Clock
78
81
  import kotlin.time.Duration.Companion.milliseconds
79
82
  import kotlin.time.ExperimentalTime
@@ -101,7 +104,7 @@ internal fun StackScreen(
101
104
  val navigator = LocalNavigator.current
102
105
  val maxApi = LocalMaxApi.current
103
106
  val context = LocalContext.current
104
- val statusBar = AppStatusBar.current
107
+ val screenLayout = getScreenLayout()
105
108
  val density = LocalDensity.current
106
109
  val screenRegistry = LocalDynamicScreenRegistry.current
107
110
  val navigation = remember { Navigation(id = id, bottomTabIndex = bottomTabIndex, initOptions = navigationOptions, registry = screenRegistry) }
@@ -157,7 +160,7 @@ internal fun StackScreen(
157
160
  }
158
161
 
159
162
  val limit = with(density) {
160
- (statusBar + HEADER_HEIGHT.dp).toPx()
163
+ screenLayout.headerHeight.toPx()
161
164
  }.toInt()
162
165
 
163
166
  val lazyListState = options.scrollData.scrollState as? LazyListState
@@ -209,11 +212,13 @@ internal fun StackScreen(
209
212
  Header(onBackHandler)
210
213
  }
211
214
 
215
+ HeaderSideBar(bottomInset = bottomBarsHeight(isBottomTabChild), modifier = Modifier.zIndex(4f))
216
+
212
217
  Column(Modifier.zIndex(5f).horizontalSafeAreaPadding()) {
213
218
  SearchAnimated(snapState = snapState)
214
219
  }
215
220
 
216
- Column(Modifier.zIndex(2f).fillMaxSize().horizontalSafeAreaPadding()) {
221
+ Column(Modifier.zIndex(2f).fillMaxSize()) {
217
222
  MainContent(content = content, isBottomTabRoot = isBottomTabRoot, isBottomTabChild = isBottomTabChild)
218
223
  FooterContent()
219
224
  }
@@ -227,22 +232,26 @@ internal fun StackScreen(
227
232
  }
228
233
  }
229
234
 
235
+ // Height the footer, tab bar or home indicator takes from the bottom of the screen.
236
+ @Composable
237
+ private fun bottomBarsHeight(isBottomTabChild: Boolean): Dp {
238
+ val options = LocalOptions.current
239
+ val navigationBar = AppNavigationBar.current
240
+ return when {
241
+ options.hasFooter() -> with(LocalDensity.current) { LocalFooterHeightPx.current.intValue.toDp() }
242
+ isBottomTabChild -> bottomTabBarInset() + (navigationBar - keyboardSizeState().value).coerceAtLeast(0.dp)
243
+ else -> navigationBar
244
+ }
245
+ }
246
+
230
247
  @Suppress("FrequentlyChangingValue")
231
248
  @Composable
232
249
  internal fun FloatingContent(isBottomTabChild: Boolean) {
233
250
  val options = LocalOptions.current
234
- val density = LocalDensity.current
235
- val footerHeightPx = LocalFooterHeightPx.current
236
251
  val scrollState = LocalScrollState.current
237
- val navigationBar = AppNavigationBar.current
238
252
 
239
253
  val fabProps = options.floatingButtonProps ?: return
240
- val bottomPadding = fabProps.bottom
241
- ?: (Spacing.M + when {
242
- options.hasFooter() -> with(density) { footerHeightPx.intValue.toDp() }
243
- isBottomTabChild -> bottomTabBarInset() + (navigationBar - keyboardSizeState().value).coerceAtLeast(0.dp)
244
- else -> navigationBar
245
- })
254
+ val bottomPadding = fabProps.bottom ?: (Spacing.M + bottomBarsHeight(isBottomTabChild))
246
255
 
247
256
  FloatingButton(
248
257
  scrollPosition = fabProps.scrollState?.value ?: scrollState.value,
@@ -264,11 +273,12 @@ internal fun ColumnScope.MainContent(content: @Composable () -> Unit, isBottomTa
264
273
  val density = LocalDensity.current
265
274
  val scrollState = LocalScrollState.current
266
275
  val navigationBar = AppNavigationBar.current
276
+ val screenLayout = getScreenLayout()
267
277
 
268
278
  Spacer(
269
279
  Modifier.height(
270
280
  if (options.headerType is HeaderType.DefaultOrExtended || (options.headerType is HeaderType.Transparent && !options.headerType.isFullScreenContent))
271
- AppStatusBar.current + HEADER_HEIGHT.dp else 0.dp
281
+ screenLayout.headerHeight else 0.dp
272
282
  )
273
283
  )
274
284
  if (inputSearchType == InputSearchType.Animated) {
@@ -276,10 +286,14 @@ internal fun ColumnScope.MainContent(content: @Composable () -> Unit, isBottomTa
276
286
 
277
287
  Spacer(Modifier.height((HEADER_HEIGHT.dp - scrollDp).coerceIn(0.dp, HEADER_HEIGHT.dp)))
278
288
  }
289
+ // a bottom tab root stays full width: the tab bar and each tab's own StackScreen handle the insets
279
290
  Column(
280
291
  Modifier
281
292
  .fillMaxWidth()
282
293
  .weight(1f)
294
+ .conditional(!isBottomTabRoot) {
295
+ horizontalSafeAreaPadding()
296
+ }
283
297
  .conditional(options.scrollData.scrollable && options.scrollData.scrollState is ScrollState) {
284
298
  verticalScroll(scrollState)
285
299
  }
@@ -292,7 +306,11 @@ internal fun ColumnScope.MainContent(content: @Composable () -> Unit, isBottomTa
292
306
  }
293
307
  )
294
308
  ) {
295
- ScreenContent(content = content)
309
+ CompositionLocalProvider(
310
+ LocalScreenLayout provides if (isBottomTabRoot) screenLayout else screenLayout.consumeHorizontalInsets()
311
+ ) {
312
+ ScreenContent(content = content)
313
+ }
296
314
  }
297
315
 
298
316
  }
@@ -311,7 +329,7 @@ internal fun ScreenContent(content: @Composable () -> Unit) {
311
329
  Box(bannerModifier) {
312
330
  animatedHeader.composable.invoke(scrollState.value)
313
331
  }
314
- Box(Modifier.padding(top = AppStatusBar.current + HEADER_HEIGHT.dp + animatedHeader.layoutOffSet)) {
332
+ Box(Modifier.padding(top = getScreenLayout().headerHeight + animatedHeader.layoutOffSet)) {
315
333
  content()
316
334
  }
317
335
  }
@@ -383,6 +401,7 @@ fun Footer(
383
401
  .conditional(IsShowBaseLineDebug) {
384
402
  border(1.dp, Colors.blue_03)
385
403
  }
404
+ .horizontalSafeAreaPadding()
386
405
  .padding(
387
406
  top = if (useMargin) Spacing.S else 0.dp,
388
407
  start = if (useMargin) Spacing.M else 0.dp,
@@ -430,17 +449,24 @@ internal fun SearchAnimated(
430
449
  if (inputSearchType == InputSearchType.None) return
431
450
  val inputSearchProps = (options.headerType as? HeaderType.DefaultOrExtended)?.inputSearchProps ?: return
432
451
 
433
- val minTopPadding = AppStatusBar.current
434
- val maxTopPadding = AppStatusBar.current + HEADER_HEIGHT.dp
452
+ val screenLayout = getScreenLayout()
453
+ val useSideBar = screenLayout.sideBar != null
454
+ // collapsed, the search is centered in the bar row
455
+ val minTopPadding = screenLayout.headerStatusBarHeight + (screenLayout.headerBarHeight - HEADER_HEIGHT.dp) / 2
456
+ val maxTopPadding = screenLayout.headerHeight
435
457
  val minStartPadding = Spacing.M
436
- val maxStartPadding = if (options.hiddenBack) Spacing.M else 48.dp // button back: size + left +right = 28 + 12 + 8 = 48
458
+ val maxStartPadding = if (options.hiddenBack || useSideBar) Spacing.M else 48.dp // button back: size + left +right = 28 + 12 + 8 = 48
437
459
  val minEndPadding = Spacing.M
438
- val maxEndPadding = headerRightWidthDp + if (options.headerRight is HeaderRight.None) Spacing.M else Spacing.M * 2
460
+ val maxEndPadding = when {
461
+ options.headerRight is HeaderRight.None -> Spacing.M
462
+ useSideBar && options.headerRight is HeaderRight.Toolkit -> Spacing.M
463
+ else -> headerRightWidthDp + Spacing.M * 2
464
+ }
439
465
 
440
466
  val targetColor = lerp(
441
467
  AppTheme.current.colors.background.surface,
442
468
  AppTheme.current.colors.background.default,
443
- (scrollDp / minTopPadding).coerceIn(0f, 1f)
469
+ (scrollDp / minTopPadding.coerceAtLeast(1.dp)).coerceIn(0f, 1f)
444
470
  )
445
471
  val animatedColor by animateColorAsState(targetValue = targetColor, label = "BackgroundColor")
446
472
 
@@ -26,7 +26,8 @@ import vn.momo.kits.const.*
26
26
  import vn.momo.kits.modifier.InternalApi
27
27
  import vn.momo.kits.modifier.conditional
28
28
  import vn.momo.kits.modifier.noFeedbackClickable
29
- import vn.momo.kits.platform.getScreenDimensions
29
+ import vn.momo.kits.layout.getScreenLayout
30
+ import vn.momo.kits.utils.horizontalSafeAreaPadding
30
31
 
31
32
  internal val floatingButtonWidth = 75.dp
32
33
  @InternalApi
@@ -42,16 +43,17 @@ internal fun BottomTabBar(
42
43
  val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
43
44
  val selectedIndex = items.indexOfFirst { "option${items.indexOf(it)}" == currentRoute }
44
45
 
45
- val screenWidth = getScreenDimensions().width.dp
46
+ val layout = getScreenLayout()
46
47
  val mid = items.size / 2
47
48
 
48
49
  val tabWidth = if (floatingButton != null) {
49
- (screenWidth - floatingButtonWidth) / items.size
50
+ (layout.contentWidth - floatingButtonWidth) / items.size
50
51
  } else {
51
- screenWidth / items.size
52
+ layout.contentWidth / items.size
52
53
  }
53
54
  val adjustSize = if(floatingButton != null && selectedIndex >= mid) floatingButtonWidth else 0.dp
54
- val targetOffset = tabWidth * selectedIndex + tabWidth / 2 - screenWidth / 2 + adjustSize
55
+ // offsets are from the bar center; tabs live between the side safe areas
56
+ val targetOffset = layout.insetLeft + tabWidth * selectedIndex + tabWidth / 2 - layout.width / 2 + adjustSize
55
57
 
56
58
  val indicatorOffsetX by animateDpAsState(
57
59
  targetValue = targetOffset,
@@ -69,7 +71,8 @@ internal fun BottomTabBar(
69
71
  .background(AppTheme.current.colors.background.surface)
70
72
  .conditional(IsShowBaseLineDebug) {
71
73
  border(1.dp, Colors.blue_03)
72
- },
74
+ }
75
+ .horizontalSafeAreaPadding(),
73
76
  horizontalArrangement = Arrangement.SpaceAround,
74
77
  ) {
75
78
  if (floatingButton == null) {
@@ -101,7 +104,11 @@ internal fun BottomTabBar(
101
104
  )
102
105
  )
103
106
  )
104
- floatingButton?.let { FloatingButton(it) }
107
+ floatingButton?.let {
108
+ Box(Modifier.offset(x = (layout.insetLeft - layout.insetRight) / 2)) {
109
+ FloatingButton(it)
110
+ }
111
+ }
105
112
  }
106
113
  }
107
114