@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.
- package/CLAUDE.md +7 -0
- package/compose/build.gradle.kts +1 -1
- package/compose/compose.podspec +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Card.kt +13 -11
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +21 -9
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +157 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Section.kt +11 -9
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +4 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +47 -21
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +14 -7
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +37 -16
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +2 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +1 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +1 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +53 -15
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +1 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +169 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +4 -1
- package/gradle.properties +1 -1
- package/ios/native-kits.podspec +1 -1
- package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
- package/package.json +1 -1
- package/.claude/settings.local.json +0 -185
- 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.
|
package/compose/build.gradle.kts
CHANGED
package/compose/compose.podspec
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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 =
|
|
32
|
-
val
|
|
35
|
+
val columns = gridColumns()
|
|
36
|
+
val gap = Spacing.S
|
|
33
37
|
val shape = RoundedCornerShape(Radius.M)
|
|
34
|
-
var surface = modifier.fillMaxWidth().padding(horizontal =
|
|
35
|
-
if (useShadow) surface = surface.shadow(
|
|
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
|
-
|
|
44
|
-
CompositionLocalProvider(LocalGridContext provides GridContext(columns, gutter, sizePerSpan)) {
|
|
46
|
+
CompositionLocalProvider(LocalGridContext provides gridContext(constraints.maxWidth, columns, gap)) {
|
|
45
47
|
FlowRow(
|
|
46
|
-
horizontalArrangement = Arrangement.spacedBy(
|
|
47
|
-
verticalArrangement = Arrangement.spacedBy(
|
|
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
|
|
15
|
-
val
|
|
16
|
+
val gutterPx: Int = 0,
|
|
17
|
+
val columnPx: Float = 0f,
|
|
18
|
+
val density: Float = 1f,
|
|
16
19
|
) {
|
|
17
|
-
fun sizeForSpan(span: Int): Dp =
|
|
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
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
23
|
-
val
|
|
24
|
-
val margin = if (useMargin)
|
|
25
|
-
Box(modifier =
|
|
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
|
-
|
|
31
|
-
CompositionLocalProvider(LocalGridContext provides GridContext(columns, gutter, sizePerSpan)) {
|
|
33
|
+
CompositionLocalProvider(LocalGridContext provides gridContext(constraints.maxWidth, columns, gap)) {
|
|
32
34
|
FlowRow(
|
|
33
|
-
horizontalArrangement = Arrangement.spacedBy(
|
|
34
|
-
verticalArrangement = Arrangement.spacedBy(
|
|
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
|
|
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
|
-
|
|
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()
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
434
|
-
val
|
|
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 =
|
|
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.
|
|
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
|
|
46
|
+
val layout = getScreenLayout()
|
|
46
47
|
val mid = items.size / 2
|
|
47
48
|
|
|
48
49
|
val tabWidth = if (floatingButton != null) {
|
|
49
|
-
(
|
|
50
|
+
(layout.contentWidth - floatingButtonWidth) / items.size
|
|
50
51
|
} else {
|
|
51
|
-
|
|
52
|
+
layout.contentWidth / items.size
|
|
52
53
|
}
|
|
53
54
|
val adjustSize = if(floatingButton != null && selectedIndex >= mid) floatingButtonWidth else 0.dp
|
|
54
|
-
|
|
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 {
|
|
107
|
+
floatingButton?.let {
|
|
108
|
+
Box(Modifier.offset(x = (layout.insetLeft - layout.insetRight) / 2)) {
|
|
109
|
+
FloatingButton(it)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
105
112
|
}
|
|
106
113
|
}
|
|
107
114
|
|