@momo-kits/native-kits 0.165.1-beta.11 → 0.165.1-beta.12-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/settings.local.json +14 -0
- package/CLAUDE.md +1 -0
- package/compose/build.gradle.kts +1 -1
- package/compose/compose.podspec +1 -1
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +2 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +57 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +84 -34
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/HostBackGestureBridge.kt +58 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +2 -8
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +33 -47
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +28 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +3 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +3 -1
- package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/HostBackGesture.ios.kt +15 -68
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +3 -7
- package/gradle/libs.versions.toml +1 -1
- package/gradle.properties +1 -1
- package/ios/Application/Navigation/NavigatorManager.swift +0 -27
- package/ios/Information/Information.swift +5 -2
- package/ios/InputMoney/InputMoney.swift +76 -21
- package/ios/Typography/Text.swift +41 -1
- package/ios/native-kits.podspec +1 -1
- package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InformationDemo.swift +9 -1
- package/local.properties +2 -2
- package/package.json +1 -1
- package/publish.sh +25 -0
- package/example/ios/Example.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/Example.xcscheme +0 -32
- package/example/ios/Example.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/xcschememanagement.plist +0 -14
- package/example/ios/Example.xcworkspace/xcuserdata/sonnguyen.xcuserdatad/UserInterfaceState.xcuserstate +0 -0
- package/example/ios/Example.xcworkspace/xcuserdata/sonnguyen.xcuserdatad/WorkspaceSettings.xcsettings +0 -16
- package/example/ios/Example.xcworkspace/xcuserdata/sonnguyen.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +0 -6
- package/example/ios/Example.xcworkspace/xcuserdata/sonnguyen.xcuserdatad/xcschemes/xcschememanagement.plist +0 -5
- package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/MoMoUIKits.xcscheme +0 -58
- package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/Pods-Example.xcscheme +0 -58
- package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/SDWebImage.xcscheme +0 -58
- package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/SDWebImageSwiftUI.xcscheme +0 -58
- package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/SkeletonUI.xcscheme +0 -58
- package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/lottie-ios-LottiePrivacyInfo.xcscheme +0 -58
- package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/lottie-ios.xcscheme +0 -58
- package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/xcschememanagement.plist +0 -60
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"WebSearch",
|
|
5
|
+
"Bash(./gradlew assembleDebug 2>&1)",
|
|
6
|
+
"Bash(./gradlew :sample:androidApp:installDebug 2>&1)",
|
|
7
|
+
"Bash(./gradlew :compose:compileCommonMainKotlinMetadata -q)",
|
|
8
|
+
"Bash(mkdir -p compose/src/commonMain/composeResources/files)",
|
|
9
|
+
"Bash(cp sample/shared/src/commonMain/composeResources/files/anim_processing_transaction.json sample/shared/src/commonMain/composeResources/files/dot_loading.json sample/shared/src/commonMain/composeResources/files/face_loading.json sample/shared/src/commonMain/composeResources/files/lottie_circle_loader.json compose/src/commonMain/composeResources/files/)",
|
|
10
|
+
"Bash(git rm *)",
|
|
11
|
+
"Bash(./gradlew :sample:androidApp:compileDebugKotlin -q)"
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
}
|
package/CLAUDE.md
CHANGED
|
@@ -45,6 +45,7 @@ Publishing is automated (`publish.sh` + GitLab CI on `[ci build]` / `main`). Do
|
|
|
45
45
|
- **Platform code** goes through `expect`/`actual` in `platform/` — never reference Android/iOS APIs from `commonMain` directly.
|
|
46
46
|
- **Native bridge:** `IMaxApi` (`vn.momo.maxapi`, exposed as `LocalMaxApi`) is the host-app bridge (dismiss, tracking, device info, language).
|
|
47
47
|
- Navigation uses a runtime `DynamicScreenRegistry`; overlays (modal/bottom-sheet/snackbar) go through `Navigator` + `OverplayComponentRegistry`.
|
|
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.
|
|
48
49
|
|
|
49
50
|
## i18n / Localize (port of RN `Localize`)
|
|
50
51
|
|
package/compose/build.gradle.kts
CHANGED
package/compose/compose.podspec
CHANGED
|
@@ -30,6 +30,7 @@ import com.airbnb.lottie.compose.rememberLottieDynamicProperties
|
|
|
30
30
|
import com.airbnb.lottie.compose.rememberLottieDynamicProperty
|
|
31
31
|
import vn.momo.kits.const.AppNavigationBar
|
|
32
32
|
import vn.momo.kits.const.AppStatusBar
|
|
33
|
+
import vn.momo.kits.navigation.Navigator
|
|
33
34
|
import vn.momo.kits.utils.readJson
|
|
34
35
|
import androidx.activity.compose.BackHandler as AndroidBackHandler
|
|
35
36
|
|
|
@@ -64,7 +65,7 @@ actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
|
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
@Composable
|
|
67
|
-
actual fun ProvideNavigationEventDispatcherOwner(content: @Composable () -> Unit) {
|
|
68
|
+
actual fun ProvideNavigationEventDispatcherOwner(navigator: Navigator, content: @Composable () -> Unit) {
|
|
68
69
|
content()
|
|
69
70
|
}
|
|
70
71
|
|
|
@@ -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) {
|
|
@@ -9,13 +9,20 @@ import androidx.compose.runtime.Composable
|
|
|
9
9
|
import androidx.compose.runtime.remember
|
|
10
10
|
import androidx.compose.ui.Modifier
|
|
11
11
|
import androidx.compose.ui.draw.clip
|
|
12
|
+
import androidx.compose.ui.text.AnnotatedString
|
|
13
|
+
import androidx.compose.ui.text.SpanStyle
|
|
14
|
+
import androidx.compose.ui.text.buildAnnotatedString
|
|
15
|
+
import androidx.compose.ui.text.font.FontSynthesis
|
|
12
16
|
import androidx.compose.ui.text.style.TextAlign
|
|
17
|
+
import androidx.compose.ui.text.withStyle
|
|
13
18
|
import androidx.compose.ui.unit.dp
|
|
14
19
|
import vn.momo.kits.application.IsShowBaseLineDebug
|
|
20
|
+
import vn.momo.kits.const.AppTheme
|
|
15
21
|
import vn.momo.kits.const.Colors
|
|
16
22
|
import vn.momo.kits.const.Radius
|
|
17
23
|
import vn.momo.kits.const.Spacing
|
|
18
24
|
import vn.momo.kits.const.Typography
|
|
25
|
+
import vn.momo.kits.const.getFontFamily
|
|
19
26
|
import vn.momo.kits.modifier.conditional
|
|
20
27
|
import vn.momo.kits.modifier.setAutomationId
|
|
21
28
|
|
|
@@ -37,6 +44,7 @@ fun Information(
|
|
|
37
44
|
showIconClose: Boolean = true,
|
|
38
45
|
onPressClose: (() -> Unit)? = null,
|
|
39
46
|
accessibilityLabel: String? = null,
|
|
47
|
+
highlightText: List<String> = emptyList(),
|
|
40
48
|
) {
|
|
41
49
|
val automationId = remember(accessibilityLabel, title) {
|
|
42
50
|
accessibilityLabel ?: "Information${if (title != null) "/$title" else ""}"
|
|
@@ -61,6 +69,22 @@ fun Information(
|
|
|
61
69
|
iconSource = "24_notifications_alert_octagon"
|
|
62
70
|
}
|
|
63
71
|
|
|
72
|
+
// Host fonts are one file per weight: swap fontFamily and disable synthesis so the SemiBold file isn't
|
|
73
|
+
// faux-bolded again; fontWeight still applies when no host font is bundled and the system font is used.
|
|
74
|
+
val highlightWeight = Typography.headerXsSemibold.fontWeight
|
|
75
|
+
val highlightFontFamily = getFontFamily(AppTheme.current.font, highlightWeight)
|
|
76
|
+
val annotatedDescription = remember(description, highlightText, highlightFontFamily) {
|
|
77
|
+
highlightDescription(
|
|
78
|
+
description,
|
|
79
|
+
highlightText,
|
|
80
|
+
SpanStyle(
|
|
81
|
+
fontFamily = highlightFontFamily,
|
|
82
|
+
fontWeight = highlightWeight,
|
|
83
|
+
fontSynthesis = FontSynthesis.None,
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
64
88
|
Column(
|
|
65
89
|
modifier = Modifier.border(
|
|
66
90
|
width = 0.5.dp,
|
|
@@ -100,7 +124,7 @@ fun Information(
|
|
|
100
124
|
)
|
|
101
125
|
}
|
|
102
126
|
Text(
|
|
103
|
-
|
|
127
|
+
annotatedDescription,
|
|
104
128
|
style = Typography.descriptionDefaultRegular,
|
|
105
129
|
accessibilityId = "$automationId|description-text"
|
|
106
130
|
)
|
|
@@ -131,4 +155,35 @@ fun Information(
|
|
|
131
155
|
}
|
|
132
156
|
}
|
|
133
157
|
|
|
134
|
-
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Whole-word matches only; the longest keyword wins when keywords overlap (RN parity).
|
|
161
|
+
private fun highlightDescription(
|
|
162
|
+
description: String,
|
|
163
|
+
highlightText: List<String>,
|
|
164
|
+
style: SpanStyle,
|
|
165
|
+
): AnnotatedString {
|
|
166
|
+
val keywords = highlightText.filter { it.isNotEmpty() }
|
|
167
|
+
if (keywords.isEmpty()) return AnnotatedString(description)
|
|
168
|
+
val regex = Regex(keywords.sortedByDescending { it.length }.joinToString("|") { Regex.escape(it) })
|
|
169
|
+
fun isWordChar(index: Int) = description.getOrNull(index)?.let { it.isLetterOrDigit() || it == '_' } == true
|
|
170
|
+
|
|
171
|
+
return buildAnnotatedString {
|
|
172
|
+
var lastIndex = 0
|
|
173
|
+
var searchFrom = 0
|
|
174
|
+
while (true) {
|
|
175
|
+
val match = regex.find(description, searchFrom) ?: break
|
|
176
|
+
val start = match.range.first
|
|
177
|
+
val end = match.range.last + 1
|
|
178
|
+
if (isWordChar(start - 1) || isWordChar(end)) {
|
|
179
|
+
searchFrom = start + 1
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
append(description.substring(lastIndex, start))
|
|
183
|
+
withStyle(style) { append(match.value) }
|
|
184
|
+
lastIndex = end
|
|
185
|
+
searchFrom = end
|
|
186
|
+
}
|
|
187
|
+
append(description.substring(lastIndex))
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -30,12 +30,10 @@ import androidx.compose.ui.graphics.Color
|
|
|
30
30
|
import androidx.compose.ui.semantics.contentDescription
|
|
31
31
|
import androidx.compose.ui.semantics.semantics
|
|
32
32
|
import androidx.compose.ui.semantics.testTag
|
|
33
|
-
import androidx.compose.ui.text.
|
|
33
|
+
import androidx.compose.ui.text.TextRange
|
|
34
34
|
import androidx.compose.ui.text.TextStyle
|
|
35
35
|
import androidx.compose.ui.text.input.KeyboardType
|
|
36
|
-
import androidx.compose.ui.text.input.
|
|
37
|
-
import androidx.compose.ui.text.input.TransformedText
|
|
38
|
-
import androidx.compose.ui.text.input.VisualTransformation
|
|
36
|
+
import androidx.compose.ui.text.input.TextFieldValue
|
|
39
37
|
import androidx.compose.ui.unit.dp
|
|
40
38
|
import androidx.compose.ui.unit.sp
|
|
41
39
|
import androidx.compose.ui.zIndex
|
|
@@ -46,34 +44,63 @@ import vn.momo.kits.const.Colors
|
|
|
46
44
|
import vn.momo.kits.const.Typography
|
|
47
45
|
import vn.momo.kits.const.getFontFamily
|
|
48
46
|
import vn.momo.kits.modifier.conditional
|
|
49
|
-
import vn.momo.kits.utils.formatNumberToMoney
|
|
50
47
|
|
|
51
|
-
|
|
52
|
-
override fun filter(text: AnnotatedString): TransformedText {
|
|
53
|
-
if (text.text.isEmpty()) {
|
|
54
|
-
return TransformedText(
|
|
55
|
-
AnnotatedString("0"),
|
|
56
|
-
object : OffsetMapping {
|
|
57
|
-
override fun originalToTransformed(offset: Int): Int = 0
|
|
58
|
-
override fun transformedToOriginal(offset: Int): Int = 0
|
|
59
|
-
}
|
|
60
|
-
)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (text.text == "0") {
|
|
64
|
-
return TransformedText(AnnotatedString("0"), OffsetMapping.Identity)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
val formattedText = formatNumberToMoney(text.text.toLong())
|
|
48
|
+
private const val MONEY_CURRENCY = "đ"
|
|
68
49
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
50
|
+
private data class MoneyEdit(val start: Int, val end: Int, val inserted: String)
|
|
51
|
+
|
|
52
|
+
private fun digitsOf(text: String) = text.filter { it in '0'..'9' }
|
|
53
|
+
|
|
54
|
+
private fun normalizeDigits(text: String) = digitsOf(text).let { digits ->
|
|
55
|
+
digits.trimStart('0').ifEmpty { digits.take(1) }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private fun moneyDisplay(digits: String) =
|
|
59
|
+
if (digits.isEmpty()) "" else digits.reversed().chunked(3).joinToString(".").reversed() + MONEY_CURRENCY
|
|
60
|
+
|
|
61
|
+
// Caret position right after the `digitIndex`-th digit of `display`.
|
|
62
|
+
private fun caretFromDigitIndex(display: String, digitIndex: Int): Int {
|
|
63
|
+
var count = 0
|
|
64
|
+
display.forEachIndexed { index, char ->
|
|
65
|
+
if (char in '0'..'9' && ++count == digitIndex) return index + 1
|
|
76
66
|
}
|
|
67
|
+
return 0
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Finds which range of `old` was replaced to produce `next`, given the post-edit caret.
|
|
71
|
+
private fun detectEdit(old: String, next: String, caret: Int): MoneyEdit {
|
|
72
|
+
val tail = next.length - caret
|
|
73
|
+
if (tail in 0..old.length && next.substring(caret) == old.substring(old.length - tail)) {
|
|
74
|
+
var prefix = 0
|
|
75
|
+
while (prefix < minOf(caret, old.length - tail) && old[prefix] == next[prefix]) prefix++
|
|
76
|
+
return MoneyEdit(prefix, old.length - tail, next.substring(prefix, caret))
|
|
77
|
+
}
|
|
78
|
+
var prefix = 0
|
|
79
|
+
while (prefix < minOf(old.length, next.length) && old[prefix] == next[prefix]) prefix++
|
|
80
|
+
var suffix = 0
|
|
81
|
+
while (suffix < minOf(old.length, next.length) - prefix &&
|
|
82
|
+
old[old.length - 1 - suffix] == next[next.length - 1 - suffix]
|
|
83
|
+
) suffix++
|
|
84
|
+
return MoneyEdit(prefix, old.length - suffix, next.substring(prefix, next.length - suffix))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Applies `edit` to the digits of `display`. Separators and currency are never
|
|
88
|
+
// deleted: removing a separator deletes the digit before it instead, removing
|
|
89
|
+
// only the currency leaves the digits untouched.
|
|
90
|
+
// Returns the new digits and the caret as a digit index.
|
|
91
|
+
private fun applyEdit(display: String, edit: MoneyEdit): Pair<String, Int> {
|
|
92
|
+
val digits = digitsOf(display)
|
|
93
|
+
var left = digitsOf(display.substring(0, edit.start)).length
|
|
94
|
+
var removed = digitsOf(display.substring(edit.start, edit.end)).length
|
|
95
|
+
val inserted = digitsOf(edit.inserted)
|
|
96
|
+
if (removed == 0 && inserted.isEmpty() && left > 0 && '.' in display.substring(edit.start, edit.end)) {
|
|
97
|
+
left -= 1
|
|
98
|
+
removed = 1
|
|
99
|
+
}
|
|
100
|
+
if (removed == 0 && inserted.isEmpty()) return digits to left
|
|
101
|
+
val next = digits.substring(0, left) + inserted + digits.substring(left + removed)
|
|
102
|
+
val trimmed = next.trimStart('0').ifEmpty { next.take(1) }
|
|
103
|
+
return trimmed to maxOf(0, left + inserted.length - (next.length - trimmed.length))
|
|
77
104
|
}
|
|
78
105
|
|
|
79
106
|
@Composable
|
|
@@ -102,6 +129,11 @@ fun InputMoney(
|
|
|
102
129
|
keyboardType: KeyboardType = KeyboardType.Number,
|
|
103
130
|
) {
|
|
104
131
|
var isFocused by remember { mutableStateOf(false) }
|
|
132
|
+
var field by remember { mutableStateOf(TextFieldValue()) }
|
|
133
|
+
val digits = normalizeDigits(text.value)
|
|
134
|
+
val current = if (digitsOf(field.text) == digits) field else moneyDisplay(digits).let {
|
|
135
|
+
TextFieldValue(it, TextRange(caretFromDigitIndex(it, digits.length)))
|
|
136
|
+
}
|
|
105
137
|
var isBlurred = false
|
|
106
138
|
val (textColor, placeholderColor, iconTintColor, floatingTitleColor, floatingIconTintColor) =
|
|
107
139
|
getInputColors(disabled, iconColor, floatingValueColor, floatingIconColor)
|
|
@@ -118,7 +150,7 @@ fun InputMoney(
|
|
|
118
150
|
BasicTextField(
|
|
119
151
|
enabled = !disabled,
|
|
120
152
|
singleLine = true,
|
|
121
|
-
value =
|
|
153
|
+
value = current,
|
|
122
154
|
textStyle = TextStyle(
|
|
123
155
|
color = textColor,
|
|
124
156
|
fontSize = 20.sp,
|
|
@@ -126,7 +158,6 @@ fun InputMoney(
|
|
|
126
158
|
fontWeight = fontWeight.value,
|
|
127
159
|
fontFamily = getFontFamily(AppTheme.current.font, fontWeight.value)
|
|
128
160
|
),
|
|
129
|
-
visualTransformation = CustomConverter(),
|
|
130
161
|
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = keyboardType),
|
|
131
162
|
modifier = Modifier.height(size.values.height).onFocusChanged {
|
|
132
163
|
isFocused = it.isFocused
|
|
@@ -136,9 +167,28 @@ fun InputMoney(
|
|
|
136
167
|
if (!it.isFocused && isBlurred) onBlur()
|
|
137
168
|
if (it.isFocused && !isBlurred) isBlurred = true
|
|
138
169
|
},
|
|
139
|
-
onValueChange = {
|
|
140
|
-
|
|
141
|
-
|
|
170
|
+
onValueChange = { value ->
|
|
171
|
+
if (value.text == current.text) {
|
|
172
|
+
// Keeps the caret out of the currency suffix.
|
|
173
|
+
val max = if (current.text.isEmpty()) 0 else current.text.length - MONEY_CURRENCY.length
|
|
174
|
+
field = value.copy(
|
|
175
|
+
selection = TextRange(
|
|
176
|
+
value.selection.start.coerceAtMost(max),
|
|
177
|
+
value.selection.end.coerceAtMost(max)
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
} else {
|
|
181
|
+
val (newDigits, caretDigit) = applyEdit(
|
|
182
|
+
current.text,
|
|
183
|
+
detectEdit(current.text, value.text, value.selection.min)
|
|
184
|
+
)
|
|
185
|
+
val display = moneyDisplay(newDigits)
|
|
186
|
+
field = TextFieldValue(display, TextRange(caretFromDigitIndex(display, caretDigit)))
|
|
187
|
+
if (newDigits != digits) {
|
|
188
|
+
text.value = newDigits
|
|
189
|
+
onChangeText(newDigits)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
142
192
|
},
|
|
143
193
|
decorationBox = { innerTextField ->
|
|
144
194
|
// Floating Icon
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
package vn.momo.kits.navigation
|
|
2
|
+
|
|
3
|
+
interface HostBackTarget {
|
|
4
|
+
fun canPop(): Boolean
|
|
5
|
+
|
|
6
|
+
fun onBack()
|
|
7
|
+
|
|
8
|
+
fun backStarted()
|
|
9
|
+
fun backProgressed(progress: Float)
|
|
10
|
+
fun backCompleted()
|
|
11
|
+
fun backCancelled()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
object HostBackGestureBridge {
|
|
15
|
+
private val targets = mutableListOf<HostBackTarget>()
|
|
16
|
+
private var gestureTarget: HostBackTarget? = null
|
|
17
|
+
|
|
18
|
+
var shouldUseFloatingArrowBack: Boolean = false
|
|
19
|
+
|
|
20
|
+
fun checkCanPop(): Boolean = targets.isNotEmpty()
|
|
21
|
+
|
|
22
|
+
fun canPopInner(): Boolean = top()?.canPop() == true
|
|
23
|
+
|
|
24
|
+
fun pop() {
|
|
25
|
+
top()?.onBack()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
fun started() {
|
|
29
|
+
val target = top() ?: return
|
|
30
|
+
gestureTarget = target
|
|
31
|
+
target.backStarted()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
fun progressed(progress: Float) {
|
|
35
|
+
gestureTarget?.backProgressed(progress.coerceIn(0f, 1f))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
fun completed() {
|
|
39
|
+
(gestureTarget ?: top())?.backCompleted()
|
|
40
|
+
gestureTarget = null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fun cancelled() {
|
|
44
|
+
gestureTarget?.backCancelled()
|
|
45
|
+
gestureTarget = null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
fun attach(target: HostBackTarget) {
|
|
49
|
+
targets += target
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
fun detach(target: HostBackTarget) {
|
|
53
|
+
targets -= target
|
|
54
|
+
if (gestureTarget === target) gestureTarget = null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private fun top(): HostBackTarget? = targets.lastOrNull()
|
|
58
|
+
}
|
|
@@ -42,10 +42,10 @@ import kotlin.time.Duration.Companion.milliseconds
|
|
|
42
42
|
fun NavigationContainer(
|
|
43
43
|
initialScreenName: String,
|
|
44
44
|
initialScreen: @Composable () -> Unit,
|
|
45
|
+
maxApi: IMaxApi,
|
|
45
46
|
options: NavigationOptions? = null,
|
|
46
47
|
initialTheme: Theme = defaultTheme,
|
|
47
48
|
miniAppContext: MiniAppContext? = null,
|
|
48
|
-
maxApi: IMaxApi? = null,
|
|
49
49
|
setNavigator: ((Navigator) -> Unit)? = null,
|
|
50
50
|
statusBarHeight: Dp? = null,
|
|
51
51
|
localize: Localize? = null,
|
|
@@ -93,7 +93,7 @@ fun NavigationContainer(
|
|
|
93
93
|
val startDestination = remember(screenId) { DynamicScreenRoute(screenId) }
|
|
94
94
|
|
|
95
95
|
ProvideOsFontScale {
|
|
96
|
-
ProvideNavigationEventDispatcherOwner {
|
|
96
|
+
ProvideNavigationEventDispatcherOwner(navigator) {
|
|
97
97
|
CompositionLocalProvider(
|
|
98
98
|
LocalNavigator provides navigator,
|
|
99
99
|
LocalDynamicScreenRegistry provides registry,
|
|
@@ -112,8 +112,6 @@ fun NavigationContainer(
|
|
|
112
112
|
|
|
113
113
|
SwipeBackHaptic(maxApi)
|
|
114
114
|
|
|
115
|
-
// Gesture pops never reach Navigator.dismissScreen(), so prune the registry from
|
|
116
|
-
// the back stack itself. collectLatest lets a rapid re-push cancel a pending prune.
|
|
117
115
|
LaunchedEffect(navController, registry) {
|
|
118
116
|
navController.currentBackStack.collectLatest { entries ->
|
|
119
117
|
delay(STACK_TRANSITION_DURATION.milliseconds)
|
|
@@ -126,10 +124,6 @@ fun NavigationContainer(
|
|
|
126
124
|
}
|
|
127
125
|
}
|
|
128
126
|
|
|
129
|
-
// The transitions must live on the NavHost call site, not on the destinations:
|
|
130
|
-
// NavHost.uikit computes isDefaultTransition from these arguments alone, and while
|
|
131
|
-
// it is true the library paints its own hard-coded black iOS blackout that would
|
|
132
|
-
// sit over the pink scrim.
|
|
133
127
|
NavHost(
|
|
134
128
|
navController,
|
|
135
129
|
startDestination = startDestination,
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
package vn.momo.kits.navigation
|
|
2
2
|
|
|
3
3
|
import androidx.compose.animation.animateColorAsState
|
|
4
|
-
import androidx.compose.animation.core.animateDpAsState
|
|
5
4
|
import androidx.compose.foundation.ScrollState
|
|
6
5
|
import androidx.compose.foundation.background
|
|
7
6
|
import androidx.compose.foundation.border
|
|
8
7
|
import androidx.compose.foundation.gestures.ScrollableState
|
|
8
|
+
import androidx.compose.foundation.gestures.animateScrollBy
|
|
9
9
|
import androidx.compose.foundation.layout.Box
|
|
10
10
|
import androidx.compose.foundation.layout.Column
|
|
11
11
|
import androidx.compose.foundation.layout.ColumnScope
|
|
@@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.imePadding
|
|
|
21
21
|
import androidx.compose.foundation.layout.offset
|
|
22
22
|
import androidx.compose.foundation.layout.padding
|
|
23
23
|
import androidx.compose.foundation.lazy.LazyListState
|
|
24
|
-
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
25
24
|
import androidx.compose.foundation.rememberScrollState
|
|
26
25
|
import androidx.compose.foundation.verticalScroll
|
|
27
26
|
import androidx.compose.material.ExperimentalMaterialApi
|
|
@@ -45,8 +44,11 @@ import androidx.compose.ui.layout.onGloballyPositioned
|
|
|
45
44
|
import androidx.compose.ui.platform.LocalDensity
|
|
46
45
|
import androidx.compose.ui.unit.Dp
|
|
47
46
|
import androidx.compose.ui.unit.dp
|
|
47
|
+
import androidx.compose.ui.unit.lerp
|
|
48
48
|
import androidx.compose.ui.zIndex
|
|
49
49
|
import kotlinx.coroutines.flow.Flow
|
|
50
|
+
import kotlinx.coroutines.flow.filter
|
|
51
|
+
import kotlinx.coroutines.launch
|
|
50
52
|
import vn.momo.kits.components.InputSearch
|
|
51
53
|
import vn.momo.kits.const.AppNavigationBar
|
|
52
54
|
import vn.momo.kits.const.AppStatusBar
|
|
@@ -154,13 +156,15 @@ internal fun StackScreen(
|
|
|
154
156
|
}
|
|
155
157
|
|
|
156
158
|
val limit = with(density) {
|
|
157
|
-
(statusBar).toPx()
|
|
159
|
+
(statusBar + HEADER_HEIGHT.dp).toPx()
|
|
158
160
|
}.toInt()
|
|
159
161
|
|
|
160
|
-
val
|
|
161
|
-
|
|
162
|
+
val lazyListState = options.scrollData.scrollState as? LazyListState
|
|
163
|
+
val (scrollState, _) = if (lazyListState != null)
|
|
164
|
+
lazyListState.proxyScrollState(limit, 1)
|
|
162
165
|
else
|
|
163
|
-
(options.scrollData.scrollState as? ScrollState ?: rememberScrollState()).proxyLimitedScrollState(limit,
|
|
166
|
+
(options.scrollData.scrollState as? ScrollState ?: rememberScrollState()).proxyLimitedScrollState(limit, 1)
|
|
167
|
+
val snapState: ScrollableState = lazyListState ?: scrollState
|
|
164
168
|
|
|
165
169
|
val footerHeightPx = remember { mutableIntStateOf(0) }
|
|
166
170
|
val headerRightWidthPx = remember { mutableIntStateOf(0) }
|
|
@@ -205,7 +209,7 @@ internal fun StackScreen(
|
|
|
205
209
|
}
|
|
206
210
|
|
|
207
211
|
Column(Modifier.zIndex(5f)) {
|
|
208
|
-
SearchAnimated(
|
|
212
|
+
SearchAnimated(snapState = snapState)
|
|
209
213
|
}
|
|
210
214
|
|
|
211
215
|
Column(Modifier.zIndex(2f).fillMaxSize()) {
|
|
@@ -269,11 +273,7 @@ internal fun ColumnScope.MainContent(content: @Composable () -> Unit, isBottomTa
|
|
|
269
273
|
if (inputSearchType == InputSearchType.Animated) {
|
|
270
274
|
val scrollDp = with(density) { scrollState.value.toDp() }
|
|
271
275
|
|
|
272
|
-
|
|
273
|
-
targetValue = (HEADER_HEIGHT.dp - scrollDp).coerceIn(0.dp, HEADER_HEIGHT.dp),
|
|
274
|
-
label = "AnimatedTopPadding"
|
|
275
|
-
)
|
|
276
|
-
Spacer(Modifier.height(animatedTopPadding))
|
|
276
|
+
Spacer(Modifier.height((HEADER_HEIGHT.dp - scrollDp).coerceIn(0.dp, HEADER_HEIGHT.dp)))
|
|
277
277
|
}
|
|
278
278
|
Column(
|
|
279
279
|
Modifier
|
|
@@ -413,7 +413,7 @@ data class InputSearchLayoutParams(
|
|
|
413
413
|
@Suppress("FrequentlyChangingValue")
|
|
414
414
|
@Composable
|
|
415
415
|
internal fun SearchAnimated(
|
|
416
|
-
|
|
416
|
+
snapState: ScrollableState
|
|
417
417
|
) {
|
|
418
418
|
val scrollState = LocalScrollState.current
|
|
419
419
|
val options = LocalOptions.current
|
|
@@ -453,44 +453,24 @@ internal fun SearchAnimated(
|
|
|
453
453
|
}
|
|
454
454
|
|
|
455
455
|
InputSearchType.Animated -> {
|
|
456
|
-
val
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
)
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
targetValue = (minEndPadding + scrollDp).coerceIn(minEndPadding, maxEndPadding),
|
|
468
|
-
label = "AnimatedEndPadding"
|
|
469
|
-
)
|
|
470
|
-
|
|
471
|
-
val maxPadding = remember(maxTopPadding, maxStartPadding, maxEndPadding) {
|
|
472
|
-
maxOf(maxEndPadding, maxStartPadding, maxTopPadding)
|
|
473
|
-
}
|
|
474
|
-
val snapScrollValue = with(density) { maxPadding.toPx().toInt() }
|
|
475
|
-
|
|
476
|
-
LaunchedEffect(isScrollInProgress && scrollState.value != 0 && scrollState.value != snapScrollValue) {
|
|
477
|
-
if (scrollDp < maxPadding) {
|
|
478
|
-
if (!isScrollInProgress) {
|
|
479
|
-
val midpoint = (maxTopPadding - minTopPadding) / 2
|
|
480
|
-
|
|
481
|
-
if (scrollDp < midpoint) {
|
|
482
|
-
scrollState.animateScrollTo(0)
|
|
483
|
-
} else {
|
|
484
|
-
scrollState.animateScrollTo(snapScrollValue)
|
|
485
|
-
}
|
|
456
|
+
val collapsePx = with(density) { HEADER_HEIGHT.dp.roundToPx() }
|
|
457
|
+
val progress = (scrollState.value / collapsePx.toFloat()).coerceIn(0f, 1f)
|
|
458
|
+
|
|
459
|
+
LaunchedEffect(snapState, collapsePx) {
|
|
460
|
+
snapshotFlow { snapState.isScrollInProgress }
|
|
461
|
+
.filter { !it }
|
|
462
|
+
.collect {
|
|
463
|
+
val offset = snapState.headerOffsetPx() ?: return@collect
|
|
464
|
+
if (offset <= 0 || offset >= collapsePx) return@collect
|
|
465
|
+
val target = if (offset < collapsePx / 2) 0 else collapsePx
|
|
466
|
+
launch { snapState.animateScrollBy((target - offset).toFloat()) }
|
|
486
467
|
}
|
|
487
|
-
}
|
|
488
468
|
}
|
|
489
469
|
|
|
490
470
|
InputSearchLayoutParams(
|
|
491
|
-
topPadding =
|
|
492
|
-
startPadding =
|
|
493
|
-
endPadding =
|
|
471
|
+
topPadding = lerp(maxTopPadding, minTopPadding, progress),
|
|
472
|
+
startPadding = lerp(minStartPadding, maxStartPadding, progress),
|
|
473
|
+
endPadding = lerp(minEndPadding, maxEndPadding, progress)
|
|
494
474
|
)
|
|
495
475
|
}
|
|
496
476
|
|
|
@@ -519,6 +499,12 @@ internal fun SearchAnimated(
|
|
|
519
499
|
}
|
|
520
500
|
}
|
|
521
501
|
|
|
502
|
+
private fun ScrollableState.headerOffsetPx(): Int? = when (this) {
|
|
503
|
+
is LazyListState -> if (firstVisibleItemIndex == 0) firstVisibleItemScrollOffset else null
|
|
504
|
+
is ScrollState -> value
|
|
505
|
+
else -> null
|
|
506
|
+
}
|
|
507
|
+
|
|
522
508
|
private fun quantize(value: Int, stepPx: Int): Int {
|
|
523
509
|
if (stepPx <= 1) return value
|
|
524
510
|
return (value / stepPx) * stepPx
|
|
@@ -15,11 +15,14 @@ import androidx.compose.foundation.layout.offset
|
|
|
15
15
|
import androidx.compose.foundation.layout.padding
|
|
16
16
|
import androidx.compose.foundation.layout.size
|
|
17
17
|
import androidx.compose.foundation.layout.width
|
|
18
|
+
import androidx.compose.foundation.layout.wrapContentHeight
|
|
18
19
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
19
20
|
import androidx.compose.runtime.Composable
|
|
20
21
|
import androidx.compose.runtime.getValue
|
|
21
22
|
import androidx.compose.ui.Alignment
|
|
22
23
|
import androidx.compose.ui.Modifier
|
|
24
|
+
import androidx.compose.ui.draw.clipToBounds
|
|
25
|
+
import androidx.compose.ui.graphics.graphicsLayer
|
|
23
26
|
import androidx.compose.ui.graphics.Brush
|
|
24
27
|
import androidx.compose.ui.graphics.Color
|
|
25
28
|
import androidx.compose.ui.layout.onGloballyPositioned
|
|
@@ -28,6 +31,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|
|
28
31
|
import androidx.compose.ui.unit.Dp
|
|
29
32
|
import androidx.compose.ui.unit.dp
|
|
30
33
|
import vn.momo.kits.components.Icon
|
|
34
|
+
import vn.momo.kits.components.Image
|
|
31
35
|
import vn.momo.kits.components.InputSearchProps
|
|
32
36
|
import vn.momo.kits.const.AppStatusBar
|
|
33
37
|
import vn.momo.kits.const.AppTheme
|
|
@@ -88,15 +92,38 @@ internal fun Header(onBackHandler: (() -> Unit)? = null) {
|
|
|
88
92
|
// Centered title needs a full-width overlay to stay screen-centered, not boxed between back button and header right.
|
|
89
93
|
val centerTitle = titlePosition == TitlePosition.CENTER && options.headerTitle is HeaderTitle.Default
|
|
90
94
|
|
|
95
|
+
val headerImage = AppTheme.current.assets.headerBackground
|
|
96
|
+
.takeUnless { options.headerType is HeaderType.Transparent }
|
|
97
|
+
|
|
91
98
|
Box(
|
|
92
99
|
Modifier.height(AppStatusBar.current + HEADER_HEIGHT.dp)
|
|
93
100
|
.fillMaxWidth()
|
|
94
|
-
.
|
|
101
|
+
.conditional(headerImage.isNullOrEmpty()) {
|
|
102
|
+
background(background.copy(alpha = animatedAlpha))
|
|
103
|
+
}
|
|
95
104
|
.conditional(IsShowBaseLineDebug) {
|
|
96
105
|
border(1.dp, Colors.blue_03)
|
|
97
106
|
},
|
|
98
107
|
contentAlignment = Alignment.BottomCenter
|
|
99
108
|
) {
|
|
109
|
+
if (!headerImage.isNullOrEmpty()) {
|
|
110
|
+
Box(
|
|
111
|
+
Modifier
|
|
112
|
+
.matchParentSize()
|
|
113
|
+
.clipToBounds()
|
|
114
|
+
.graphicsLayer { alpha = animatedAlpha }
|
|
115
|
+
.background(background)
|
|
116
|
+
) {
|
|
117
|
+
Image(
|
|
118
|
+
source = headerImage,
|
|
119
|
+
loading = false,
|
|
120
|
+
modifier = Modifier
|
|
121
|
+
.fillMaxWidth()
|
|
122
|
+
.wrapContentHeight(Alignment.Top, unbounded = true)
|
|
123
|
+
.height(HEADER_BACKGROUND_HEIGHT),
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
100
127
|
Row(
|
|
101
128
|
modifier = Modifier.height(HEADER_HEIGHT.dp)
|
|
102
129
|
.fillMaxWidth()
|
|
@@ -27,6 +27,8 @@ enum class AnimatedHeaderRatio(val value: Float){
|
|
|
27
27
|
RATIO_3_2(3f / 2f)
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
internal val HEADER_BACKGROUND_HEIGHT = 154.dp
|
|
31
|
+
|
|
30
32
|
@Suppress("FrequentlyChangingValue")
|
|
31
33
|
@Composable
|
|
32
34
|
internal fun HeaderBackground() {
|
|
@@ -37,7 +39,7 @@ internal fun HeaderBackground() {
|
|
|
37
39
|
val backgroundColor = options.backgroundColor ?: AppTheme.current.colors.background.default
|
|
38
40
|
|
|
39
41
|
val minHeight = AppStatusBar.current + HEADER_HEIGHT.dp
|
|
40
|
-
val maxHeight =
|
|
42
|
+
val maxHeight = HEADER_BACKGROUND_HEIGHT
|
|
41
43
|
val opacity by animateFloatAsState(
|
|
42
44
|
targetValue = (1 - (scrollState.value * 1f / HEADER_HEIGHT * 1f)).coerceIn(0f, 1f),
|
|
43
45
|
)
|