@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 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
 
@@ -40,7 +40,7 @@ kotlin {
40
40
  }
41
41
 
42
42
  cocoapods {
43
- version = "0.165.1-beta.11-debug"
43
+ version = "0.165.2-beta.2-debug"
44
44
  summary = "IOS Shared module"
45
45
  homepage = "https://momo.vn"
46
46
  ios.deploymentTarget = "15.0"
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |spec|
2
2
  spec.name = 'compose'
3
- spec.version = '0.165.1-beta.11-debug'
3
+ spec.version = '0.165.2-beta.2-debug'
4
4
  spec.homepage = 'https://momo.vn'
5
5
  spec.source = { :http=> ''}
6
6
  spec.authors = ''
@@ -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
- description,
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.AnnotatedString
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.OffsetMapping
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
- class CustomConverter : VisualTransformation {
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
- return TransformedText(
70
- AnnotatedString(formattedText),
71
- object : OffsetMapping {
72
- override fun originalToTransformed(offset: Int): Int = formattedText.length
73
- override fun transformedToOriginal(offset: Int): Int = text.length
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 = text.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
- val numericValue = it.filter { char -> char.isDigit() }
141
- onChangeText(numericValue)
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, input) {
25
+ DisposableEffect(dispatcher, target) {
71
26
  dispatcher.addInput(input)
72
- HostBackGestureBridge.attach(input)
27
+ HostBackGestureBridge.attach(target)
73
28
  onDispose {
74
- HostBackGestureBridge.detach(input)
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,
@@ -1,5 +1,5 @@
1
1
  [versions]
2
- android-compileSdk = "36"
2
+ android-compileSdk = "37"
3
3
  android-minSdk = "24"
4
4
  android-targetSdk = "36"
5
5
  ktor-version = "3.0.0-rc-1"
package/gradle.properties CHANGED
@@ -18,7 +18,7 @@ kotlin.apple.xcodeCompatibility.nowarn=true
18
18
  name="ComposeKits"
19
19
  group=vn.momo.kits
20
20
  artifact.id=kits
21
- version=0.165.1-beta.11
21
+ version=0.165.2-beta.2
22
22
 
23
23
  repo=GitLab
24
24
  url=https://gitlab.mservice.com.vn/api/v4/projects/5400/packages/maven
@@ -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. Caret is pinned before
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 !field.isFirstResponder && field.text != expected {
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
- private func displayValue(_ raw: String) -> String {
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
- @objc func editingChanged(_ textField: UITextField) {
334
- // Extract digits from whatever the user typed/deleted.
335
- let numeric = (textField.text ?? "").filter { $0.isNumber }
336
- let formatted = numeric.isEmpty ? "" : formatNumberToMoney(numeric)
337
- if textField.text != formatted {
338
- textField.text = formatted
339
- // Keep caret before the currency suffix ("đ").
340
- if let end = textField.position(from: textField.endOfDocument, offset: -1) {
341
- textField.selectedTextRange = textField.textRange(from: end, to: end)
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 = SwiftUI.Text(content)
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)
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |s|
2
2
  s.name = 'MoMoUIKits'
3
- s.version = '0.165.1-beta.11'
3
+ s.version = '0.165.2-beta.2'
4
4
  s.summary = 'MoMoUIKits for iOS'
5
5
  s.homepage = 'https://momo.vn'
6
6
  s.license = { :type => 'MIT' }
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |s|
2
2
  s.name = 'MoMoUIKitsDemo'
3
- s.version = '0.165.1-beta.11'
3
+ s.version = '0.165.2-beta.2'
4
4
  s.summary = 'Demo browser for MoMoUIKits SwiftUI components'
5
5
  s.homepage = 'https://momo.vn'
6
6
  s.license = { :type => 'MIT' }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/native-kits",
3
- "version": "0.165.1-beta.11",
3
+ "version": "0.165.2-beta.2-debug",
4
4
  "private": false,
5
5
  "dependeNavigationncies": {},
6
6
  "devDependencies": {},
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"