@momo-kits/native-kits 0.168.1-beta.2-debug → 0.168.1-beta.24-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 +10 -6
- package/compose/build.gradle.kts +1 -1
- package/compose/compose.podspec +1 -1
- package/compose/src/androidMain/kotlin/vn/momo/kits/const/HostFont.android.kt +4 -3
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +0 -14
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/DesignSystemKits.kt +62 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +25 -16
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +3 -3
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +2 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +18 -14
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +25 -13
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +206 -45
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +14 -3
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +2 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +53 -30
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +2 -3
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +124 -59
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +3 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +2 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +10 -3
- package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +3 -1
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +9 -8
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +54 -10
- package/gradle.properties +1 -1
- package/ios/Application/ApplicationEnvironment.swift +7 -6
- package/ios/Application/DesignSystemKits.swift +163 -0
- package/ios/Application/DesignSystemKitsPayload.swift +68 -0
- package/ios/Badge/BadgeRibbon.swift +8 -1
- package/ios/Extensions/Color++.swift +2 -4
- package/ios/Input/Input.swift +13 -3
- package/ios/Input/InputPhoneNumber.swift +1 -3
- package/ios/Input/InputSearch.swift +1 -2
- package/ios/Input/InputTextArea.swift +5 -3
- package/ios/InputMoney/InputMoney.swift +6 -7
- package/ios/Layout/ScreenLayout.swift +65 -10
- package/ios/Navigation/BottomSheet.swift +153 -23
- package/ios/Navigation/BottomTab/DuoBarCompression.swift +2 -3
- package/ios/Navigation/BottomTab/DuoTabBar.swift +94 -69
- package/ios/Navigation/Component/HeaderBackground.swift +22 -1
- package/ios/Navigation/Component/SnackBar.swift +4 -0
- package/ios/Navigation/ModalScreen.swift +7 -3
- package/ios/Navigation/StackScreen.swift +3 -7
- package/ios/Popup/PopupDisplay.swift +2 -2
- package/ios/Popup/PopupInput.swift +2 -2
- package/ios/Popup/PopupLayout.swift +15 -10
- package/ios/Popup/PopupNotify.swift +2 -2
- package/ios/Stepper/Stepper.swift +1 -2
- package/ios/Template/TrustBanner/TrustBanner.swift +4 -2
- package/ios/Typography/FontScale.swift +29 -15
- package/ios/Typography/Text.swift +86 -12
- package/ios/Typography/Typography.swift +2 -2
- package/ios/native-kits.podspec +1 -1
- package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
- package/local.properties +8 -0
- package/package.json +1 -1
- package/.claude/settings.local.json +0 -9
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitDesignSystem.kt +0 -18
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitFontScale.kt +0 -22
- package/ios/Typography/FontScaleStore.swift +0 -58
- package/ios/Typography/KitDesignSystem.swift +0 -17
|
@@ -2,8 +2,12 @@ package vn.momo.kits.navigation.bottomtab
|
|
|
2
2
|
|
|
3
3
|
import androidx.compose.animation.core.Spring
|
|
4
4
|
import androidx.compose.animation.core.animateDpAsState
|
|
5
|
+
import androidx.compose.animation.core.animateFloatAsState
|
|
5
6
|
import androidx.compose.animation.core.spring
|
|
6
7
|
import androidx.compose.foundation.background
|
|
8
|
+
import androidx.compose.foundation.clickable
|
|
9
|
+
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
10
|
+
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
|
7
11
|
import androidx.compose.foundation.border
|
|
8
12
|
import androidx.compose.foundation.layout.Arrangement
|
|
9
13
|
import androidx.compose.foundation.layout.Box
|
|
@@ -15,6 +19,7 @@ import androidx.compose.foundation.layout.RowScope
|
|
|
15
19
|
import androidx.compose.foundation.layout.absolutePadding
|
|
16
20
|
import androidx.compose.foundation.layout.fillMaxHeight
|
|
17
21
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
22
|
+
import androidx.compose.foundation.layout.fillMaxWidth
|
|
18
23
|
import androidx.compose.foundation.layout.height
|
|
19
24
|
import androidx.compose.foundation.layout.offset
|
|
20
25
|
import androidx.compose.foundation.layout.padding
|
|
@@ -23,25 +28,33 @@ import androidx.compose.foundation.layout.width
|
|
|
23
28
|
import androidx.compose.foundation.shape.CircleShape
|
|
24
29
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
25
30
|
import androidx.compose.runtime.Composable
|
|
31
|
+
import androidx.compose.runtime.CompositionLocalProvider
|
|
32
|
+
import androidx.compose.runtime.LaunchedEffect
|
|
33
|
+
import androidx.compose.runtime.MutableState
|
|
26
34
|
import androidx.compose.runtime.compositionLocalOf
|
|
27
35
|
import androidx.compose.runtime.getValue
|
|
36
|
+
import androidx.compose.runtime.mutableStateOf
|
|
37
|
+
import androidx.compose.runtime.remember
|
|
28
38
|
import androidx.compose.ui.AbsoluteAlignment
|
|
29
39
|
import androidx.compose.ui.Alignment
|
|
30
40
|
import androidx.compose.ui.Modifier
|
|
31
41
|
import androidx.compose.ui.draw.clip
|
|
32
42
|
import androidx.compose.ui.draw.shadow
|
|
43
|
+
import androidx.compose.ui.graphics.graphicsLayer
|
|
33
44
|
import androidx.compose.ui.graphics.Color
|
|
34
45
|
import androidx.compose.ui.graphics.Shape
|
|
46
|
+
import androidx.compose.ui.semantics.Role
|
|
35
47
|
import androidx.compose.ui.text.style.TextOverflow
|
|
36
48
|
import androidx.compose.ui.unit.Dp
|
|
37
49
|
import androidx.compose.ui.unit.dp
|
|
50
|
+
import vn.momo.kits.application.LocalContext
|
|
51
|
+
import vn.momo.kits.application.MiniAppContext
|
|
38
52
|
import vn.momo.kits.components.Badge
|
|
39
53
|
import vn.momo.kits.components.BadgeDot
|
|
40
54
|
import vn.momo.kits.components.DotSize
|
|
41
55
|
import vn.momo.kits.components.Icon
|
|
42
56
|
import vn.momo.kits.components.Text
|
|
43
57
|
import vn.momo.kits.const.AppTheme
|
|
44
|
-
import vn.momo.kits.const.Radius
|
|
45
58
|
import vn.momo.kits.const.Spacing
|
|
46
59
|
import vn.momo.kits.const.Typography
|
|
47
60
|
import vn.momo.kits.layout.DuoMetrics
|
|
@@ -59,14 +72,18 @@ private val RAIL_GAP = Spacing.XXS
|
|
|
59
72
|
// selection bubble overflows the 48 rail cell by 5 above and below
|
|
60
73
|
private val RAIL_BUBBLE_WIDTH = 44.dp
|
|
61
74
|
private val RAIL_BUBBLE_HEIGHT = 58.dp
|
|
62
|
-
private val FLOATING_HEIGHT =
|
|
75
|
+
private val FLOATING_HEIGHT = DuoMetrics.floatingTabBarHeight
|
|
63
76
|
private val FLOATING_PADDING = Spacing.XS
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
private const val
|
|
77
|
+
// the system bar sizes its pill to the tabs instead of stretching it across the display
|
|
78
|
+
private val FLOATING_ITEM_WIDTH = 80.dp
|
|
79
|
+
// while pressed the selection bubble grows into a lens past the pill's edges
|
|
80
|
+
private const val LENS_SCALE_X = 1.2f
|
|
81
|
+
private const val LENS_SCALE_Y = 1.3f
|
|
82
|
+
// Figma Home-2026 tab bar "Selection": brand pink at 8%
|
|
83
|
+
private const val SELECTION_TINT_ALPHA = 0x14 / 255f
|
|
68
84
|
// RN reanimated spring { damping: 18, stiffness: 220, mass: 0.8 }
|
|
69
85
|
private val BUBBLE_SPRING = spring<Dp>(dampingRatio = 0.68f, stiffness = 275f, visibilityThreshold = Dp(0.5f))
|
|
86
|
+
private val LENS_SPRING = spring<Float>(dampingRatio = 0.68f, stiffness = 275f)
|
|
70
87
|
|
|
71
88
|
// Space from the window bottom the rail stack and its gap take, so the header side bar stays above it.
|
|
72
89
|
internal val LocalDuoRailReserve = compositionLocalOf { 0.dp }
|
|
@@ -78,11 +95,15 @@ internal fun duoRailHeight(itemCount: Int, hasQr: Boolean, compressed: Boolean =
|
|
|
78
95
|
return rail + if (hasQr) Spacing.M + ITEM_SIZE else 0.dp
|
|
79
96
|
}
|
|
80
97
|
|
|
98
|
+
// Overlaps the home indicator inset like the system tab bar (21pt above the bottom with a 34pt inset).
|
|
99
|
+
private fun floatingTabBarBottom(insetBottom: Dp): Dp =
|
|
100
|
+
maxOf(insetBottom - DuoMetrics.floatingTabBarInsetOverlap, Spacing.S)
|
|
101
|
+
|
|
81
102
|
// Space tab content leaves under the floating bar; 0 when the rail sits in the side strip or off Duo.
|
|
82
103
|
internal fun duoFloatingTabBarInset(layout: ScreenLayout): Dp =
|
|
83
|
-
if (layout.isIPhoneDuo && layout.sideBar == null) FLOATING_HEIGHT +
|
|
104
|
+
if (layout.isIPhoneDuo && layout.sideBar == null) FLOATING_HEIGHT + floatingTabBarBottom(layout.insetBottom) else 0.dp
|
|
84
105
|
|
|
85
|
-
// iPhone Duo tab bar:
|
|
106
|
+
// iPhone Duo tab bar: rail in the side bar, or a floating bar when unfolded portrait.
|
|
86
107
|
@Composable
|
|
87
108
|
internal fun BoxScope.DuoTabBar(
|
|
88
109
|
layout: ScreenLayout,
|
|
@@ -147,18 +168,19 @@ private fun BoxScope.DuoRailTabBar(
|
|
|
147
168
|
}
|
|
148
169
|
}
|
|
149
170
|
|
|
150
|
-
//
|
|
171
|
+
// Column of icon-only tab items with the sliding selection bubble.
|
|
151
172
|
@Composable
|
|
152
173
|
private fun Rail(items: List<BottomTabItem>, selectedIndex: Int, onItemClick: (Int) -> Unit) {
|
|
174
|
+
val pressedIndex = remember { mutableStateOf<Int?>(null) }
|
|
153
175
|
val bubbleOffset by animateDpAsState(
|
|
154
|
-
targetValue = (ITEM_SIZE + RAIL_GAP) * selectedIndex.coerceAtLeast(0),
|
|
176
|
+
targetValue = (ITEM_SIZE + RAIL_GAP) * (pressedIndex.value ?: selectedIndex).coerceAtLeast(0),
|
|
155
177
|
animationSpec = BUBBLE_SPRING,
|
|
156
178
|
label = "DuoRailBubble",
|
|
157
179
|
)
|
|
158
180
|
Box(
|
|
159
181
|
Modifier
|
|
160
182
|
.width(ITEM_SIZE)
|
|
161
|
-
.tabBarMaterial(
|
|
183
|
+
.tabBarMaterial(CircleShape)
|
|
162
184
|
.setAutomationId("bottom_tab_bar")
|
|
163
185
|
) {
|
|
164
186
|
Box(
|
|
@@ -168,7 +190,7 @@ private fun Rail(items: List<BottomTabItem>, selectedIndex: Int, onItemClick: (I
|
|
|
168
190
|
y = RAIL_PADDING - (RAIL_BUBBLE_HEIGHT - ITEM_SIZE) / 2 + bubbleOffset,
|
|
169
191
|
)
|
|
170
192
|
.size(RAIL_BUBBLE_WIDTH, RAIL_BUBBLE_HEIGHT)
|
|
171
|
-
.
|
|
193
|
+
.selectionLens(pressedIndex.value != null)
|
|
172
194
|
)
|
|
173
195
|
Column(
|
|
174
196
|
modifier = Modifier.padding(vertical = RAIL_PADDING),
|
|
@@ -178,7 +200,7 @@ private fun Rail(items: List<BottomTabItem>, selectedIndex: Int, onItemClick: (I
|
|
|
178
200
|
Box(
|
|
179
201
|
modifier = Modifier
|
|
180
202
|
.size(ITEM_SIZE)
|
|
181
|
-
.
|
|
203
|
+
.tabClickable(index, pressedIndex) { onItemClick(index) },
|
|
182
204
|
contentAlignment = Alignment.Center,
|
|
183
205
|
) {
|
|
184
206
|
DuoTabIcon(item, selected = index == selectedIndex)
|
|
@@ -188,6 +210,7 @@ private fun Rail(items: List<BottomTabItem>, selectedIndex: Int, onItemClick: (I
|
|
|
188
210
|
}
|
|
189
211
|
}
|
|
190
212
|
|
|
213
|
+
// Floating labelled bar for the unfolded portrait display, QR pinned in its own platter beside it.
|
|
191
214
|
@Composable
|
|
192
215
|
private fun BoxScope.DuoFloatingTabBar(
|
|
193
216
|
layout: ScreenLayout,
|
|
@@ -196,73 +219,115 @@ private fun BoxScope.DuoFloatingTabBar(
|
|
|
196
219
|
selectedIndex: Int,
|
|
197
220
|
onTabSelected: (Int) -> Unit,
|
|
198
221
|
) {
|
|
199
|
-
val
|
|
200
|
-
val barWidth = columnWidth * GRID_SPAN + Spacing.M * (GRID_SPAN - 1)
|
|
201
|
-
val useQr = floatingButton != null && items.size % 2 == 0
|
|
202
|
-
val middle = items.size / 2
|
|
203
|
-
val cellCount = items.size + if (useQr) 1 else 0
|
|
204
|
-
val visualIndex = if (useQr && selectedIndex >= middle) selectedIndex + 1 else selectedIndex
|
|
222
|
+
val pressedIndex = remember { mutableStateOf<Int?>(null) }
|
|
205
223
|
|
|
206
|
-
|
|
224
|
+
Row(
|
|
207
225
|
modifier = Modifier
|
|
208
226
|
.align(Alignment.BottomCenter)
|
|
209
|
-
.
|
|
210
|
-
.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
.padding(FLOATING_PADDING)
|
|
214
|
-
.setAutomationId("bottom_tab_bar")
|
|
215
|
-
) {
|
|
216
|
-
BoxWithConstraints(Modifier.fillMaxSize()) {
|
|
217
|
-
val cellWidth = maxWidth / cellCount
|
|
218
|
-
val bubbleOffset by animateDpAsState(
|
|
219
|
-
targetValue = cellWidth * visualIndex.coerceAtLeast(0),
|
|
220
|
-
animationSpec = BUBBLE_SPRING,
|
|
221
|
-
label = "DuoFloatingBubble",
|
|
227
|
+
.fillMaxWidth()
|
|
228
|
+
.absolutePadding(
|
|
229
|
+
left = layout.insetLeft + DuoMetrics.floatingTabBarMargin,
|
|
230
|
+
right = layout.insetRight + DuoMetrics.floatingTabBarMargin,
|
|
222
231
|
)
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
232
|
+
.padding(bottom = floatingTabBarBottom(layout.insetBottom)),
|
|
233
|
+
horizontalArrangement = Arrangement.spacedBy(Spacing.S, Alignment.CenterHorizontally),
|
|
234
|
+
) {
|
|
235
|
+
Box(
|
|
236
|
+
modifier = Modifier
|
|
237
|
+
.weight(1f, fill = false)
|
|
238
|
+
.width(FLOATING_ITEM_WIDTH * items.size + FLOATING_PADDING * 2)
|
|
239
|
+
.height(FLOATING_HEIGHT)
|
|
240
|
+
.tabBarMaterial(RoundedCornerShape(FLOATING_HEIGHT / 2))
|
|
241
|
+
.padding(FLOATING_PADDING)
|
|
242
|
+
.setAutomationId("bottom_tab_bar")
|
|
243
|
+
) {
|
|
244
|
+
BoxWithConstraints(Modifier.fillMaxSize()) {
|
|
245
|
+
val cellWidth = maxWidth / items.size.coerceAtLeast(1)
|
|
246
|
+
val bubbleOffset by animateDpAsState(
|
|
247
|
+
targetValue = cellWidth * (pressedIndex.value ?: selectedIndex).coerceAtLeast(0),
|
|
248
|
+
animationSpec = BUBBLE_SPRING,
|
|
249
|
+
label = "DuoFloatingBubble",
|
|
250
|
+
)
|
|
251
|
+
Box(
|
|
252
|
+
Modifier
|
|
253
|
+
.offset(x = bubbleOffset)
|
|
254
|
+
.width(cellWidth)
|
|
255
|
+
.fillMaxHeight()
|
|
256
|
+
.selectionLens(pressedIndex.value != null)
|
|
257
|
+
)
|
|
258
|
+
Row(Modifier.fillMaxSize()) {
|
|
259
|
+
items.forEachIndexed { index, item ->
|
|
260
|
+
FloatingCell(item, selected = index == selectedIndex, index, pressedIndex) {
|
|
261
|
+
onTabSelected(index)
|
|
235
262
|
}
|
|
236
263
|
}
|
|
237
|
-
FloatingCell(item, selected = index == selectedIndex) { onTabSelected(index) }
|
|
238
264
|
}
|
|
239
265
|
}
|
|
240
266
|
}
|
|
267
|
+
if (floatingButton != null) {
|
|
268
|
+
QrButton(floatingButton, size = FLOATING_HEIGHT, Modifier.shadow(3.dp, CircleShape))
|
|
269
|
+
}
|
|
241
270
|
}
|
|
242
271
|
}
|
|
243
272
|
|
|
244
273
|
@Composable
|
|
245
|
-
private fun RowScope.FloatingCell(
|
|
274
|
+
private fun RowScope.FloatingCell(
|
|
275
|
+
item: BottomTabItem,
|
|
276
|
+
selected: Boolean,
|
|
277
|
+
index: Int,
|
|
278
|
+
pressedIndex: MutableState<Int?>,
|
|
279
|
+
onClick: () -> Unit,
|
|
280
|
+
) {
|
|
246
281
|
val colors = AppTheme.current.colors
|
|
247
282
|
Column(
|
|
248
283
|
modifier = Modifier
|
|
249
284
|
.weight(1f)
|
|
250
285
|
.fillMaxHeight()
|
|
251
|
-
.
|
|
286
|
+
.tabClickable(index, pressedIndex, onClick),
|
|
252
287
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
253
288
|
verticalArrangement = Arrangement.spacedBy(Spacing.XXS, Alignment.CenterVertically),
|
|
254
289
|
) {
|
|
255
290
|
DuoTabIcon(item, selected)
|
|
256
|
-
Text
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
291
|
+
// like the system tab bar the label keeps its design size; Text grows it up to 5pt on the wide Duo displays
|
|
292
|
+
val context = LocalContext.current
|
|
293
|
+
val fixedScale = remember(context) {
|
|
294
|
+
(context ?: MiniAppContext()).copy(userScaleRate = 1f, useOSScaleRate = false)
|
|
295
|
+
}
|
|
296
|
+
CompositionLocalProvider(LocalContext provides fixedScale) {
|
|
297
|
+
Text(
|
|
298
|
+
text = item.label,
|
|
299
|
+
style = Typography.labelXsMedium,
|
|
300
|
+
color = if (selected) colors.primary else colors.text.hint,
|
|
301
|
+
maxLines = 1,
|
|
302
|
+
overflow = TextOverflow.Ellipsis,
|
|
303
|
+
)
|
|
304
|
+
}
|
|
263
305
|
}
|
|
264
306
|
}
|
|
265
307
|
|
|
308
|
+
// Tracks the finger so the selection lens jumps to the pressed tab before it is selected.
|
|
309
|
+
@Composable
|
|
310
|
+
private fun Modifier.tabClickable(index: Int, pressedIndex: MutableState<Int?>, onClick: () -> Unit): Modifier {
|
|
311
|
+
val interactionSource = remember { MutableInteractionSource() }
|
|
312
|
+
val pressed by interactionSource.collectIsPressedAsState()
|
|
313
|
+
LaunchedEffect(pressed) {
|
|
314
|
+
if (pressed) pressedIndex.value = index
|
|
315
|
+
else if (pressedIndex.value == index) pressedIndex.value = null
|
|
316
|
+
}
|
|
317
|
+
return clickable(interactionSource = interactionSource, indication = null, role = Role.Tab, onClick = onClick)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Selection bubble; while pressed it grows into a lens (80x54 to about 96x70).
|
|
321
|
+
@Composable
|
|
322
|
+
private fun Modifier.selectionLens(pressed: Boolean): Modifier {
|
|
323
|
+
val lens by animateFloatAsState(if (pressed) 1f else 0f, LENS_SPRING, label = "DuoSelectionLens")
|
|
324
|
+
val tint = AppTheme.current.colors.primary.copy(alpha = SELECTION_TINT_ALPHA)
|
|
325
|
+
return graphicsLayer {
|
|
326
|
+
scaleX = 1 + lens * (LENS_SCALE_X - 1)
|
|
327
|
+
scaleY = 1 + lens * (LENS_SCALE_Y - 1)
|
|
328
|
+
}.background(tint, CircleShape)
|
|
329
|
+
}
|
|
330
|
+
|
|
266
331
|
@Composable
|
|
267
332
|
private fun DuoTabIcon(item: BottomTabItem, selected: Boolean) {
|
|
268
333
|
val colors = AppTheme.current.colors
|
|
@@ -271,7 +336,7 @@ private fun DuoTabIcon(item: BottomTabItem, selected: Boolean) {
|
|
|
271
336
|
Icon(
|
|
272
337
|
source = item.icon,
|
|
273
338
|
size = ICON_SIZE,
|
|
274
|
-
color = if (selected) colors.primary else colors.text.
|
|
339
|
+
color = if (selected) colors.primary else colors.text.hint,
|
|
275
340
|
)
|
|
276
341
|
when {
|
|
277
342
|
badgeLabel == null && !item.showDot -> Unit
|
|
@@ -288,9 +353,9 @@ private fun DuoTabIcon(item: BottomTabItem, selected: Boolean) {
|
|
|
288
353
|
}
|
|
289
354
|
|
|
290
355
|
@Composable
|
|
291
|
-
private fun QrButton(data: BottomTabFloatingButton, size: Dp) {
|
|
356
|
+
private fun QrButton(data: BottomTabFloatingButton, size: Dp, modifier: Modifier = Modifier) {
|
|
292
357
|
Box(
|
|
293
|
-
modifier =
|
|
358
|
+
modifier = modifier
|
|
294
359
|
.size(size)
|
|
295
360
|
.clip(CircleShape)
|
|
296
361
|
.background(AppTheme.current.colors.primary)
|
|
@@ -302,7 +367,7 @@ private fun QrButton(data: BottomTabFloatingButton, size: Dp) {
|
|
|
302
367
|
}
|
|
303
368
|
}
|
|
304
369
|
|
|
305
|
-
// RN
|
|
370
|
+
// RN BarMaterial: surface with a 1dp border.
|
|
306
371
|
@Composable
|
|
307
372
|
private fun Modifier.tabBarMaterial(shape: Shape): Modifier {
|
|
308
373
|
val colors = AppTheme.current.colors
|
|
@@ -97,7 +97,7 @@ internal fun headerTitleMaxWidth(): Dp {
|
|
|
97
97
|
// Scroll distance over which the Duo glass bar fades in.
|
|
98
98
|
private val DUO_GLASS_FADE_DISTANCE = 16.dp
|
|
99
99
|
|
|
100
|
-
// RN DuoGlassBackground
|
|
100
|
+
// RN DuoGlassBackground: translucent surface with a hairline once content scrolls under it.
|
|
101
101
|
@Suppress("FrequentlyChangingValue")
|
|
102
102
|
@Composable
|
|
103
103
|
private fun Modifier.duoGlassBar(): Modifier {
|
|
@@ -90,7 +90,9 @@ internal fun HeaderBackground() {
|
|
|
90
90
|
.fillMaxWidth()
|
|
91
91
|
.then(if (fillsBox && !theme.isHeaderImage()) Modifier.fillMaxHeight() else Modifier.height(maxHeight))
|
|
92
92
|
.conditional(!theme.isHeaderImage()) {
|
|
93
|
-
|
|
93
|
+
// RN spans the gradient over 375:154 and clips it, so a default header shows only its top
|
|
94
|
+
val endY = if (fillsBox) Float.POSITIVE_INFINITY else with(density) { maxHeight.toPx() }
|
|
95
|
+
background(Brush.verticalGradient(colors = listOf(color, backgroundColor), endY = endY))
|
|
94
96
|
}
|
|
95
97
|
){
|
|
96
98
|
if (!theme.isHeaderImage()) return@Box
|
|
@@ -8,13 +8,13 @@ import androidx.compose.ui.text.style.TextAlign
|
|
|
8
8
|
import androidx.compose.ui.text.style.TextOverflow
|
|
9
9
|
import androidx.compose.ui.unit.sp
|
|
10
10
|
import androidx.compose.ui.zIndex
|
|
11
|
+
import vn.momo.kits.application.DesignSystemKits
|
|
11
12
|
import vn.momo.kits.components.Text
|
|
12
|
-
import vn.momo.kits.const.KitDesignSystem
|
|
13
13
|
import vn.momo.kits.const.Typography
|
|
14
14
|
import vn.momo.kits.modifier.setAutomationId
|
|
15
15
|
|
|
16
16
|
internal val headerFontFamily: String?
|
|
17
|
-
get() = if (
|
|
17
|
+
get() = if (DesignSystemKits.getConfigs().isWhiteList) "MoMoTrustDisplay" else null
|
|
18
18
|
|
|
19
19
|
@Composable
|
|
20
20
|
internal fun HeaderTitle(
|
|
@@ -7,7 +7,14 @@ import androidx.compose.ui.unit.Dp
|
|
|
7
7
|
@Immutable
|
|
8
8
|
internal data class ReservedRegion(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
// iPhone Duo camera, and the status bar cluster as it grows for a call or a Live Activity.
|
|
10
|
+
// occlusion: regions the system occludes (iOS 27.1+), in the coordinates of the Compose root view:
|
|
11
|
+
// the iPhone Duo camera, and the status bar cluster as it grows for a call or a Live Activity.
|
|
12
|
+
// isFoldable: the device reports a `.division` (hinge) region, Apple's foldable check.
|
|
13
|
+
@Immutable
|
|
14
|
+
internal data class ReservedRegions(
|
|
15
|
+
val occlusion: List<ReservedRegion> = emptyList(),
|
|
16
|
+
val isFoldable: Boolean = false,
|
|
17
|
+
)
|
|
18
|
+
|
|
12
19
|
@Composable
|
|
13
|
-
internal expect fun rememberReservedRegions():
|
|
20
|
+
internal expect fun rememberReservedRegions(): ReservedRegions
|
|
@@ -5,6 +5,7 @@ package vn.momo.kits.const
|
|
|
5
5
|
import androidx.compose.runtime.Composable
|
|
6
6
|
import androidx.compose.runtime.remember
|
|
7
7
|
import androidx.compose.ui.text.font.FontFamily
|
|
8
|
+
import androidx.compose.ui.text.font.FontWeight
|
|
8
9
|
import androidx.compose.ui.text.platform.Font
|
|
9
10
|
import kotlinx.cinterop.ExperimentalForeignApi
|
|
10
11
|
import kotlinx.cinterop.addressOf
|
|
@@ -15,7 +16,7 @@ import platform.Foundation.dataWithContentsOfFile
|
|
|
15
16
|
import platform.posix.memcpy
|
|
16
17
|
|
|
17
18
|
@Composable
|
|
18
|
-
internal actual fun hostFontFamily(fileName: String): FontFamily? = remember(fileName) {
|
|
19
|
+
internal actual fun hostFontFamily(fileName: String, weight: FontWeight): FontFamily? = remember(fileName, weight) {
|
|
19
20
|
val name = fileName.substringBeforeLast('.')
|
|
20
21
|
val ext = fileName.substringAfterLast('.', "")
|
|
21
22
|
// Font từ font-source nằm rời ở bundle root, không nằm trong compose-resources.
|
|
@@ -26,6 +27,7 @@ internal actual fun hostFontFamily(fileName: String): FontFamily? = remember(fil
|
|
|
26
27
|
getData = {
|
|
27
28
|
NSData.dataWithContentsOfFile(path)?.toByteArray() ?: ByteArray(0)
|
|
28
29
|
},
|
|
30
|
+
weight = weight,
|
|
29
31
|
),
|
|
30
32
|
)
|
|
31
33
|
}
|
|
@@ -21,6 +21,8 @@ import androidx.compose.ui.backhandler.BackHandler as ComposeBackHandler
|
|
|
21
21
|
import androidx.compose.ui.graphics.Color
|
|
22
22
|
import androidx.compose.ui.graphics.NativePaint
|
|
23
23
|
import androidx.compose.ui.graphics.toArgb
|
|
24
|
+
import androidx.compose.ui.platform.LocalDensity
|
|
25
|
+
import androidx.compose.ui.platform.LocalWindowInfo
|
|
24
26
|
import androidx.compose.ui.unit.Dp
|
|
25
27
|
import androidx.compose.ui.unit.dp
|
|
26
28
|
import androidx.compose.ui.viewinterop.UIKitInteropProperties
|
|
@@ -29,8 +31,6 @@ import cocoapods.lottie_ios.CompatibleAnimation
|
|
|
29
31
|
import cocoapods.lottie_ios.CompatibleAnimationKeypath
|
|
30
32
|
import cocoapods.lottie_ios.CompatibleAnimationView
|
|
31
33
|
import kotlinx.cinterop.ExperimentalForeignApi
|
|
32
|
-
import kotlinx.cinterop.get
|
|
33
|
-
import kotlinx.cinterop.memScoped
|
|
34
34
|
import androidx.navigationevent.NavigationEventDispatcher
|
|
35
35
|
import androidx.navigationevent.NavigationEventDispatcherOwner
|
|
36
36
|
import org.jetbrains.skia.FilterBlurMode
|
|
@@ -38,7 +38,6 @@ import org.jetbrains.skia.MaskFilter
|
|
|
38
38
|
import platform.Foundation.NSBundle
|
|
39
39
|
import platform.UIKit.UIColor
|
|
40
40
|
import platform.UIKit.UIDevice
|
|
41
|
-
import platform.UIKit.UIScreen
|
|
42
41
|
import platform.UIKit.UIUserInterfaceIdiomPhone
|
|
43
42
|
import vn.momo.kits.navigation.AttachHostBackGesture
|
|
44
43
|
import vn.momo.kits.navigation.Navigator
|
|
@@ -48,15 +47,17 @@ actual fun getPlatformName(): String = "iOS"
|
|
|
48
47
|
actual fun isPhoneIdiom(): Boolean =
|
|
49
48
|
UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPhone
|
|
50
49
|
|
|
51
|
-
|
|
50
|
+
// AI-GENERATED START: window size, not UIScreen.main (stays on one display on iPhone Duo); recomposes on fold/unfold
|
|
52
51
|
@Composable
|
|
53
|
-
actual fun getScreenDimensions(): ScreenDimension
|
|
54
|
-
val
|
|
52
|
+
actual fun getScreenDimensions(): ScreenDimension {
|
|
53
|
+
val density = LocalDensity.current
|
|
54
|
+
val size = LocalWindowInfo.current.containerSize
|
|
55
55
|
return ScreenDimension(
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
(size.width / density.density).toInt(),
|
|
57
|
+
(size.height / density.density).toInt()
|
|
58
58
|
)
|
|
59
59
|
}
|
|
60
|
+
// AI-GENERATED END: window size, not UIScreen.main (stays on one display on iPhone Duo); recomposes on fold/unfold
|
|
60
61
|
|
|
61
62
|
actual fun NativePaint.setMaskFilter(blurRadius: Float) {
|
|
62
63
|
this.maskFilter = MaskFilter.makeBlur(FilterBlurMode.NORMAL, blurRadius / 2, true)
|
|
@@ -9,9 +9,15 @@ import androidx.compose.runtime.setValue
|
|
|
9
9
|
import androidx.compose.ui.uikit.LocalUIViewController
|
|
10
10
|
import androidx.compose.ui.unit.dp
|
|
11
11
|
import kotlinx.cinterop.BetaInteropApi
|
|
12
|
+
import kotlinx.cinterop.CFunction
|
|
13
|
+
import kotlinx.cinterop.COpaquePointer
|
|
12
14
|
import kotlinx.cinterop.ExperimentalForeignApi
|
|
15
|
+
import kotlinx.cinterop.interpretCPointer
|
|
13
16
|
import kotlinx.cinterop.interpretObjCPointer
|
|
17
|
+
import kotlinx.cinterop.interpretObjCPointerOrNull
|
|
18
|
+
import kotlinx.cinterop.invoke
|
|
14
19
|
import kotlinx.cinterop.objcPtr
|
|
20
|
+
import kotlinx.cinterop.reinterpret
|
|
15
21
|
import kotlinx.cinterop.readValue
|
|
16
22
|
import kotlinx.cinterop.useContents
|
|
17
23
|
import platform.CoreGraphics.CGRectZero
|
|
@@ -25,18 +31,54 @@ import platform.UIKit.UIViewAutoresizingFlexibleHeight
|
|
|
25
31
|
import platform.UIKit.UIViewAutoresizingFlexibleWidth
|
|
26
32
|
import platform.darwin.NSObject
|
|
27
33
|
import platform.Foundation.NSClassFromString
|
|
34
|
+
import platform.posix.RTLD_DEFAULT
|
|
35
|
+
import platform.posix.dlsym
|
|
28
36
|
|
|
29
37
|
// The API is newer than the SDK Kotlin/Native binds, so it is reached through the runtime.
|
|
30
38
|
@OptIn(ExperimentalForeignApi::class)
|
|
31
39
|
private val regionsOfKind = NSSelectorFromString("reservedRegionsOfKind:")
|
|
32
40
|
|
|
41
|
+
@OptIn(ExperimentalForeignApi::class)
|
|
42
|
+
private val regionsOfKindOptions = NSSelectorFromString("reservedRegionsOfKind:options:")
|
|
43
|
+
|
|
44
|
+
// a hinge never goes away, so a positive answer is kept for every later container
|
|
45
|
+
private var isKnownFoldable = false
|
|
46
|
+
|
|
33
47
|
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
|
34
|
-
private fun
|
|
35
|
-
if (window == null || !respondsToSelector(regionsOfKind)) return emptyList()
|
|
48
|
+
private fun regionKind(name: String): Any? {
|
|
36
49
|
// a class object fails `as? NSObjectProtocol` (K/N checks it as an instance), so it is reinterpreted
|
|
37
50
|
val kindClass = NSClassFromString("UIViewReservedRegionKind")
|
|
38
|
-
?.let { interpretObjCPointer<NSObject>(it.objcPtr()) } ?: return
|
|
39
|
-
|
|
51
|
+
?.let { interpretObjCPointer<NSObject>(it.objcPtr()) } ?: return null
|
|
52
|
+
return kindClass.performSelector(NSSelectorFromString(name))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// performSelector cannot pass the NSUInteger options, so objc_msgSend is called with the method's own signature.
|
|
56
|
+
@OptIn(ExperimentalForeignApi::class)
|
|
57
|
+
private val msgSendRegionsOfKindOptions = dlsym(RTLD_DEFAULT, "objc_msgSend")
|
|
58
|
+
?.reinterpret<CFunction<(COpaquePointer?, COpaquePointer?, COpaquePointer?, ULong) -> COpaquePointer?>>()
|
|
59
|
+
|
|
60
|
+
// Apple's foldable check: only a device with a hinge reports a `.division` region, inactive while
|
|
61
|
+
// flat (UIViewReservedRegionQueryOptionsIncludeInactive) but still there. Screen size cannot tell it
|
|
62
|
+
// apart from a large iPhone in landscape or a narrowed window.
|
|
63
|
+
@OptIn(ExperimentalForeignApi::class)
|
|
64
|
+
private fun UIView.hasDivisionRegion(): Boolean {
|
|
65
|
+
if (window == null || !respondsToSelector(regionsOfKindOptions)) return false
|
|
66
|
+
val msgSend = msgSendRegionsOfKindOptions ?: return false
|
|
67
|
+
val kind = regionKind("divisionRegionKind") as? NSObject ?: return false
|
|
68
|
+
val result = msgSend(
|
|
69
|
+
interpretCPointer(objcPtr()),
|
|
70
|
+
regionsOfKindOptions,
|
|
71
|
+
interpretCPointer(kind.objcPtr()),
|
|
72
|
+
1uL,
|
|
73
|
+
)
|
|
74
|
+
val regions = interpretObjCPointerOrNull<NSArray>(result?.rawValue ?: return false)
|
|
75
|
+
return (regions?.count ?: 0uL) > 0uL
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@OptIn(ExperimentalForeignApi::class)
|
|
79
|
+
private fun UIView.occlusionRegions(target: UIView): List<ReservedRegion> {
|
|
80
|
+
if (window == null || !respondsToSelector(regionsOfKind)) return emptyList()
|
|
81
|
+
val kind = regionKind("occlusionRegionKind") ?: return emptyList()
|
|
40
82
|
val regions = when (val result = performSelector(regionsOfKind, withObject = kind)) {
|
|
41
83
|
is List<*> -> result
|
|
42
84
|
is NSArray -> List(result.count.toInt()) { result.objectAtIndex(it.toULong()) }
|
|
@@ -54,9 +96,9 @@ private fun UIView.occlusionRegions(target: UIView): List<ReservedRegion> {
|
|
|
54
96
|
// when they change, so nothing polls.
|
|
55
97
|
@OptIn(ExperimentalForeignApi::class)
|
|
56
98
|
private class ReservedRegionsProbe(
|
|
57
|
-
private val onChange: (
|
|
99
|
+
private val onChange: (ReservedRegions) -> Unit,
|
|
58
100
|
) : UIView(frame = CGRectZero.readValue()) {
|
|
59
|
-
private var last:
|
|
101
|
+
private var last: ReservedRegions? = null
|
|
60
102
|
|
|
61
103
|
init {
|
|
62
104
|
userInteractionEnabled = false
|
|
@@ -65,7 +107,9 @@ private class ReservedRegionsProbe(
|
|
|
65
107
|
|
|
66
108
|
override fun layoutSubviews() {
|
|
67
109
|
super.layoutSubviews()
|
|
68
|
-
|
|
110
|
+
// re-read until found: the window may not report the hinge on the first pass
|
|
111
|
+
if (!isKnownFoldable) isKnownFoldable = hasDivisionRegion()
|
|
112
|
+
val regions = ReservedRegions(superview?.let { occlusionRegions(it) } ?: emptyList(), isKnownFoldable)
|
|
69
113
|
if (regions != last) {
|
|
70
114
|
last = regions
|
|
71
115
|
onChange(regions)
|
|
@@ -80,10 +124,10 @@ private class ReservedRegionsProbe(
|
|
|
80
124
|
|
|
81
125
|
@OptIn(ExperimentalForeignApi::class)
|
|
82
126
|
@Composable
|
|
83
|
-
internal actual fun rememberReservedRegions():
|
|
84
|
-
if (!isPhoneIdiom()) return
|
|
127
|
+
internal actual fun rememberReservedRegions(): ReservedRegions {
|
|
128
|
+
if (!isPhoneIdiom()) return ReservedRegions()
|
|
85
129
|
val host = LocalUIViewController.current.view
|
|
86
|
-
var regions by remember { mutableStateOf(
|
|
130
|
+
var regions by remember { mutableStateOf(ReservedRegions(isFoldable = isKnownFoldable)) }
|
|
87
131
|
DisposableEffect(host) {
|
|
88
132
|
val probe = ReservedRegionsProbe { regions = it }
|
|
89
133
|
probe.setFrame(host.bounds)
|
package/gradle.properties
CHANGED
|
@@ -17,8 +17,8 @@ public class MiniAppContext {
|
|
|
17
17
|
public var toolkitConfig: [String: Any] = [:]
|
|
18
18
|
public var providerId: String = "momo"
|
|
19
19
|
public var permissions: [[String: Any]] = []
|
|
20
|
-
// Per-mini-app font-scale override; nil falls back to
|
|
21
|
-
// MiniAppContext.userScaleRate / useOSScaleRate, which win per field over
|
|
20
|
+
// Per-mini-app font-scale override; nil falls back to DesignSystemKits. Mirrors Compose's
|
|
21
|
+
// MiniAppContext.userScaleRate / useOSScaleRate, which win per field over DesignSystemKits.
|
|
22
22
|
public var userScaleRate: CGFloat? = nil
|
|
23
23
|
public var useOSScaleRate: Bool? = nil
|
|
24
24
|
|
|
@@ -37,10 +37,11 @@ public class MiniAppContext {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
public var
|
|
43
|
-
public var
|
|
40
|
+
// Value type so DesignSystemKitsConfigs can dedupe host pushes; mirrors Compose's DesignSystemConfig.
|
|
41
|
+
public struct KitConfig: Equatable {
|
|
42
|
+
public var trustBanner: TrustBannerData?
|
|
43
|
+
public var headerBar: String?
|
|
44
|
+
public var headerGradient: String?
|
|
44
45
|
|
|
45
46
|
public init(trustBanner: TrustBannerData? = nil, headerBar: String? = nil, headerGradient: String? = nil) {
|
|
46
47
|
self.trustBanner = trustBanner
|