@momo-kits/native-kits 0.160.6-debug → 0.160.7-beta.5-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 (26) hide show
  1. package/.claude/settings.local.json +31 -0
  2. package/compose/build.gradle.kts +9 -3
  3. package/compose/build.gradle.kts.backup +8 -2
  4. package/compose/compose.podspec +1 -1
  5. package/compose/src/androidMain/kotlin/vn/momo/kits/navigation/ScrollToTop.android.kt +6 -0
  6. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +7 -1
  7. package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +324 -117
  8. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +4 -1
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +1 -0
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +5 -1
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +12 -0
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ScrollToTop.kt +8 -0
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +72 -68
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ComposeLottieAnimation.kt +62 -0
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +9 -1
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Resources.kt +12 -4
  17. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/ScrollToTop.ios.kt +33 -0
  18. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +7 -1
  19. package/gradle/libs.versions.toml +2 -0
  20. package/gradle.properties +1 -1
  21. package/ios/StatusBarTap/StatusBarTap.h +13 -0
  22. package/ios/StatusBarTap/StatusBarTap.m +75 -0
  23. package/ios/native-kits.podspec +2 -1
  24. package/local.properties +8 -0
  25. package/package.json +1 -1
  26. package/settings.gradle.kts +15 -3
@@ -8,6 +8,7 @@ import androidx.compose.animation.core.rememberInfiniteTransition
8
8
  import androidx.compose.animation.core.tween
9
9
  import androidx.compose.foundation.background
10
10
  import androidx.compose.foundation.border
11
+ import androidx.compose.ui.draw.alpha
11
12
  import androidx.compose.foundation.clickable
12
13
  import androidx.compose.foundation.layout.Arrangement
13
14
  import androidx.compose.foundation.layout.Box
@@ -131,7 +132,9 @@ fun InputOTP(
131
132
  },
132
133
  decorationBox = { innerTextField ->
133
134
  Box {
134
- Box(modifier = Modifier.size(0.dp)) { innerTextField() }
135
+ Box(
136
+ modifier = Modifier.fillMaxWidth().height(56.dp).alpha(0f)
137
+ ) { innerTextField() }
135
138
  if (floatingValue.isNotEmpty()) {
136
139
  Box(
137
140
  modifier = Modifier.wrapContentSize()
@@ -96,4 +96,5 @@ data class KeyboardOptions(
96
96
  data class ScrollData(
97
97
  val scrollable: Boolean = true,
98
98
  val scrollState: ScrollableState? = null,
99
+ val scrollToTopCallback: (() -> Unit)? = null,
99
100
  )
@@ -3,6 +3,7 @@ package vn.momo.kits.navigation
3
3
  import androidx.compose.animation.*
4
4
  import androidx.compose.animation.core.tween
5
5
  import androidx.compose.runtime.*
6
+ import androidx.compose.runtime.saveable.rememberSaveable
6
7
  import androidx.compose.ui.unit.Dp
7
8
  import androidx.navigation.compose.NavHost
8
9
  import androidx.navigation.compose.composable
@@ -49,7 +50,9 @@ fun NavigationContainer(
49
50
  }
50
51
  }
51
52
 
52
- val startDestination = DynamicScreenRegistry.register(initialScreenName, initialScreen, options)
53
+ val screenId = rememberSaveable { DynamicScreenRegistry.nextId() }
54
+ DynamicScreenRegistry.bind(screenId, initialScreenName, initialScreen, options)
55
+ val startDestination = remember(screenId) { DynamicScreenRoute(screenId) }
53
56
 
54
57
  ProvideNavigationEventDispatcherOwner {
55
58
  CompositionLocalProvider(
@@ -132,6 +135,7 @@ fun NavigationContainer(
132
135
  DisposableEffect(Unit) {
133
136
  onDispose {
134
137
  navigator.dispose()
138
+ DynamicScreenRegistry.unregisterScreen(screenId)
135
139
  }
136
140
  }
137
141
  }
@@ -196,6 +196,18 @@ object DynamicScreenRegistry {
196
196
  return DynamicScreenRoute(id)
197
197
  }
198
198
 
199
+ fun nextId(): Int = idCounter++
200
+
201
+ fun bind(id: Int, screenName: String, content: @Composable () -> Unit, options: NavigationOptions?) {
202
+ screens[id] = DynamicScreen(
203
+ id = id,
204
+ name = screenName,
205
+ content = content,
206
+ options = options
207
+ )
208
+ if (id >= idCounter) idCounter = id + 1 // keep counter ahead after process-death restore
209
+ }
210
+
199
211
  fun unregisterScreen(id: Int) {
200
212
  screens.remove(id)
201
213
  }
@@ -0,0 +1,8 @@
1
+ package vn.momo.kits.navigation
2
+
3
+ import androidx.compose.runtime.Composable
4
+
5
+ @Composable
6
+ internal expect fun RegisterScrollToTop(callback: (() -> Unit)?)
7
+
8
+
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Column
11
11
  import androidx.compose.foundation.layout.ColumnScope
12
12
  import androidx.compose.foundation.layout.Spacer
13
13
  import androidx.compose.foundation.layout.WindowInsets
14
+ import androidx.compose.foundation.layout.asPaddingValues
14
15
  import androidx.compose.foundation.layout.aspectRatio
15
16
  import androidx.compose.foundation.layout.fillMaxSize
16
17
  import androidx.compose.foundation.layout.fillMaxWidth
@@ -27,10 +28,12 @@ import androidx.compose.material.ExperimentalMaterialApi
27
28
  import androidx.compose.runtime.Composable
28
29
  import androidx.compose.runtime.CompositionLocalProvider
29
30
  import androidx.compose.runtime.LaunchedEffect
31
+ import androidx.compose.runtime.State
30
32
  import androidx.compose.runtime.getValue
31
33
  import androidx.compose.runtime.mutableIntStateOf
32
34
  import androidx.compose.runtime.mutableStateOf
33
35
  import androidx.compose.runtime.remember
36
+ import androidx.compose.runtime.rememberUpdatedState
34
37
  import androidx.compose.runtime.snapshotFlow
35
38
  import androidx.compose.runtime.staticCompositionLocalOf
36
39
  import androidx.compose.ui.Alignment
@@ -150,6 +153,10 @@ internal fun StackScreen(
150
153
  val footerHeightPx = remember { mutableIntStateOf(0) }
151
154
  val headerRightWidthPx = remember { mutableIntStateOf(0) }
152
155
 
156
+ if (navigation.options.scrollData.scrollToTopCallback != null) {
157
+ RegisterScrollToTop(navigation.options.scrollData.scrollToTopCallback)
158
+ }
159
+
153
160
  BackHandler(true) { navigator.onBackSafe() }
154
161
 
155
162
  CompositionLocalProvider(
@@ -160,37 +167,35 @@ internal fun StackScreen(
160
167
  LocalFooterHeightPx provides footerHeightPx,
161
168
  LocalHeaderRightWidthPx provides headerRightWidthPx
162
169
  ) {
163
- Box(Modifier
164
- .fillMaxSize()
165
- .background(options.backgroundColor ?: AppTheme.current.colors.background.default)
166
- .conditional(options.keyboardOptions.keyboardShouldPersistTaps) {
167
- hideKeyboardOnTap()
168
- }
169
- .conditional(options.keyboardOptions.useAvoidKeyboard && supportsImePadding()) {
170
- imePadding()
171
- }
170
+ Box(
171
+ Modifier
172
+ .fillMaxSize()
173
+ .background(options.backgroundColor ?: AppTheme.current.colors.background.default)
174
+ .conditional(options.keyboardOptions.keyboardShouldPersistTaps) {
175
+ hideKeyboardOnTap()
176
+ }
177
+ .conditional(options.keyboardOptions.useAvoidKeyboard && supportsImePadding()) {
178
+ imePadding()
179
+ }
172
180
  ) {
173
181
  Box(Modifier.zIndex(1f)) {
174
182
  HeaderBackground()
175
183
  }
176
184
 
177
- Box(Modifier.zIndex(5f)) {
185
+ Box(Modifier.zIndex(4f)) {
178
186
  Header(onBackHandler)
179
187
  }
180
188
 
181
- Column (Modifier.zIndex(6f)) {
189
+ Column(Modifier.zIndex(5f)) {
182
190
  SearchAnimated(isScrollInProgress = scrollInProcess)
183
191
  }
184
192
 
185
193
  Column(Modifier.zIndex(2f).fillMaxSize()) {
186
194
  MainContent(content = content)
187
- }
188
-
189
- Box(Modifier.zIndex(4f).align(Alignment.BottomCenter)) {
190
195
  FooterContent()
191
196
  }
192
197
 
193
- Box(Modifier.zIndex(7f)){
198
+ Box(Modifier.zIndex(6f)) {
194
199
  FloatingContent()
195
200
  }
196
201
 
@@ -200,15 +205,15 @@ internal fun StackScreen(
200
205
  }
201
206
 
202
207
  @Composable
203
- fun FloatingContent(){
208
+ fun FloatingContent() {
204
209
  val options = LocalOptions.current
205
210
  val density = LocalDensity.current
206
211
  val footerHeightPx = LocalFooterHeightPx.current
207
212
  val scrollState = LocalScrollState.current
208
213
 
209
214
  val fabProps = options.floatingButtonProps ?: return
210
- val bottomPadding = fabProps.bottom ?:
211
- (Spacing.M + if (options.footerComponent != null) with(density){ footerHeightPx.intValue.toDp() } else 0.dp)
215
+ val bottomPadding = fabProps.bottom
216
+ ?: (Spacing.M + if (options.footerComponent != null) with(density) { footerHeightPx.intValue.toDp() } else 0.dp)
212
217
 
213
218
  FloatingButton(
214
219
  scrollPosition = fabProps.scrollState?.value ?: scrollState.value,
@@ -224,17 +229,19 @@ fun FloatingContent(){
224
229
  }
225
230
 
226
231
  @Composable
227
- fun ColumnScope.MainContent(content: @Composable ()-> Unit){
232
+ fun ColumnScope.MainContent(content: @Composable () -> Unit) {
228
233
  val options = LocalOptions.current
229
234
  val inputSearchType = getInputSearchType(options)
230
235
  val density = LocalDensity.current
231
236
  val scrollState = LocalScrollState.current
232
237
 
233
- Spacer(Modifier.height(
234
- if (options.headerType is HeaderType.DefaultOrExtended || (options.headerType is HeaderType.Transparent && !options.headerType.isFullScreenContent))
235
- AppStatusBar.current + HEADER_HEIGHT.dp else 0.dp)
238
+ Spacer(
239
+ Modifier.height(
240
+ if (options.headerType is HeaderType.DefaultOrExtended || (options.headerType is HeaderType.Transparent && !options.headerType.isFullScreenContent))
241
+ AppStatusBar.current + HEADER_HEIGHT.dp else 0.dp
242
+ )
236
243
  )
237
- if (inputSearchType == InputSearchType.Animated){
244
+ if (inputSearchType == InputSearchType.Animated) {
238
245
  val scrollDp = with(density) { scrollState.value.toDp() }
239
246
 
240
247
  val animatedTopPadding by animateDpAsState(
@@ -243,35 +250,31 @@ fun ColumnScope.MainContent(content: @Composable ()-> Unit){
243
250
  )
244
251
  Spacer(Modifier.height(animatedTopPadding))
245
252
  }
246
- Column (Modifier
247
- .fillMaxWidth()
248
- .weight(1f)
249
- .conditional(options.scrollData.scrollable && options.scrollData.scrollState is ScrollState) {
250
- verticalScroll(scrollState)
251
- }
253
+ Column(
254
+ Modifier
255
+ .fillMaxWidth()
256
+ .weight(1f)
257
+ .conditional(options.scrollData.scrollable && options.scrollData.scrollState is ScrollState) {
258
+ verticalScroll(scrollState)
259
+ }
252
260
  ) {
253
261
  ScreenContent(content = content)
254
262
  }
255
263
 
256
- if (options.footerComponent != null){
257
- val footerHeight = LocalFooterHeightPx.current
258
- val footerHeightDp = with(density) { footerHeight.value.toDp() }
259
- Spacer(Modifier.height(footerHeightDp))
260
- }
261
264
  }
262
265
 
263
266
  @Composable
264
- fun ScreenContent(content: @Composable () -> Unit){
267
+ fun ScreenContent(content: @Composable () -> Unit) {
265
268
  val scrollState = LocalScrollState.current
266
269
  val options = LocalOptions.current
267
270
 
268
- if (options.headerType is HeaderType.Animated){
271
+ if (options.headerType is HeaderType.Animated) {
269
272
  val animatedHeader = options.headerType
270
273
  Box {
271
- Box(Modifier.fillMaxWidth().aspectRatio(animatedHeader.aspectRatio.value)){
274
+ Box(Modifier.fillMaxWidth().aspectRatio(animatedHeader.aspectRatio.value)) {
272
275
  animatedHeader.composable.invoke(scrollState.value)
273
276
  }
274
- Box(Modifier.offset(x = 0.dp, y = AppStatusBar.current + HEADER_HEIGHT.dp + animatedHeader.layoutOffSet)){
277
+ Box(Modifier.offset(x = 0.dp, y = AppStatusBar.current + HEADER_HEIGHT.dp + animatedHeader.layoutOffSet)) {
275
278
  content()
276
279
  }
277
280
  }
@@ -281,25 +284,27 @@ fun ScreenContent(content: @Composable () -> Unit){
281
284
  }
282
285
 
283
286
  @Composable
284
- fun FooterContent(){
287
+ fun FooterContent() {
285
288
  val options = LocalOptions.current
286
- if (options.footerComponent != null){
287
- val ime = WindowInsets.ime
288
- val density = LocalDensity.current
289
- val imeBottom = ime.getBottom(density)
290
- val thresholdPx = with(density) { 50.dp.toPx() }
291
- val isKeyboardVisible = imeBottom > thresholdPx
292
- val bottomPadding = if (isKeyboardVisible) 0.dp else AppNavigationBar.current
289
+ if (options.footerComponent != null) {
290
+ val keyboardSize = keyboardSizeState()
291
+ val bottomPadding = (AppNavigationBar.current - keyboardSize.value).coerceAtLeast(0.dp)
293
292
  Footer(footerComponent = options.footerComponent, bottomPadding = bottomPadding)
294
293
  }
295
294
  }
296
295
 
297
296
  @Composable
298
- fun OverplayView(bottomTabIndex: Int, id: Int){
297
+ fun keyboardSizeState(): State<Dp> {
298
+ val bottom = WindowInsets.ime.asPaddingValues()
299
+ return rememberUpdatedState(bottom.calculateBottomPadding())
300
+ }
301
+
302
+ @Composable
303
+ fun OverplayView(bottomTabIndex: Int, id: Int) {
299
304
  val overplayType = OverplayComponentRegistry.getOverplayType()
300
305
 
301
306
  if (overplayType != null) {
302
- Box(Modifier.zIndex(if (overplayType == OverplayComponentType.SNACK_BAR) 3f else 8f).fillMaxSize()){
307
+ Box(Modifier.zIndex(if (overplayType == OverplayComponentType.SNACK_BAR) 3f else 7f).fillMaxSize()) {
303
308
  if (bottomTabIndex != -1) return@Box
304
309
  if (OverplayComponentRegistry.currentRootId() != id) return@Box
305
310
  OverplayComponentRegistry.OverlayComponent()
@@ -322,22 +327,24 @@ fun Footer(footerComponent: @Composable (() -> Unit)?, bottomPadding: Dp) {
322
327
  Box(Modifier.onGloballyPositioned {
323
328
  if (footerHeightPx.intValue != it.size.height) footerHeightPx.intValue = it.size.height
324
329
  }) {
325
- Box(Modifier
326
- .fillMaxWidth()
327
- .background(AppTheme.current.colors.background.surface)
328
- .conditional(IsShowBaseLineDebug) {
329
- border(1.dp, Colors.blue_03)
330
- }
331
- .padding(top = Spacing.S, start = Spacing.M, end = Spacing.M, bottom = bottomPadding + Spacing.S)
332
- ){
330
+ Box(
331
+ Modifier
332
+ .fillMaxWidth()
333
+ .background(AppTheme.current.colors.background.surface)
334
+ .conditional(IsShowBaseLineDebug) {
335
+ border(1.dp, Colors.blue_03)
336
+ }
337
+ .padding(top = Spacing.S, start = Spacing.M, end = Spacing.M, bottom = bottomPadding + Spacing.S)
338
+ ) {
333
339
  footerComponent.invoke()
334
340
  }
335
341
 
336
- Box(modifier = Modifier
337
- .fillMaxWidth()
338
- .height(6.dp)
339
- .offset(x = 0.dp, y = (-6).dp)
340
- .background(shadowBrush)
342
+ Box(
343
+ modifier = Modifier
344
+ .fillMaxWidth()
345
+ .height(6.dp)
346
+ .offset(x = 0.dp, y = (-6).dp)
347
+ .background(shadowBrush)
341
348
  )
342
349
  }
343
350
  }
@@ -347,6 +354,7 @@ data class InputSearchLayoutParams(
347
354
  val startPadding: Dp,
348
355
  val endPadding: Dp
349
356
  )
357
+
350
358
  @Composable
351
359
  fun SearchAnimated(
352
360
  isScrollInProgress: Boolean
@@ -455,12 +463,6 @@ fun SearchAnimated(
455
463
  }
456
464
  }
457
465
 
458
- @Composable
459
- internal fun isKeyboardVisible(): Boolean {
460
- val ime = WindowInsets.ime
461
- val density = LocalDensity.current
462
- return ime.getBottom(density) > 0
463
- }
464
466
  private fun quantize(value: Int, stepPx: Int): Int {
465
467
  if (stepPx <= 1) return value
466
468
  return (value / stepPx) * stepPx
@@ -540,13 +542,15 @@ internal val LocalOptions = staticCompositionLocalOf<NavigationOptions> { error(
540
542
 
541
543
  val StackScreenScrollableState = staticCompositionLocalOf<ScrollableState?> { null }
542
544
 
543
- internal fun getInputSearchType(options: NavigationOptions): InputSearchType{
545
+ internal fun getInputSearchType(options: NavigationOptions): InputSearchType {
544
546
  return when (val headerType = options.headerType) {
545
547
  is HeaderType.DefaultOrExtended -> when {
546
548
  headerType.inputSearchProps == null -> InputSearchType.None
547
549
  headerType.useAnimated -> InputSearchType.Animated
548
550
  else -> InputSearchType.Header
549
551
  }
552
+
550
553
  else -> InputSearchType.None
551
554
  }
552
555
  }
556
+
@@ -0,0 +1,62 @@
1
+ package vn.momo.kits.platform
2
+
3
+ import androidx.compose.foundation.Image
4
+ import androidx.compose.foundation.background
5
+ import androidx.compose.foundation.layout.Box
6
+ import androidx.compose.foundation.layout.fillMaxSize
7
+ import androidx.compose.runtime.Composable
8
+ import androidx.compose.runtime.getValue
9
+ import androidx.compose.ui.Modifier
10
+ import androidx.compose.ui.graphics.Color
11
+ import io.github.alexzhirkevich.compottie.Compottie
12
+ import io.github.alexzhirkevich.compottie.ExperimentalCompottieApi
13
+ import io.github.alexzhirkevich.compottie.LottieCompositionSpec
14
+ import io.github.alexzhirkevich.compottie.dynamic.rememberLottieDynamicProperties
15
+ import io.github.alexzhirkevich.compottie.rememberLottieComposition
16
+ import io.github.alexzhirkevich.compottie.rememberLottiePainter
17
+ import vn.momo.kits.utils.readJson
18
+
19
+ @OptIn(ExperimentalCompottieApi::class)
20
+ @Composable
21
+ internal fun ComposeLottieAnimation(
22
+ path: String,
23
+ tintColor: Color?,
24
+ bgColor: Color?,
25
+ modifier: Modifier,
26
+ ) {
27
+ val json = readJson(path)
28
+
29
+ if (json.isEmpty()) {
30
+ Box(modifier.background(bgColor ?: Color.Transparent))
31
+ return
32
+ }
33
+
34
+ val composition by rememberLottieComposition(json) {
35
+ LottieCompositionSpec.JsonString(json)
36
+ }
37
+
38
+ // Recolor only the fill/stroke color slots (matching the native lottie-ios per-keypath tint)
39
+ // instead of a global ColorFilter.tint, which would flatten a multicolor animation to a silhouette.
40
+ val dynamicProperties = if (tintColor != null) {
41
+ rememberLottieDynamicProperties(tintColor) {
42
+ shapeLayer("**") {
43
+ fill("**") { color { tintColor } }
44
+ stroke("**") { color { tintColor } }
45
+ }
46
+ }
47
+ } else {
48
+ null
49
+ }
50
+
51
+ Box(modifier.background(bgColor ?: Color.Transparent)) {
52
+ Image(
53
+ painter = rememberLottiePainter(
54
+ composition = composition,
55
+ iterations = Compottie.IterateForever,
56
+ dynamicProperties = dynamicProperties,
57
+ ),
58
+ contentDescription = null,
59
+ modifier = Modifier.fillMaxSize(),
60
+ )
61
+ }
62
+ }
@@ -44,13 +44,21 @@ fun supportsImePadding(): Boolean = when (val v = getOSVersion()) {
44
44
  is OSVersion.IOS -> true
45
45
  }
46
46
 
47
+ /**
48
+ * @param useCompose when `true`, renders via the pure-Compose Compottie engine instead of the
49
+ * platform-native engine (airbnb-lottie on Android, lottie-ios on iOS). Defaults to `false` for
50
+ * backward compatibility. Note: when `useCompose = true`, `tintColor` recolors only the fill/stroke
51
+ * color slots (matching the native lottie-ios per-keypath tint), so gradient and text-layer colors
52
+ * are left untinted; `placedAsOverlay` is a no-op.
53
+ */
47
54
  @Composable
48
55
  expect fun LottieAnimation(
49
56
  path: String,
50
57
  tintColor: Color? = null,
51
58
  bgColor: Color? = null,
52
59
  placedAsOverlay: Boolean = false,
53
- modifier: Modifier = Modifier
60
+ modifier: Modifier = Modifier,
61
+ useCompose: Boolean = false
54
62
  )
55
63
 
56
64
  expect fun NativePaint.setColor(
@@ -24,13 +24,21 @@ fun getResource(name: String): DrawableResource {
24
24
  @Composable
25
25
  fun readJson(name: String): String {
26
26
  val path = name.plus(".json")
27
+ val candidatePaths = listOf(
28
+ path,
29
+ "composeResources/vn.momo.compose.resources/$path",
30
+ )
27
31
 
28
32
  val jsonContent by rememberState(path, { "" }) {
29
33
  val cached = resourceCache.getOrPut(path) {
30
- val content = try {
31
- readResourceBytes(path).decodeToString()
32
- } catch (_: Exception) {
33
- ""
34
+ var content = ""
35
+ for (candidate in candidatePaths) {
36
+ try {
37
+ content = readResourceBytes(candidate).decodeToString()
38
+ break
39
+ } catch (_: Exception) {
40
+ // try next candidate
41
+ }
34
42
  }
35
43
  ResourceCache.JSON(content)
36
44
  } as ResourceCache.JSON
@@ -0,0 +1,33 @@
1
+ package vn.momo.kits.navigation
2
+
3
+ import androidx.compose.runtime.Composable
4
+ import androidx.compose.runtime.DisposableEffect
5
+ import platform.Foundation.NSNotification
6
+ import platform.Foundation.NSNotificationCenter
7
+ import platform.Foundation.NSOperationQueue
8
+ import platform.darwin.NSObjectProtocol
9
+
10
+ private const val STATUS_BAR_TAPPED_NOTIFICATION = "statusBarSelected"
11
+
12
+ @Composable
13
+ internal actual fun RegisterScrollToTop(callback: (() -> Unit)?) {
14
+ val maxApi = LocalMaxApi.current
15
+ DisposableEffect(callback) {
16
+ val cb = callback
17
+ val token: NSObjectProtocol? = if (cb != null) {
18
+ NSNotificationCenter.defaultCenter.addObserverForName(
19
+ name = STATUS_BAR_TAPPED_NOTIFICATION,
20
+ `object` = null,
21
+ queue = NSOperationQueue.mainQueue,
22
+ ) { _: NSNotification? ->
23
+ runCatching { cb() }.onFailure {
24
+ maxApi?.logFile("[ScrollToTop] callback threw: ${it.message}") {}
25
+
26
+ }
27
+ }
28
+ } else null
29
+ onDispose {
30
+ token?.let { NSNotificationCenter.defaultCenter.removeObserver(it) }
31
+ }
32
+ }
33
+ }
@@ -98,8 +98,14 @@ actual fun LottieAnimation(
98
98
  tintColor: Color?,
99
99
  bgColor: Color?,
100
100
  placedAsOverlay: Boolean,
101
- modifier: Modifier
101
+ modifier: Modifier,
102
+ useCompose: Boolean
102
103
  ) {
104
+ if (useCompose) {
105
+ ComposeLottieAnimation(path, tintColor, bgColor, modifier)
106
+ return
107
+ }
108
+
103
109
  var animation by remember { mutableStateOf<CompatibleAnimation?>(null) }
104
110
 
105
111
  LaunchedEffect(Unit) {
@@ -13,6 +13,7 @@ androidGradlePlugin = "8.13.2"
13
13
  r8 = "8.9.35"
14
14
  kotlinx-datetime = "0.7.1"
15
15
  airbnb-lottie = "5.2.0"
16
+ compottie = "2.2.1"
16
17
  androidx-activity = "1.9.1"
17
18
  androidx-appcompat = "1.7.0"
18
19
  material = "1.10.0"
@@ -38,6 +39,7 @@ coil-multiplatform-compose = { module = "io.coil-kt.coil3:coil-compose", version
38
39
  coil-multiplatform-network-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil3-multiplatform" }
39
40
  r8 = { module = "com.android.tools:r8", version.ref = "r8" }
40
41
  airbnb-lottie = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "airbnb-lottie" }
42
+ compottie = { module = "io.github.alexzhirkevich:compottie", version.ref = "compottie" }
41
43
  native-max-api = { group = "vn.momo.maxapi", name = "NativeMaxApi", version.ref = "nativemaxapi" }
42
44
  kits = { module = "vn.momo.kits:kits", version.ref = "kits" }
43
45
  androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
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.160.6
21
+ version=0.160.7-beta.5
22
22
 
23
23
  repo=GitLab
24
24
  url=https://gitlab.mservice.com.vn/api/v4/projects/5400/packages/maven
@@ -0,0 +1,13 @@
1
+ #import <Foundation/Foundation.h>
2
+
3
+ NS_ASSUME_NONNULL_BEGIN
4
+
5
+ /// Posts `MoMoStatusBarTap.notificationName` on every status-bar tap.
6
+ /// The swizzle installs automatically at framework load (`+load`) — calling
7
+ /// `install` is a no-op kept for explicit/manual triggering.
8
+ @interface MoMoStatusBarTap : NSObject
9
+ @property (class, nonatomic, readonly) NSNotificationName notificationName;
10
+ + (void)install;
11
+ @end
12
+
13
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,75 @@
1
+ #import <UIKit/UIKit.h>
2
+ #import <objc/message.h>
3
+ #import <objc/runtime.h>
4
+ #import "StatusBarTap.h"
5
+
6
+ // Performs the actual swizzle once per process. Safe to call multiple times.
7
+ static void mm_installStatusBarTapSwizzleOnce(void) {
8
+ static dispatch_once_t onceToken;
9
+ dispatch_once(&onceToken, ^{
10
+ Class cls = [UIStatusBarManager class];
11
+ SEL originalSelector = NSSelectorFromString(@"handleTapAction:");
12
+ SEL swizzledSelector = NSSelectorFromString(@"mm_handleTapAction:");
13
+
14
+ Method originalMethod = class_getInstanceMethod(cls, originalSelector);
15
+ Method swizzledMethod = class_getInstanceMethod(cls, swizzledSelector);
16
+ if (!originalMethod || !swizzledMethod) return;
17
+
18
+ // Verify the original method matches the (id) -> void shape we expect.
19
+ // Encoding may include byte offsets (e.g. "v32@0:8@16") so we filter
20
+ // digits out before comparing.
21
+ const char *encPtr = method_getTypeEncoding(originalMethod);
22
+ if (encPtr) {
23
+ NSMutableString *enc = [NSMutableString string];
24
+ for (const char *p = encPtr; *p; p++) {
25
+ if (!(*p >= '0' && *p <= '9')) [enc appendFormat:@"%c", *p];
26
+ }
27
+ if (![enc isEqualToString:@"v@:@"]) return;
28
+ }
29
+
30
+ BOOL didAdd = class_addMethod(cls,
31
+ originalSelector,
32
+ method_getImplementation(swizzledMethod),
33
+ method_getTypeEncoding(swizzledMethod));
34
+ if (didAdd) {
35
+ class_replaceMethod(cls,
36
+ swizzledSelector,
37
+ method_getImplementation(originalMethod),
38
+ method_getTypeEncoding(originalMethod));
39
+ } else {
40
+ method_exchangeImplementations(originalMethod, swizzledMethod);
41
+ }
42
+ });
43
+ }
44
+
45
+ @implementation MoMoStatusBarTap
46
+ + (NSNotificationName)notificationName { return @"statusBarSelected"; }
47
+ + (void)install { mm_installStatusBarTapSwizzleOnce(); }
48
+ @end
49
+
50
+ // MARK: - Swizzle target on UIStatusBarManager
51
+ // `mm_handleTapAction:` is the replacement IMP. After the add/exchange,
52
+ // this selector points at the original UIKit IMP — calling it inside
53
+ // the body invokes UIKit's real handler so UIScrollView.scrollsToTop
54
+ // and any other side effects continue to work.
55
+
56
+ @interface UIStatusBarManager (MoMoStatusBarTap)
57
+ @end
58
+
59
+ @implementation UIStatusBarManager (MoMoStatusBarTap)
60
+
61
+ // Auto-install at framework load. Commented out so the swizzle stays
62
+ // opt-in via `[MoMoStatusBarTap install]`. Uncomment to revert to
63
+ // auto-installation before main().
64
+ //
65
+ // + (void)load {
66
+ // mm_installStatusBarTapSwizzleOnce();
67
+ // }
68
+
69
+ - (void)mm_handleTapAction:(id)action {
70
+ [self mm_handleTapAction:action];
71
+ [[NSNotificationCenter defaultCenter] postNotificationName:MoMoStatusBarTap.notificationName
72
+ object:nil];
73
+ }
74
+
75
+ @end
@@ -10,7 +10,8 @@ Pod::Spec.new do |s|
10
10
  s.ios.deployment_target = '15.0'
11
11
  s.swift_version = '5.0'
12
12
 
13
- s.source_files = "**/*.swift"
13
+ s.source_files = "**/*.{swift,m,h}"
14
+ s.public_header_files = "StatusBarTap/*.h"
14
15
  s.framework = 'SwiftUI', 'Combine'
15
16
  s.dependency 'SDWebImageSwiftUI'
16
17
  s.dependency 'lottie-ios'
@@ -0,0 +1,8 @@
1
+ ## This file must *NOT* be checked into Version Control Systems,
2
+ # as it contains information specific to your local configuration.
3
+ #
4
+ # Location of the SDK. This is only used by Gradle.
5
+ # For customization when using a Version Control System, please read the
6
+ # header note.
7
+ #Mon Dec 22 10:07:29 ICT 2025
8
+ sdk.dir=/Users/phuc/Library/Android/sdk