@momo-kits/native-kits 0.165.1-beta.11 → 0.165.2-beta.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 +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/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/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/package.json +1 -1
- package/publish.sh +25 -0
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
|
|
|
@@ -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,
|
|
@@ -5,6 +5,7 @@ import androidx.compose.ui.Modifier
|
|
|
5
5
|
import androidx.compose.ui.graphics.Color
|
|
6
6
|
import androidx.compose.ui.graphics.NativePaint
|
|
7
7
|
import androidx.compose.ui.unit.Dp
|
|
8
|
+
import vn.momo.kits.navigation.Navigator
|
|
8
9
|
|
|
9
10
|
data class ScreenDimension(
|
|
10
11
|
val width: Int,
|
|
@@ -24,8 +25,9 @@ expect fun getStatusBarHeight(): Dp
|
|
|
24
25
|
@Composable
|
|
25
26
|
expect fun BackHandler(enabled: Boolean, onBack: () -> Unit)
|
|
26
27
|
|
|
28
|
+
@Suppress("ParamsComparedByRef")
|
|
27
29
|
@Composable
|
|
28
|
-
expect fun ProvideNavigationEventDispatcherOwner(content: @Composable () -> Unit)
|
|
30
|
+
expect fun ProvideNavigationEventDispatcherOwner(navigator: Navigator, content: @Composable () -> Unit)
|
|
29
31
|
|
|
30
32
|
@Composable
|
|
31
33
|
expect fun getScreenHeight(): Dp
|
|
@@ -7,85 +7,32 @@ import androidx.navigationevent.NavigationEvent
|
|
|
7
7
|
import androidx.navigationevent.NavigationEventDispatcher
|
|
8
8
|
import androidx.navigationevent.NavigationEventInput
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
* Kit end of the host's edge-pan recogniser. The host owns the only recogniser on iOS and replays
|
|
12
|
-
* its drag here; events reach NavHost's `PredictiveBackHandler` and seek the stack transition.
|
|
13
|
-
*
|
|
14
|
-
* **Call every method from the main thread** — nothing here is synchronised, and [attachedInputs]
|
|
15
|
-
* is also written from composition.
|
|
16
|
-
*/
|
|
17
|
-
object HostBackGestureBridge {
|
|
18
|
-
private val attachedInputs = mutableListOf<HostNavigationEventInput>()
|
|
19
|
-
private var gestureInput: HostNavigationEventInput? = null
|
|
20
|
-
|
|
21
|
-
/** `false` means no container is mounted, or it opted out — host falls back to its own nav. */
|
|
22
|
-
val hasEnabledHandlers: Boolean
|
|
23
|
-
get() = attachedInputs.lastOrNull()?.hasEnabledHandlers == true
|
|
24
|
-
|
|
25
|
-
/** Set on tabs other than the first; host pulls it when its recogniser begins. */
|
|
26
|
-
var shouldUseFloatingArrowBack: Boolean = false
|
|
27
|
-
internal set
|
|
28
|
-
|
|
29
|
-
/** Begins a drag. Pairs with exactly one [completed] or [cancelled]. */
|
|
30
|
-
fun started() {
|
|
31
|
-
val input = attachedInputs.lastOrNull() ?: return
|
|
32
|
-
gestureInput = input
|
|
33
|
-
input.backStarted()
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Drag progress in `0f..1f` — the fraction of the screen width travelled. */
|
|
37
|
-
fun progressed(progress: Float) {
|
|
38
|
-
gestureInput?.backProgressed(progress.coerceIn(0f, 1f))
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Falls back to the attached input when no drag was started: a host that commits without
|
|
42
|
-
* ever seeking still needs its back. */
|
|
43
|
-
fun completed() {
|
|
44
|
-
(gestureInput ?: attachedInputs.lastOrNull())?.backCompleted()
|
|
45
|
-
gestureInput = null
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Released below the commit threshold: the screen springs back. */
|
|
49
|
-
fun cancelled() {
|
|
50
|
-
gestureInput?.backCancelled()
|
|
51
|
-
gestureInput = null
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
internal fun attach(input: HostNavigationEventInput) {
|
|
55
|
-
attachedInputs += input
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
internal fun detach(input: HostNavigationEventInput) {
|
|
59
|
-
attachedInputs -= input
|
|
60
|
-
// else a container unmounted mid-drag hands its terminal event to whoever is on top now
|
|
61
|
-
if (gestureInput === input) gestureInput = null
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Wires one [NavigationContainer]'s dispatcher to [HostBackGestureBridge] while it is composed. */
|
|
10
|
+
@Suppress("ParamsComparedByRef")
|
|
66
11
|
@Composable
|
|
67
|
-
internal fun AttachHostBackGesture(dispatcher: NavigationEventDispatcher) {
|
|
12
|
+
internal fun AttachHostBackGesture(dispatcher: NavigationEventDispatcher, navigator: Navigator) {
|
|
68
13
|
val input = remember(dispatcher) { HostNavigationEventInput() }
|
|
14
|
+
val target = remember(input, navigator) {
|
|
15
|
+
object : HostBackTarget {
|
|
16
|
+
override fun canPop() = navigator.canPop()
|
|
17
|
+
override fun onBack() = navigator.onBackSafe()
|
|
18
|
+
override fun backStarted() = input.backStarted()
|
|
19
|
+
override fun backProgressed(progress: Float) = input.backProgressed(progress)
|
|
20
|
+
override fun backCompleted() = input.backCompleted()
|
|
21
|
+
override fun backCancelled() = input.backCancelled()
|
|
22
|
+
}
|
|
23
|
+
}
|
|
69
24
|
|
|
70
|
-
DisposableEffect(dispatcher,
|
|
25
|
+
DisposableEffect(dispatcher, target) {
|
|
71
26
|
dispatcher.addInput(input)
|
|
72
|
-
HostBackGestureBridge.attach(
|
|
27
|
+
HostBackGestureBridge.attach(target)
|
|
73
28
|
onDispose {
|
|
74
|
-
HostBackGestureBridge.detach(
|
|
29
|
+
HostBackGestureBridge.detach(target)
|
|
75
30
|
dispatcher.removeInput(input)
|
|
76
31
|
}
|
|
77
32
|
}
|
|
78
33
|
}
|
|
79
34
|
|
|
80
35
|
internal class HostNavigationEventInput : NavigationEventInput() {
|
|
81
|
-
var hasEnabledHandlers: Boolean = false
|
|
82
|
-
private set
|
|
83
|
-
|
|
84
|
-
override fun onHasEnabledHandlersChanged(hasEnabledHandlers: Boolean) {
|
|
85
|
-
this.hasEnabledHandlers = hasEnabledHandlers
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Touch coordinates are not forwarded: progress alone seeks the transition.
|
|
89
36
|
fun backStarted() = dispatchOnBackStarted(NavigationEvent(swipeEdge = NavigationEvent.EDGE_LEFT))
|
|
90
37
|
|
|
91
38
|
fun backProgressed(progress: Float) = dispatchOnBackProgressed(
|
|
@@ -40,6 +40,7 @@ import platform.UIKit.UIColor
|
|
|
40
40
|
import platform.UIKit.UIDevice
|
|
41
41
|
import platform.UIKit.UIScreen
|
|
42
42
|
import vn.momo.kits.navigation.AttachHostBackGesture
|
|
43
|
+
import vn.momo.kits.navigation.Navigator
|
|
43
44
|
|
|
44
45
|
actual fun getPlatformName(): String = "iOS"
|
|
45
46
|
|
|
@@ -62,8 +63,6 @@ actual fun getStatusBarHeight(): Dp {
|
|
|
62
63
|
return WindowInsets.systemBars.asPaddingValues().calculateTopPadding()
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
// Registered inside the NavHost destination, so an enabled one out-ranks NavHost's own
|
|
66
|
-
// predictive-back handler — that is how a screen opts out of swipe-back.
|
|
67
66
|
@Suppress("DEPRECATION")
|
|
68
67
|
@OptIn(ExperimentalComposeUiApi::class)
|
|
69
68
|
@Composable
|
|
@@ -73,17 +72,14 @@ actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
|
|
|
73
72
|
|
|
74
73
|
@OptIn(InternalComposeUiApi::class)
|
|
75
74
|
@Composable
|
|
76
|
-
actual fun ProvideNavigationEventDispatcherOwner(content: @Composable () -> Unit) {
|
|
77
|
-
// Detached on purpose: the scene's owner would arm Compose's own recogniser alongside the
|
|
78
|
-
// host's, and UIKit resolves that by driving one to .failed — a fabricated completed back.
|
|
79
|
-
// Enabled on purpose: dispatchOn* early-returns while disabled, muting the host-driven seek.
|
|
75
|
+
actual fun ProvideNavigationEventDispatcherOwner(navigator: Navigator, content: @Composable () -> Unit) {
|
|
80
76
|
val navigationEventDispatcherOwner = remember {
|
|
81
77
|
object : NavigationEventDispatcherOwner {
|
|
82
78
|
override val navigationEventDispatcher = NavigationEventDispatcher()
|
|
83
79
|
}
|
|
84
80
|
}
|
|
85
81
|
|
|
86
|
-
AttachHostBackGesture(navigationEventDispatcherOwner.navigationEventDispatcher)
|
|
82
|
+
AttachHostBackGesture(navigationEventDispatcherOwner.navigationEventDispatcher, navigator)
|
|
87
83
|
|
|
88
84
|
CompositionLocalProvider(
|
|
89
85
|
LocalCompatNavigationEventDispatcherOwner provides navigationEventDispatcherOwner,
|
package/gradle.properties
CHANGED
|
@@ -1,19 +1,3 @@
|
|
|
1
|
-
//
|
|
2
|
-
// NavigatorManager.swift
|
|
3
|
-
// MoMoUIKits
|
|
4
|
-
//
|
|
5
|
-
// Host bridge for the native iOS edge-swipe back — the SwiftUI mirror of Compose's
|
|
6
|
-
// `ComposeNavigatorManager` (momo-app `vn.momo.compose.uikits.ComposeNavigatorManager`).
|
|
7
|
-
//
|
|
8
|
-
// The kit does NOT own the swipe gesture. In the host app a kit screen lives inside a
|
|
9
|
-
// `UIHostingController` hosted by the mini-app container VC, and that VC owns the
|
|
10
|
-
// `UIScreenEdgePanGestureRecognizer`. When the swipe fires the host asks this manager
|
|
11
|
-
// whether a kit stack is mounted (`checkCanPop`) and, if so, routes the back into it
|
|
12
|
-
// (`pop` → `Navigator.onBackSafe`) instead of dismissing the whole mini-app. Standalone
|
|
13
|
-
// (no such host, e.g. the demo app) there is no edge swipe — same as Compose on iOS,
|
|
14
|
-
// whose `BackHandler` is a no-op and which relies entirely on the host.
|
|
15
|
-
//
|
|
16
|
-
|
|
17
1
|
import Foundation
|
|
18
2
|
|
|
19
3
|
@available(iOS 16.0, *)
|
|
@@ -21,28 +5,17 @@ public final class NavigatorManager {
|
|
|
21
5
|
public static let shared = NavigatorManager()
|
|
22
6
|
private init() {}
|
|
23
7
|
|
|
24
|
-
/// Stack of mounted navigators; the last one is the visible top (nested mini-apps
|
|
25
|
-
/// push more than one). Held strongly, mirroring `ComposeNavigatorManager` — entries
|
|
26
|
-
/// are released by `NavigationContainer` on disappear, not by ARC.
|
|
27
8
|
private var navigators: [Navigator] = []
|
|
28
9
|
|
|
29
|
-
/// Idempotent: SwiftUI can re-run a view's `onAppear`, and a double push would need a
|
|
30
|
-
/// matching double remove to unwind.
|
|
31
10
|
public func push(_ navigator: Navigator) {
|
|
32
11
|
guard !navigators.contains(where: { $0 === navigator }) else { return }
|
|
33
12
|
navigators.append(navigator)
|
|
34
13
|
}
|
|
35
14
|
|
|
36
|
-
/// Removes by identity rather than `removeLast` so a spurious disappear of a covered
|
|
37
|
-
/// (non-top) container can't drop the wrong navigator.
|
|
38
15
|
public func remove(_ navigator: Navigator) {
|
|
39
16
|
navigators.removeAll { $0 === navigator }
|
|
40
17
|
}
|
|
41
18
|
|
|
42
|
-
/// True while any kit stack is mounted — tells the host to route the back into the kit
|
|
43
|
-
/// instead of dismissing the mini-app. Matches `ComposeNavigatorManager.checkCanPop`:
|
|
44
|
-
/// it does not inspect stack depth; `onBackSafe` handles the at-root case itself by
|
|
45
|
-
/// asking the host to dismiss (via `composeApi`).
|
|
46
19
|
public func checkCanPop() -> Bool { !navigators.isEmpty }
|
|
47
20
|
|
|
48
21
|
public func pop() { navigators.last?.onBackSafe() }
|
|
@@ -18,6 +18,7 @@ public struct Information: View {
|
|
|
18
18
|
private let action: String?
|
|
19
19
|
private let showIconClose: Bool
|
|
20
20
|
private let onPressClose: (() -> Void)?
|
|
21
|
+
private let highlightText: [String]
|
|
21
22
|
|
|
22
23
|
public init(
|
|
23
24
|
title: String? = nil,
|
|
@@ -28,7 +29,8 @@ public struct Information: View {
|
|
|
28
29
|
showIcon: Bool = true,
|
|
29
30
|
action: String? = nil,
|
|
30
31
|
showIconClose: Bool = true,
|
|
31
|
-
onPressClose: (() -> Void)? = nil
|
|
32
|
+
onPressClose: (() -> Void)? = nil,
|
|
33
|
+
highlightText: [String] = []
|
|
32
34
|
) {
|
|
33
35
|
self.title = title
|
|
34
36
|
self.description = description
|
|
@@ -39,6 +41,7 @@ public struct Information: View {
|
|
|
39
41
|
self.action = action
|
|
40
42
|
self.showIconClose = showIconClose
|
|
41
43
|
self.onPressClose = onPressClose
|
|
44
|
+
self.highlightText = highlightText
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
private var borderColor: Color {
|
|
@@ -90,7 +93,7 @@ public struct Information: View {
|
|
|
90
93
|
if let title {
|
|
91
94
|
MomoText(title, typography: .labelDefaultMedium)
|
|
92
95
|
}
|
|
93
|
-
MomoText(description, typography: .descriptionDefaultRegular)
|
|
96
|
+
MomoText(description, typography: .descriptionDefaultRegular, highlightText: highlightText)
|
|
94
97
|
}
|
|
95
98
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
96
99
|
|
|
@@ -46,6 +46,42 @@ public func formatNumberToMoney(_ digits: String, currency: String = "đ") -> St
|
|
|
46
46
|
return String(chars.reversed()) + currency
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
private func isMoneyDigit(_ char: Character) -> Bool { char.isASCII && char.isNumber }
|
|
50
|
+
|
|
51
|
+
/// Applies an edit of `display[start..<end]` → `inserted` to the digits of `display`.
|
|
52
|
+
/// Separators and currency are never deleted: removing a separator deletes the
|
|
53
|
+
/// digit before it instead, removing only the currency leaves the digits untouched. Returns the new digits and the caret as a digit index.
|
|
54
|
+
private func applyMoneyEdit(_ display: String, start: Int, end: Int, inserted: String) -> (digits: String, caretDigit: Int) {
|
|
55
|
+
let chars = Array(display)
|
|
56
|
+
let start = min(start, chars.count)
|
|
57
|
+
let end = min(max(end, start), chars.count)
|
|
58
|
+
let digits = chars.filter(isMoneyDigit)
|
|
59
|
+
var left = chars[..<start].filter(isMoneyDigit).count
|
|
60
|
+
var removed = chars[start..<end].filter(isMoneyDigit).count
|
|
61
|
+
let insertedDigits = inserted.filter(isMoneyDigit)
|
|
62
|
+
if removed == 0 && insertedDigits.isEmpty && left > 0 && chars[start..<end].contains(".") {
|
|
63
|
+
left -= 1
|
|
64
|
+
removed = 1
|
|
65
|
+
}
|
|
66
|
+
if removed == 0 && insertedDigits.isEmpty {
|
|
67
|
+
return (String(digits), left)
|
|
68
|
+
}
|
|
69
|
+
let next = String(digits[..<left]) + insertedDigits + String(digits[(left + removed)...])
|
|
70
|
+
var trimmed = String(next.drop(while: { $0 == "0" }))
|
|
71
|
+
if trimmed.isEmpty { trimmed = String(next.prefix(1)) }
|
|
72
|
+
return (trimmed, max(0, left + insertedDigits.count - (next.count - trimmed.count)))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// Caret offset right after the `digitIndex`-th digit of `display`.
|
|
76
|
+
private func moneyCaret(_ display: String, digitIndex: Int) -> Int {
|
|
77
|
+
var count = 0
|
|
78
|
+
for (index, char) in display.enumerated() where isMoneyDigit(char) {
|
|
79
|
+
count += 1
|
|
80
|
+
if count == digitIndex { return index + 1 }
|
|
81
|
+
}
|
|
82
|
+
return 0
|
|
83
|
+
}
|
|
84
|
+
|
|
49
85
|
// MARK: - InputMoney
|
|
50
86
|
|
|
51
87
|
public struct InputMoney: View {
|
|
@@ -261,8 +297,8 @@ public struct InputMoney: View {
|
|
|
261
297
|
// MARK: - MoneyTextField (UIViewRepresentable)
|
|
262
298
|
|
|
263
299
|
/// A UITextField that shows live thousands-separated currency while the bound
|
|
264
|
-
/// `digits` string holds only the raw numeric value.
|
|
265
|
-
/// the trailing currency symbol.
|
|
300
|
+
/// `digits` string holds only the raw numeric value. The caret can move freely
|
|
301
|
+
/// but never past the trailing currency symbol; edits apply at the caret.
|
|
266
302
|
private struct MoneyTextField: UIViewRepresentable {
|
|
267
303
|
@Binding var digits: String
|
|
268
304
|
var keyboardType: UIKeyboardType
|
|
@@ -286,11 +322,6 @@ private struct MoneyTextField: UIViewRepresentable {
|
|
|
286
322
|
field.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
287
323
|
field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
288
324
|
field.text = displayValue(digits)
|
|
289
|
-
field.addTarget(
|
|
290
|
-
context.coordinator,
|
|
291
|
-
action: #selector(Coordinator.editingChanged(_:)),
|
|
292
|
-
for: .editingChanged
|
|
293
|
-
)
|
|
294
325
|
return field
|
|
295
326
|
}
|
|
296
327
|
|
|
@@ -302,12 +333,13 @@ private struct MoneyTextField: UIViewRepresentable {
|
|
|
302
333
|
field.textColor = textColor
|
|
303
334
|
field.tintColor = cursorColor
|
|
304
335
|
let expected = displayValue(digits)
|
|
305
|
-
if
|
|
336
|
+
if field.text != expected {
|
|
306
337
|
field.text = expected
|
|
338
|
+
context.coordinator.setCaret(field, offset: moneyCaret(expected, digitIndex: expected.filter(isMoneyDigit).count))
|
|
307
339
|
}
|
|
308
340
|
}
|
|
309
341
|
|
|
310
|
-
|
|
342
|
+
fileprivate func displayValue(_ raw: String) -> String {
|
|
311
343
|
let numeric = raw.filter { $0.isNumber }
|
|
312
344
|
return numeric.isEmpty ? "" : formatNumberToMoney(numeric)
|
|
313
345
|
}
|
|
@@ -330,21 +362,44 @@ private struct MoneyTextField: UIViewRepresentable {
|
|
|
330
362
|
return true
|
|
331
363
|
}
|
|
332
364
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
365
|
+
func textField(
|
|
366
|
+
_ textField: UITextField,
|
|
367
|
+
shouldChangeCharactersIn range: NSRange,
|
|
368
|
+
replacementString string: String
|
|
369
|
+
) -> Bool {
|
|
370
|
+
let (numeric, caretDigit) = applyMoneyEdit(
|
|
371
|
+
textField.text ?? "",
|
|
372
|
+
start: range.location,
|
|
373
|
+
end: range.location + range.length,
|
|
374
|
+
inserted: string
|
|
375
|
+
)
|
|
376
|
+
let formatted = parent.displayValue(numeric)
|
|
377
|
+
textField.text = formatted
|
|
378
|
+
setCaret(textField, offset: moneyCaret(formatted, digitIndex: caretDigit))
|
|
344
379
|
if parent.digits != numeric {
|
|
345
380
|
parent.digits = numeric
|
|
381
|
+
parent.onChangeText?(numeric)
|
|
382
|
+
}
|
|
383
|
+
return false
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Keeps the caret out of the currency suffix.
|
|
387
|
+
func textFieldDidChangeSelection(_ textField: UITextField) {
|
|
388
|
+
guard let text = textField.text, !text.isEmpty,
|
|
389
|
+
let range = textField.selectedTextRange,
|
|
390
|
+
let limit = textField.position(from: textField.endOfDocument, offset: -1)
|
|
391
|
+
else { return }
|
|
392
|
+
let start = textField.compare(range.start, to: limit) == .orderedDescending ? limit : range.start
|
|
393
|
+
let end = textField.compare(range.end, to: limit) == .orderedDescending ? limit : range.end
|
|
394
|
+
if start != range.start || end != range.end {
|
|
395
|
+
textField.selectedTextRange = textField.textRange(from: start, to: end)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
func setCaret(_ textField: UITextField, offset: Int) {
|
|
400
|
+
if let position = textField.position(from: textField.beginningOfDocument, offset: offset) {
|
|
401
|
+
textField.selectedTextRange = textField.textRange(from: position, to: position)
|
|
346
402
|
}
|
|
347
|
-
parent.onChangeText?(numeric)
|
|
348
403
|
}
|
|
349
404
|
}
|
|
350
405
|
}
|
|
@@ -139,6 +139,7 @@ public struct MomoText: View {
|
|
|
139
139
|
private let letterSpacing: CGFloat?
|
|
140
140
|
private let softWrap: Bool
|
|
141
141
|
private let accessibilityId: String?
|
|
142
|
+
private var highlightText: [String] = []
|
|
142
143
|
|
|
143
144
|
public init(
|
|
144
145
|
_ content: String,
|
|
@@ -176,13 +177,52 @@ public struct MomoText: View {
|
|
|
176
177
|
self.accessibilityId = accessibilityId
|
|
177
178
|
}
|
|
178
179
|
|
|
180
|
+
init(_ content: String, typography: TypographyStyle, highlightText: [String]) {
|
|
181
|
+
self.init(content, typography: typography)
|
|
182
|
+
self.highlightText = highlightText
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Whole-word matches of `highlightText` render semibold; the longest keyword wins (RN parity).
|
|
186
|
+
private var baseText: SwiftUI.Text {
|
|
187
|
+
let keywords = highlightText.filter { !$0.isEmpty }.sorted { $0.count > $1.count }
|
|
188
|
+
guard !keywords.isEmpty else { return SwiftUI.Text(content) }
|
|
189
|
+
let highlightFont = momoFont(size: fontScale(typography.fontSize), weight: TypographyStyle.headerXsSemibold.fontWeight, family: theme.font)
|
|
190
|
+
func isWordChar(_ character: Character?) -> Bool {
|
|
191
|
+
guard let character else { return false }
|
|
192
|
+
return character.isLetter || character.isNumber || character == "_"
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
var result = SwiftUI.Text(verbatim: "")
|
|
196
|
+
var plainStart = content.startIndex
|
|
197
|
+
var index = content.startIndex
|
|
198
|
+
while index < content.endIndex {
|
|
199
|
+
let previous = index > content.startIndex ? content[content.index(before: index)] : nil
|
|
200
|
+
let keyword = isWordChar(previous) ? nil : keywords.first { keyword in
|
|
201
|
+
guard content[index...].hasPrefix(keyword) else { return false }
|
|
202
|
+
let end = content.index(index, offsetBy: keyword.count)
|
|
203
|
+
return !isWordChar(end < content.endIndex ? content[end] : nil)
|
|
204
|
+
}
|
|
205
|
+
guard let keyword else {
|
|
206
|
+
index = content.index(after: index)
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
let end = content.index(index, offsetBy: keyword.count)
|
|
210
|
+
result = result
|
|
211
|
+
+ SwiftUI.Text(verbatim: String(content[plainStart..<index]))
|
|
212
|
+
+ SwiftUI.Text(verbatim: String(content[index..<end])).font(highlightFont)
|
|
213
|
+
plainStart = end
|
|
214
|
+
index = end
|
|
215
|
+
}
|
|
216
|
+
return result + SwiftUI.Text(verbatim: String(content[plainStart...]))
|
|
217
|
+
}
|
|
218
|
+
|
|
179
219
|
public var body: some View {
|
|
180
220
|
let textColor = color ?? theme.colors.text.default
|
|
181
221
|
// Strikethrough is driven either by the typography variant (mirrors Compose
|
|
182
222
|
// *Strikethrough styles) or the explicit `strikethrough` flag.
|
|
183
223
|
let isStrikethrough = strikethrough || typography.isStrikethrough
|
|
184
224
|
|
|
185
|
-
var text =
|
|
225
|
+
var text = baseText
|
|
186
226
|
.font(momoFont(size: fontScale(typography.fontSize), weight: typography.fontWeight, family: theme.font))
|
|
187
227
|
.foregroundColor(textColor)
|
|
188
228
|
.strikethrough(isStrikethrough, color: textColor)
|
package/ios/native-kits.podspec
CHANGED
|
@@ -44,6 +44,10 @@ private struct InformationPreview: View {
|
|
|
44
44
|
Information(title: "Payment failed", description: "We couldn't process your payment.",
|
|
45
45
|
state: .error, onPressAction: {}, action: "Retry")
|
|
46
46
|
}
|
|
47
|
+
BasicColumnBox("Highlight text", alignment: .leading) {
|
|
48
|
+
Information(description: "Bạn được hoàn 50.000đ, áp dụng đến hết 31/12/2026",
|
|
49
|
+
highlightText: ["50.000đ", "31/12/2026"])
|
|
50
|
+
}
|
|
47
51
|
BasicColumnBox("Without icon", alignment: .leading) {
|
|
48
52
|
Information(title: "No icon", description: "showIcon is set to false.", showIcon: false)
|
|
49
53
|
}
|
|
@@ -60,6 +64,7 @@ private struct InformationPlayground: View {
|
|
|
60
64
|
@State private var showIcon = true
|
|
61
65
|
@State private var showIconClose = false
|
|
62
66
|
@State private var action = ""
|
|
67
|
+
@State private var highlightText = "description"
|
|
63
68
|
|
|
64
69
|
var body: some View {
|
|
65
70
|
PlaygroundTab(
|
|
@@ -72,12 +77,15 @@ private struct InformationPlayground: View {
|
|
|
72
77
|
showIcon: showIcon,
|
|
73
78
|
action: action.isEmpty ? nil : action,
|
|
74
79
|
showIconClose: showIconClose,
|
|
75
|
-
onPressClose: {}
|
|
80
|
+
onPressClose: {},
|
|
81
|
+
highlightText: highlightText.components(separatedBy: ",")
|
|
82
|
+
.map { $0.trimmingCharacters(in: .whitespaces) }
|
|
76
83
|
)
|
|
77
84
|
},
|
|
78
85
|
params: [
|
|
79
86
|
.string("Title", $title),
|
|
80
87
|
.string("Description", $description),
|
|
88
|
+
.string("HighlightText: [String] (comma-separated)", $highlightText),
|
|
81
89
|
.radio("State", informationStateList, $state),
|
|
82
90
|
.checkbox("ShowIcon", $showIcon),
|
|
83
91
|
.checkbox("ShowIconClose", $showIconClose),
|
package/package.json
CHANGED
package/publish.sh
CHANGED
|
@@ -372,10 +372,34 @@ phase_publish_npm() {
|
|
|
372
372
|
# FIRST PUBLISH: DEBUG VERSION (with compose kits included)
|
|
373
373
|
# ============================================================
|
|
374
374
|
echo ""
|
|
375
|
+
# A bad token surfaces as a 404 on the PUT, after the working tree was already rewritten
|
|
376
|
+
# below — so verify auth first, before anything is touched.
|
|
377
|
+
if ! npm whoami --registry https://registry.npmjs.org >/dev/null 2>&1; then
|
|
378
|
+
echo "❌ Error: npm auth failed (npm whoami). Renew the token in .npmrc / ~/.npmrc and retry."
|
|
379
|
+
exit 1
|
|
380
|
+
fi
|
|
381
|
+
|
|
375
382
|
echo "🔧 Preparing debug version with compose kits..."
|
|
376
383
|
|
|
377
384
|
# Create debug version
|
|
378
385
|
DEBUG_VERSION="${VERSION}-debug"
|
|
386
|
+
|
|
387
|
+
# set -e aborts mid-phase on any failure; put the tree back no matter where it stopped.
|
|
388
|
+
# Idempotent: the happy path already moved the backups away and reset the version.
|
|
389
|
+
restore_npm_workspace() {
|
|
390
|
+
[ -f .npmignore.backup ] && mv .npmignore.backup .npmignore
|
|
391
|
+
[ -f compose/build.gradle.kts.backup ] && mv compose/build.gradle.kts.backup compose/build.gradle.kts
|
|
392
|
+
local pkg_version
|
|
393
|
+
pkg_version=$(node -p "require('./package.json').version")
|
|
394
|
+
if [ "$pkg_version" != "$VERSION" ]; then
|
|
395
|
+
if [[ "$OSTYPE" == "darwin"* ]]; then
|
|
396
|
+
sed -i '' "s/\"version\": \"$pkg_version\"/\"version\": \"$VERSION\"/" package.json
|
|
397
|
+
else
|
|
398
|
+
sed -i "s/\"version\": \"$pkg_version\"/\"version\": \"$VERSION\"/" package.json
|
|
399
|
+
fi
|
|
400
|
+
fi
|
|
401
|
+
}
|
|
402
|
+
trap restore_npm_workspace EXIT
|
|
379
403
|
echo "📦 Debug version: $DEBUG_VERSION"
|
|
380
404
|
|
|
381
405
|
# Get current version from package.json (in case it's different from $VERSION)
|
|
@@ -482,6 +506,7 @@ EOF
|
|
|
482
506
|
npm publish --tag $NPM_TAG --access=public
|
|
483
507
|
|
|
484
508
|
echo "✅ Normal version published successfully!"
|
|
509
|
+
trap - EXIT
|
|
485
510
|
|
|
486
511
|
echo ""
|
|
487
512
|
echo "✅ PHASE 3 COMPLETED"
|