@momo-kits/native-kits 0.157.5-debug → 0.158.1-beta.1-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.
Files changed (139) hide show
  1. package/.claude/settings.local.json +11 -0
  2. package/.claude/skills/design-system/SKILL.md +88 -0
  3. package/.claude/skills/design-system/references/components/avatar.md +134 -0
  4. package/.claude/skills/design-system/references/components/badge.md +127 -0
  5. package/.claude/skills/design-system/references/components/bottom-tab.md +177 -0
  6. package/.claude/skills/design-system/references/components/bottomsheet.md +170 -0
  7. package/.claude/skills/design-system/references/components/button.md +206 -0
  8. package/.claude/skills/design-system/references/components/carousel.md +117 -0
  9. package/.claude/skills/design-system/references/components/checkbox.md +98 -0
  10. package/.claude/skills/design-system/references/components/chip.md +146 -0
  11. package/.claude/skills/design-system/references/components/collapse.md +120 -0
  12. package/.claude/skills/design-system/references/components/date-picker.md +119 -0
  13. package/.claude/skills/design-system/references/components/divider.md +84 -0
  14. package/.claude/skills/design-system/references/components/icon.md +130 -0
  15. package/.claude/skills/design-system/references/components/image.md +81 -0
  16. package/.claude/skills/design-system/references/components/information.md +107 -0
  17. package/.claude/skills/design-system/references/components/input-dropdown.md +138 -0
  18. package/.claude/skills/design-system/references/components/input-money.md +157 -0
  19. package/.claude/skills/design-system/references/components/input-otp.md +132 -0
  20. package/.claude/skills/design-system/references/components/input-phone-number.md +140 -0
  21. package/.claude/skills/design-system/references/components/input-search.md +124 -0
  22. package/.claude/skills/design-system/references/components/input-text-area.md +133 -0
  23. package/.claude/skills/design-system/references/components/input.md +152 -0
  24. package/.claude/skills/design-system/references/components/loader.md +87 -0
  25. package/.claude/skills/design-system/references/components/pagination.md +105 -0
  26. package/.claude/skills/design-system/references/components/popup-notify.md +128 -0
  27. package/.claude/skills/design-system/references/components/progress-info.md +114 -0
  28. package/.claude/skills/design-system/references/components/radio.md +86 -0
  29. package/.claude/skills/design-system/references/components/rating.md +126 -0
  30. package/.claude/skills/design-system/references/components/skeleton.md +120 -0
  31. package/.claude/skills/design-system/references/components/slider.md +141 -0
  32. package/.claude/skills/design-system/references/components/snackbar.md +97 -0
  33. package/.claude/skills/design-system/references/components/stepper.md +100 -0
  34. package/.claude/skills/design-system/references/components/steps.md +91 -0
  35. package/.claude/skills/design-system/references/components/suggest-action.md +95 -0
  36. package/.claude/skills/design-system/references/components/swipe.md +121 -0
  37. package/.claude/skills/design-system/references/components/switch.md +98 -0
  38. package/.claude/skills/design-system/references/components/tab-view.md +120 -0
  39. package/.claude/skills/design-system/references/components/tag.md +118 -0
  40. package/.claude/skills/design-system/references/components/text.md +151 -0
  41. package/.claude/skills/design-system/references/components/toast.md +99 -0
  42. package/.claude/skills/design-system/references/components/tooltip.md +138 -0
  43. package/.claude/skills/design-system/references/components/top-nav-miniapp.md +94 -0
  44. package/.claude/skills/design-system/references/components/top-nav.md +226 -0
  45. package/.claude/skills/design-system/references/components/uploader.md +115 -0
  46. package/.claude/skills/design-system/references/navigation/bottom-tab.md +131 -0
  47. package/.claude/skills/design-system/references/navigation/bottomsheet.md +161 -0
  48. package/.claude/skills/design-system/references/navigation/modal.md +133 -0
  49. package/.claude/skills/design-system/references/navigation/navigation-options.md +225 -0
  50. package/.claude/skills/design-system/references/navigation/navigator.md +111 -0
  51. package/.claude/skills/design-system/references/navigation/setup.md +134 -0
  52. package/.claude/skills/design-system/references/navigation/stack.md +128 -0
  53. package/.claude/skills/design-system/references/spec-convention.md +80 -0
  54. package/.claude/skills/design-system/references/tokens/colors.md +131 -0
  55. package/.claude/skills/design-system/references/tokens/spacing-radius.md +144 -0
  56. package/.claude/skills/design-system/references/tokens/theme.md +125 -0
  57. package/.claude/skills/design-system/references/tokens/typography.md +135 -0
  58. package/.claude/skills/design-system-kits/SKILL.md +102 -0
  59. package/.claude/skills/design-system-kits/references/code-convention.md +118 -0
  60. package/.claude/skills/design-system-kits/references/components/avatar.md +45 -0
  61. package/.claude/skills/design-system-kits/references/components/badge.md +27 -0
  62. package/.claude/skills/design-system-kits/references/components/button.md +65 -0
  63. package/.claude/skills/design-system-kits/references/components/carousel.md +51 -0
  64. package/.claude/skills/design-system-kits/references/components/checkbox.md +39 -0
  65. package/.claude/skills/design-system-kits/references/components/chip.md +54 -0
  66. package/.claude/skills/design-system-kits/references/components/collapse.md +63 -0
  67. package/.claude/skills/design-system-kits/references/components/date-picker.md +36 -0
  68. package/.claude/skills/design-system-kits/references/components/divider.md +21 -0
  69. package/.claude/skills/design-system-kits/references/components/icon.md +382 -0
  70. package/.claude/skills/design-system-kits/references/components/image.md +62 -0
  71. package/.claude/skills/design-system-kits/references/components/information.md +61 -0
  72. package/.claude/skills/design-system-kits/references/components/input-dropdown.md +92 -0
  73. package/.claude/skills/design-system-kits/references/components/input-money.md +128 -0
  74. package/.claude/skills/design-system-kits/references/components/input-otp.md +85 -0
  75. package/.claude/skills/design-system-kits/references/components/input-phone-number.md +96 -0
  76. package/.claude/skills/design-system-kits/references/components/input-search.md +127 -0
  77. package/.claude/skills/design-system-kits/references/components/input-text-area.md +100 -0
  78. package/.claude/skills/design-system-kits/references/components/input.md +126 -0
  79. package/.claude/skills/design-system-kits/references/components/loader.md +41 -0
  80. package/.claude/skills/design-system-kits/references/components/pagination.md +47 -0
  81. package/.claude/skills/design-system-kits/references/components/popup-notify.md +69 -0
  82. package/.claude/skills/design-system-kits/references/components/popup-promotion.md +35 -0
  83. package/.claude/skills/design-system-kits/references/components/progress-info.md +55 -0
  84. package/.claude/skills/design-system-kits/references/components/radio.md +42 -0
  85. package/.claude/skills/design-system-kits/references/components/rating.md +36 -0
  86. package/.claude/skills/design-system-kits/references/components/skeleton.md +25 -0
  87. package/.claude/skills/design-system-kits/references/components/slider.md +53 -0
  88. package/.claude/skills/design-system-kits/references/components/snackbar.md +52 -0
  89. package/.claude/skills/design-system-kits/references/components/stepper.md +46 -0
  90. package/.claude/skills/design-system-kits/references/components/steps.md +57 -0
  91. package/.claude/skills/design-system-kits/references/components/suggest-action.md +44 -0
  92. package/.claude/skills/design-system-kits/references/components/swipe.md +44 -0
  93. package/.claude/skills/design-system-kits/references/components/switch.md +43 -0
  94. package/.claude/skills/design-system-kits/references/components/tab-view.md +56 -0
  95. package/.claude/skills/design-system-kits/references/components/tag.md +50 -0
  96. package/.claude/skills/design-system-kits/references/components/text.md +56 -0
  97. package/.claude/skills/design-system-kits/references/components/toast.md +51 -0
  98. package/.claude/skills/design-system-kits/references/components/tooltip.md +95 -0
  99. package/.claude/skills/design-system-kits/references/components/uploader.md +48 -0
  100. package/.claude/skills/design-system-kits/references/design-spec-structure.md +356 -0
  101. package/.claude/skills/design-system-kits/references/design-spec-to-code.md +596 -0
  102. package/.claude/skills/design-system-kits/references/navigation/bottom-tab.md +155 -0
  103. package/.claude/skills/design-system-kits/references/navigation/bottomsheet.md +94 -0
  104. package/.claude/skills/design-system-kits/references/navigation/modal.md +125 -0
  105. package/.claude/skills/design-system-kits/references/navigation/navigation-options.md +430 -0
  106. package/.claude/skills/design-system-kits/references/navigation/navigator.md +177 -0
  107. package/.claude/skills/design-system-kits/references/navigation/setup.md +94 -0
  108. package/.claude/skills/design-system-kits/references/navigation/stack.md +152 -0
  109. package/.claude/skills/design-system-kits/references/screen-layout-rule.md +125 -0
  110. package/.claude/skills/design-system-kits/references/tokens/colors.md +183 -0
  111. package/.claude/skills/design-system-kits/references/tokens/spacing-radius.md +45 -0
  112. package/.claude/skills/design-system-kits/references/tokens/theme.md +97 -0
  113. package/.claude/skills/design-system-kits/references/tokens/typography.md +105 -0
  114. package/.claude/skills/vibe-design/SKILL.md +306 -0
  115. package/compose/build.gradle.kts +1 -1
  116. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +7 -0
  117. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Avatar.kt +157 -0
  118. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +123 -0
  119. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Collapse.kt +224 -0
  120. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Loader.kt +108 -0
  121. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +2 -2
  122. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +338 -0
  123. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Rating.kt +87 -0
  124. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +348 -0
  125. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Stepper.kt +256 -0
  126. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Steps.kt +494 -0
  127. package/compose/src/commonMain/kotlin/vn/momo/kits/components/SuggestAction.kt +131 -0
  128. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +215 -0
  129. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +531 -0
  130. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Uploader.kt +192 -0
  131. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Spacing.kt +3 -0
  132. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Theme.kt +5 -2
  133. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/AutomationId.kt +2 -11
  134. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +5 -1
  135. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +12 -0
  136. package/gradle.properties +1 -1
  137. package/ios/Popup/PopupPromotion.swift +2 -2
  138. package/local.properties +8 -0
  139. package/package.json +1 -1
@@ -0,0 +1,348 @@
1
+ package vn.momo.kits.components
2
+
3
+ import androidx.compose.animation.core.animateFloatAsState
4
+ import androidx.compose.animation.core.spring
5
+ import androidx.compose.foundation.background
6
+ import androidx.compose.foundation.border
7
+ import androidx.compose.foundation.layout.Box
8
+ import androidx.compose.foundation.layout.BoxWithConstraints
9
+ import androidx.compose.foundation.layout.fillMaxWidth
10
+ import androidx.compose.foundation.layout.height
11
+ import androidx.compose.foundation.layout.offset
12
+ import androidx.compose.foundation.layout.padding
13
+ import androidx.compose.foundation.layout.size
14
+ import androidx.compose.foundation.layout.width
15
+ import androidx.compose.foundation.shape.RoundedCornerShape
16
+ import androidx.compose.runtime.Composable
17
+ import androidx.compose.runtime.getValue
18
+ import androidx.compose.runtime.mutableFloatStateOf
19
+ import androidx.compose.runtime.mutableStateOf
20
+ import androidx.compose.runtime.remember
21
+ import androidx.compose.runtime.setValue
22
+ import androidx.compose.ui.Alignment
23
+ import androidx.compose.ui.Modifier
24
+ import androidx.compose.ui.draw.clip
25
+ import androidx.compose.ui.graphics.Color
26
+ import androidx.compose.ui.input.pointer.pointerInput
27
+ import androidx.compose.ui.layout.onGloballyPositioned
28
+ import androidx.compose.ui.platform.LocalDensity
29
+ import androidx.compose.ui.text.style.TextAlign
30
+ import androidx.compose.ui.unit.IntOffset
31
+ import androidx.compose.ui.unit.dp
32
+ import vn.momo.kits.application.IsShowBaseLineDebug
33
+ import vn.momo.kits.const.AppTheme
34
+ import vn.momo.kits.const.Colors
35
+ import vn.momo.kits.const.Radius
36
+ import vn.momo.kits.const.Spacing
37
+ import vn.momo.kits.const.Typography
38
+ import vn.momo.kits.modifier.conditional
39
+ import vn.momo.kits.modifier.shadow
40
+ import kotlin.math.abs
41
+ import kotlin.math.min
42
+ import kotlin.math.roundToInt
43
+
44
+ // ── Design constants ─────────────────────────────────────────────────────────
45
+
46
+ private val THUMB_SIZE = 20.dp
47
+ private val THUMB_BORDER = 3.dp
48
+ private val TRACK_HEIGHT = 4.dp
49
+ private val LABEL_PAD_H = Spacing.XS
50
+ private val LABEL_PAD_V = Spacing.XXS
51
+ private val LABEL_GAP = Spacing.XS // space between label and thumb top
52
+
53
+ // ── Pure helpers (mirrors RN helpers.ts) ─────────────────────────────────────
54
+
55
+ private fun clamp(value: Float, min: Float, max: Float): Float =
56
+ min(kotlin.math.max(value, min), max)
57
+
58
+ /**
59
+ * Converts a raw touch position into a snapped slider value.
60
+ * Direct port of RN getValueForPosition().
61
+ */
62
+ private fun getValueForPosition(
63
+ positionInView: Float,
64
+ containerWidth: Float,
65
+ thumbWidth: Float,
66
+ min: Float,
67
+ max: Float,
68
+ step: Float,
69
+ ): Float {
70
+ val availableSpace = containerWidth - thumbWidth
71
+ if (availableSpace <= 0f) return min
72
+ val relStepUnit = step / (max - min)
73
+ var relPosition = (positionInView - thumbWidth / 2f) / availableSpace
74
+ val relOffset = relPosition % relStepUnit
75
+ relPosition -= relOffset
76
+ if (relOffset / relStepUnit >= 0.5f) relPosition += relStepUnit
77
+ return clamp(min + (relPosition / relStepUnit).roundToInt() * step, min, max)
78
+ }
79
+
80
+ /** Direct port of RN isLowCloser(). */
81
+ private fun isLowCloser(downX: Float, lowPosition: Float, highPosition: Float): Boolean {
82
+ if (lowPosition == highPosition) return downX < lowPosition
83
+ return abs(downX - lowPosition) < abs(downX - highPosition)
84
+ }
85
+
86
+ private fun valueToFraction(value: Float, min: Float, max: Float): Float {
87
+ if (max == min) return 0f
88
+ return clamp((value - min) / (max - min), 0f, 1f)
89
+ }
90
+
91
+ private fun formatLabel(value: Float): String =
92
+ if (value == value.toLong().toFloat()) value.toLong().toString()
93
+ else ((value * 10).toLong() / 10.0).toString()
94
+
95
+ // ── Component ─────────────────────────────────────────────────────────────────
96
+
97
+ /**
98
+ * Range slider (or single-thumb when [disableRange] = true), migrated from React Native.
99
+ *
100
+ * @param min Minimum selectable value.
101
+ * @param max Maximum selectable value.
102
+ * @param step Snap increment between values.
103
+ * @param modifier Modifier applied to the root container.
104
+ * @param low Initial lower-thumb value (defaults to [min]).
105
+ * @param high Initial upper-thumb value (defaults to [max]).
106
+ * @param minRange Minimum required gap between low and high (default 0).
107
+ * @param disableRange When true only the low thumb is shown (single-value mode).
108
+ * @param floatingLabel When true a value label floats above the active thumb during drag.
109
+ * @param disabled Non-interactive; rendered in the disabled colour.
110
+ * @param onValueChanged Invoked with (low, high, byUser) on every value change.
111
+ * @param onSliderTouchStart Invoked when a drag gesture begins.
112
+ * @param onSliderTouchEnd Invoked when a drag gesture ends.
113
+ */
114
+ @Composable
115
+ fun Slider(
116
+ min: Float,
117
+ max: Float,
118
+ step: Float,
119
+ modifier: Modifier = Modifier,
120
+ low: Float? = null,
121
+ high: Float? = null,
122
+ minRange: Float = 0f,
123
+ disableRange: Boolean = false,
124
+ floatingLabel: Boolean = false,
125
+ disabled: Boolean = false,
126
+ onValueChanged: (low: Float, high: Float, byUser: Boolean) -> Unit = { _, _, _ -> },
127
+ onSliderTouchStart: (low: Float, high: Float) -> Unit = { _, _ -> },
128
+ onSliderTouchEnd: (low: Float, high: Float) -> Unit = { _, _ -> },
129
+ ) {
130
+ val theme = AppTheme.current
131
+ val density = LocalDensity.current
132
+
133
+ // Validated, step-snapped initial values
134
+ val initLow = remember(low, min, max, step) {
135
+ clamp(low ?: min, min, max)
136
+ }
137
+ val initHigh = remember(high, min, max, step, disableRange) {
138
+ if (disableRange) max else clamp(high ?: max, min, max)
139
+ }
140
+
141
+ var lowValue by remember { mutableFloatStateOf(initLow) }
142
+ var highValue by remember { mutableFloatStateOf(initHigh) }
143
+
144
+ // Smoothly animated display fractions
145
+ val lowFraction by animateFloatAsState(
146
+ targetValue = valueToFraction(lowValue, min, max),
147
+ animationSpec = spring(stiffness = 1200f),
148
+ label = "lowFraction"
149
+ )
150
+ val highFraction by animateFloatAsState(
151
+ targetValue = valueToFraction(highValue, min, max),
152
+ animationSpec = spring(stiffness = 1200f),
153
+ label = "highFraction"
154
+ )
155
+
156
+ var draggingLow by remember { mutableStateOf(true) }
157
+ var isDragging by remember { mutableStateOf(false) }
158
+
159
+ val thumbColor = if (disabled) theme.colors.text.disable else theme.colors.primary
160
+ val trackActiveColor = if (disabled) theme.colors.text.disable else theme.colors.primary
161
+ val trackInactiveColor = theme.colors.background.default
162
+
163
+ // trackWidthPx is the full pixel width of the BoxWithConstraints (incl. thumb padding)
164
+ var trackWidthPx by remember { mutableFloatStateOf(0f) }
165
+ val thumbSizePx = with(density) { THUMB_SIZE.toPx() }
166
+
167
+ Box(
168
+ modifier = modifier
169
+ .fillMaxWidth()
170
+ .conditional(IsShowBaseLineDebug) { border(1.dp, Colors.blue_03) }
171
+ ) {
172
+ BoxWithConstraints(
173
+ modifier = Modifier
174
+ .fillMaxWidth()
175
+ .onGloballyPositioned { coords -> trackWidthPx = coords.size.width.toFloat() }
176
+ // ── Gesture handling ──────────────────────────────────────────
177
+ .pointerInput(disabled, min, max, step, minRange, disableRange) {
178
+ if (disabled) return@pointerInput
179
+
180
+ awaitPointerEventScope {
181
+ while (true) {
182
+ // Finger down
183
+ val downEvent = awaitPointerEvent()
184
+ val downChange = downEvent.changes.firstOrNull() ?: continue
185
+ val touchX = downChange.position.x
186
+
187
+ // Pick the closer thumb
188
+ val lowCenterX = thumbSizePx / 2f +
189
+ valueToFraction(lowValue, min, max) * (trackWidthPx - thumbSizePx)
190
+ val highCenterX = thumbSizePx / 2f +
191
+ valueToFraction(highValue, min, max) * (trackWidthPx - thumbSizePx)
192
+
193
+ draggingLow = disableRange || isLowCloser(touchX, lowCenterX, highCenterX)
194
+ isDragging = true
195
+ onSliderTouchStart(lowValue, highValue)
196
+
197
+ // Apply initial press
198
+ applyDrag(touchX, trackWidthPx, thumbSizePx,
199
+ min, max, step, minRange, draggingLow,
200
+ lowValue, highValue) { nl, nh ->
201
+ lowValue = nl
202
+ highValue = nh
203
+ onValueChanged(nl, nh, true)
204
+ }
205
+
206
+ // Track move / release
207
+ while (true) {
208
+ val moveEvent = awaitPointerEvent()
209
+ val moveChange = moveEvent.changes.firstOrNull() ?: break
210
+ moveChange.consume()
211
+
212
+ if (!moveChange.pressed) {
213
+ isDragging = false
214
+ onSliderTouchEnd(lowValue, highValue)
215
+ break
216
+ }
217
+
218
+ applyDrag(moveChange.position.x, trackWidthPx, thumbSizePx,
219
+ min, max, step, minRange, draggingLow,
220
+ lowValue, highValue) { nl, nh ->
221
+ lowValue = nl
222
+ highValue = nh
223
+ onValueChanged(nl, nh, true)
224
+ }
225
+ }
226
+ }
227
+ }
228
+ }
229
+ ) {
230
+ val trackWidth = maxWidth
231
+ val availableWidth = trackWidth - THUMB_SIZE
232
+
233
+ // ── Floating label ────────────────────────────────────────────────
234
+ if (floatingLabel && isDragging) {
235
+ val activeFraction = if (draggingLow) lowFraction else highFraction
236
+ val activeValue = if (draggingLow) lowValue else highValue
237
+ val labelOffsetDp = availableWidth * activeFraction
238
+
239
+ Box(
240
+ modifier = Modifier
241
+ .align(Alignment.TopStart)
242
+ .offset { IntOffset(x = with(density) { labelOffsetDp.toPx().roundToInt() }, y = 0) }
243
+ ) {
244
+ Box(
245
+ modifier = Modifier
246
+ .background(
247
+ color = Colors.black_01,
248
+ shape = RoundedCornerShape(Radius.XS)
249
+ )
250
+ .padding(horizontal = LABEL_PAD_H, vertical = LABEL_PAD_V)
251
+ ) {
252
+ Text(
253
+ text = formatLabel(activeValue),
254
+ style = Typography.descriptionXsRegular,
255
+ color = theme.colors.text.default,
256
+ textAlign = TextAlign.Center
257
+ )
258
+ }
259
+ }
260
+ }
261
+
262
+ // Top padding pushes track + thumbs below the label when floatingLabel is on
263
+ val topPad = if (floatingLabel) THUMB_SIZE + LABEL_GAP else 0.dp
264
+
265
+ // ── Inactive track (full width) ───────────────────────────────────
266
+ Box(
267
+ modifier = Modifier
268
+ .align(Alignment.TopStart)
269
+ .padding(top = topPad + (THUMB_SIZE - TRACK_HEIGHT) / 2)
270
+ .padding(horizontal = THUMB_SIZE / 2)
271
+ .fillMaxWidth()
272
+ .height(TRACK_HEIGHT)
273
+ .clip(RoundedCornerShape(percent = 50))
274
+ .background(trackInactiveColor)
275
+ )
276
+
277
+ // ── Active track (selected range / single value) ──────────────────
278
+ val activeStartFraction = if (disableRange) 0f else lowFraction
279
+ val activeEndFraction = if (disableRange) lowFraction else highFraction
280
+ val activeStartDp = THUMB_SIZE / 2 + availableWidth * activeStartFraction
281
+ val activeWidthDp = availableWidth * (activeEndFraction - activeStartFraction)
282
+
283
+ if (activeWidthDp > 0.dp) {
284
+ Box(
285
+ modifier = Modifier
286
+ .align(Alignment.TopStart)
287
+ .padding(top = topPad + (THUMB_SIZE - TRACK_HEIGHT) / 2)
288
+ .offset(x = activeStartDp)
289
+ .width(activeWidthDp)
290
+ .height(TRACK_HEIGHT)
291
+ .clip(RoundedCornerShape(percent = 50))
292
+ .background(trackActiveColor)
293
+ )
294
+ }
295
+
296
+ // ── Low thumb ─────────────────────────────────────────────────────
297
+ Box(
298
+ modifier = Modifier
299
+ .align(Alignment.TopStart)
300
+ .padding(top = topPad)
301
+ .offset(x = availableWidth * lowFraction)
302
+ .size(THUMB_SIZE)
303
+ .clip(RoundedCornerShape(percent = 100))
304
+ .background(thumbColor)
305
+ .border(THUMB_BORDER, theme.colors.background.surface, RoundedCornerShape(percent = 100))
306
+ )
307
+
308
+ // ── High thumb (range mode only) ──────────────────────────────────
309
+ if (!disableRange) {
310
+ Box(
311
+ modifier = Modifier
312
+ .align(Alignment.TopStart)
313
+ .padding(top = topPad)
314
+ .offset(x = availableWidth * highFraction)
315
+ .size(THUMB_SIZE)
316
+ .clip(RoundedCornerShape(percent = 100))
317
+ .background(thumbColor)
318
+ .border(THUMB_BORDER, theme.colors.background.surface, RoundedCornerShape(percent = 100))
319
+ )
320
+ }
321
+ }
322
+ }
323
+ }
324
+
325
+ // ── Private drag helper ───────────────────────────────────────────────────────
326
+
327
+ private fun applyDrag(
328
+ positionX: Float,
329
+ trackWidthPx: Float,
330
+ thumbSizePx: Float,
331
+ min: Float,
332
+ max: Float,
333
+ step: Float,
334
+ minRange: Float,
335
+ isLow: Boolean,
336
+ currentLow: Float,
337
+ currentHigh: Float,
338
+ onUpdate: (Float, Float) -> Unit,
339
+ ) {
340
+ val snapped = getValueForPosition(positionX, trackWidthPx, thumbSizePx, min, max, step)
341
+ if (isLow) {
342
+ val newLow = clamp(snapped, min, currentHigh - minRange)
343
+ if (newLow != currentLow) onUpdate(newLow, currentHigh)
344
+ } else {
345
+ val newHigh = clamp(snapped, currentLow + minRange, max)
346
+ if (newHigh != currentHigh) onUpdate(currentLow, newHigh)
347
+ }
348
+ }
@@ -0,0 +1,256 @@
1
+ package vn.momo.kits.components
2
+
3
+ import androidx.compose.foundation.background
4
+ import androidx.compose.foundation.border
5
+ import androidx.compose.foundation.layout.Box
6
+ import androidx.compose.foundation.layout.Row
7
+ import androidx.compose.foundation.layout.defaultMinSize
8
+ import androidx.compose.foundation.layout.height
9
+ import androidx.compose.foundation.layout.padding
10
+ import androidx.compose.foundation.layout.size
11
+ import androidx.compose.foundation.shape.CircleShape
12
+ import androidx.compose.foundation.shape.RoundedCornerShape
13
+ import androidx.compose.foundation.text.BasicTextField
14
+ import androidx.compose.foundation.text.KeyboardOptions
15
+ import androidx.compose.runtime.Composable
16
+ import androidx.compose.runtime.getValue
17
+ import androidx.compose.runtime.mutableStateOf
18
+ import androidx.compose.runtime.remember
19
+ import androidx.compose.runtime.setValue
20
+ import androidx.compose.ui.Alignment
21
+ import androidx.compose.ui.Modifier
22
+ import androidx.compose.ui.graphics.Color
23
+ import androidx.compose.ui.text.TextStyle
24
+ import androidx.compose.ui.text.input.KeyboardType
25
+ import androidx.compose.ui.text.style.TextAlign
26
+ import androidx.compose.ui.unit.Dp
27
+ import androidx.compose.ui.unit.dp
28
+ import androidx.compose.ui.unit.sp
29
+ import vn.momo.kits.application.IsShowBaseLineDebug
30
+ import vn.momo.kits.const.AppTheme
31
+ import vn.momo.kits.const.Colors
32
+ import vn.momo.kits.const.Radius
33
+ import vn.momo.kits.const.Spacing
34
+ import vn.momo.kits.const.scaleSize
35
+ import vn.momo.kits.modifier.activeOpacityClickable
36
+ import vn.momo.kits.modifier.conditional
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Size variant
40
+ // ---------------------------------------------------------------------------
41
+
42
+ enum class StepperSize {
43
+ LARGE,
44
+ SMALL
45
+ }
46
+
47
+ private data class StepperSizeSpec(
48
+ val buttonSize: Dp,
49
+ val iconSize: Dp,
50
+ val numberMinWidth: Dp,
51
+ val numberHeight: Dp,
52
+ )
53
+
54
+ private val stepperSizeSpecs: Map<StepperSize, StepperSizeSpec> = mapOf(
55
+ StepperSize.LARGE to StepperSizeSpec(
56
+ buttonSize = 36.dp,
57
+ iconSize = 22.dp,
58
+ numberMinWidth = 32.dp,
59
+ numberHeight = 28.dp,
60
+ ),
61
+ StepperSize.SMALL to StepperSizeSpec(
62
+ buttonSize = 28.dp,
63
+ iconSize = 18.dp,
64
+ numberMinWidth = 28.dp,
65
+ numberHeight = 24.dp,
66
+ ),
67
+ )
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Private sub-composables
71
+ // ---------------------------------------------------------------------------
72
+
73
+ @Composable
74
+ private fun StepperButton(
75
+ sign: String = "+",
76
+ size: StepperSize = StepperSize.LARGE,
77
+ disabled: Boolean = false,
78
+ onPress: () -> Unit = {},
79
+ modifier: Modifier = Modifier,
80
+ ) {
81
+ val theme = AppTheme.current
82
+ val specs = remember(size) { stepperSizeSpecs[size] ?: stepperSizeSpecs[StepperSize.LARGE]!! }
83
+
84
+ val iconColor: Color = if (disabled) theme.colors.text.disable else theme.colors.primary
85
+ val iconName: String =
86
+ if (sign == "-") "24_navigation_minus_circle" else "24_navigation_plus_circle"
87
+
88
+ val clickableModifier: Modifier = if (!disabled) {
89
+ Modifier.activeOpacityClickable(onClick = onPress)
90
+ } else {
91
+ Modifier
92
+ }
93
+
94
+ Box(
95
+ modifier = modifier
96
+ .size(specs.buttonSize)
97
+ .background(color = theme.colors.background.disable, shape = CircleShape)
98
+ .then(clickableModifier)
99
+ .conditional(IsShowBaseLineDebug) { border(1.dp, Colors.blue_03) },
100
+ contentAlignment = Alignment.Center,
101
+ ) {
102
+ Icon(
103
+ source = iconName,
104
+ size = specs.iconSize,
105
+ color = iconColor,
106
+ )
107
+ }
108
+ }
109
+
110
+ @Composable
111
+ private fun StepperNumberView(
112
+ value: Int,
113
+ size: StepperSize = StepperSize.LARGE,
114
+ disabled: Boolean = false,
115
+ editable: Boolean = false,
116
+ onValueChange: (String) -> Unit = {},
117
+ modifier: Modifier = Modifier,
118
+ ) {
119
+ val theme = AppTheme.current
120
+ val specs = remember(size) { stepperSizeSpecs[size] ?: stepperSizeSpecs[StepperSize.LARGE]!! }
121
+
122
+ val textColor: Color = if (disabled) theme.colors.text.disable else theme.colors.text.default
123
+ val borderColor: Color =
124
+ if (disabled) theme.colors.border.disable else theme.colors.border.default
125
+
126
+ val scaledFontSize = scaleSize(12.sp)
127
+
128
+ val textStyle = TextStyle(
129
+ color = textColor,
130
+ fontSize = scaledFontSize,
131
+ textAlign = TextAlign.Center,
132
+ )
133
+
134
+ Box(
135
+ modifier = modifier
136
+ .defaultMinSize(minWidth = specs.numberMinWidth)
137
+ .height(specs.numberHeight)
138
+ .border(
139
+ width = 1.dp,
140
+ color = borderColor,
141
+ shape = RoundedCornerShape(Radius.S),
142
+ )
143
+ .padding(horizontal = Spacing.XS)
144
+ .conditional(IsShowBaseLineDebug) { border(1.dp, Colors.blue_03) },
145
+ contentAlignment = Alignment.Center,
146
+ ) {
147
+ BasicTextField(
148
+ value = value.toString(),
149
+ onValueChange = { if (editable) onValueChange(it) },
150
+ enabled = editable && !disabled,
151
+ readOnly = !editable,
152
+ singleLine = true,
153
+ textStyle = textStyle,
154
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
155
+ decorationBox = { innerTextField -> innerTextField() },
156
+ )
157
+ }
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Public API
162
+ // ---------------------------------------------------------------------------
163
+
164
+ /**
165
+ * Stepper — migrated from React Native Stepper.
166
+ *
167
+ * Layout: [− button] [value display] [+ button]
168
+ *
169
+ * @param defaultValue Initial value (default 0). Clamped to [min]..[max] on first composition.
170
+ * @param min Minimum allowed value (default 0). Minus button is auto-disabled at this bound.
171
+ * @param max Maximum allowed value (default 100). Plus button is auto-disabled at this bound.
172
+ * @param step Amount added/subtracted per button press (default 1).
173
+ * @param disabled Disable state. Pass `true` to disable everything, or a [BooleanArray] of
174
+ * size >= 2 where index 0 = minus button, index 1 = plus button.
175
+ * @param size Visual size variant: [StepperSize.LARGE] (default) or [StepperSize.SMALL].
176
+ * @param editable When `true` the number display becomes a direct keyboard input (default false).
177
+ * @param onValueChange Callback invoked with the new [Int] value on every change.
178
+ * @param modifier Modifier applied to the root [Row].
179
+ */
180
+ @Composable
181
+ fun Stepper(
182
+ defaultValue: Int = 0,
183
+ min: Int = 0,
184
+ max: Int = 100,
185
+ step: Int = 1,
186
+ disabled: Any = false,
187
+ size: StepperSize = StepperSize.LARGE,
188
+ editable: Boolean = false,
189
+ onValueChange: (Int) -> Unit = {},
190
+ modifier: Modifier = Modifier,
191
+ ) {
192
+ var value by remember(defaultValue) { mutableStateOf(defaultValue.coerceIn(min, max)) }
193
+
194
+ fun applyValue(raw: String) {
195
+ var parsed = raw.toIntOrNull() ?: 0
196
+ parsed = parsed.coerceIn(min, max)
197
+ value = parsed
198
+ onValueChange(parsed)
199
+ }
200
+
201
+ // Resolve per-part disabled state — mirrors RN getViewDisabledStatus()
202
+ val disabledMinus: Boolean
203
+ val disabledPlus: Boolean
204
+ val disabledNumber: Boolean
205
+
206
+ when {
207
+ disabled is BooleanArray && disabled.size >= 2 -> {
208
+ disabledMinus = disabled[0]
209
+ disabledPlus = disabled[1]
210
+ disabledNumber = disabled[0] && disabled[1]
211
+ }
212
+ disabled is Boolean && disabled -> {
213
+ disabledMinus = true
214
+ disabledPlus = true
215
+ disabledNumber = true
216
+ }
217
+ else -> {
218
+ disabledMinus = false
219
+ disabledPlus = false
220
+ disabledNumber = false
221
+ }
222
+ }
223
+
224
+ // Auto-disable buttons when the value is already at a bound
225
+ val effectiveMinusDisabled = disabledMinus || value <= min
226
+ val effectivePlusDisabled = disabledPlus || value >= max
227
+
228
+ Row(
229
+ modifier = modifier
230
+ .conditional(IsShowBaseLineDebug) { border(1.dp, Colors.blue_03) },
231
+ verticalAlignment = Alignment.CenterVertically,
232
+ ) {
233
+ StepperButton(
234
+ sign = "-",
235
+ size = size,
236
+ disabled = effectiveMinusDisabled,
237
+ onPress = { applyValue((value - step).toString()) },
238
+ )
239
+
240
+ StepperNumberView(
241
+ value = value,
242
+ size = size,
243
+ disabled = disabledNumber,
244
+ editable = editable,
245
+ onValueChange = { applyValue(it) },
246
+ modifier = Modifier.padding(horizontal = Spacing.S),
247
+ )
248
+
249
+ StepperButton(
250
+ sign = "+",
251
+ size = size,
252
+ disabled = effectivePlusDisabled,
253
+ onPress = { applyValue((value + step).toString()) },
254
+ )
255
+ }
256
+ }