@momo-kits/native-kits 0.300.3-debug → 0.300.4-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 (218) hide show
  1. package/CLAUDE.md +51 -7
  2. package/README.md +2 -2
  3. package/compose/build.gradle.kts +10 -2
  4. package/compose/build.gradle.kts.backup +8 -0
  5. package/compose/compose.podspec +1 -3
  6. package/compose/src/androidMain/kotlin/vn/momo/kits/const/HostFont.android.kt +4 -3
  7. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +2 -0
  8. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +6 -0
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +0 -14
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/application/DesignSystemKits.kt +62 -0
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +351 -111
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +20 -21
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +2 -1
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +4 -2
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +1 -1
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +1 -1
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +1 -1
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +9 -6
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +11 -12
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +4 -9
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +2 -2
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +10 -9
  23. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +6 -6
  24. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +55 -22
  25. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +12 -1
  26. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +7 -9
  27. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +1 -1
  28. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +13 -6
  29. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +13 -5
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +3 -2
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +14 -6
  32. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +19 -21
  33. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +2 -2
  34. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +5 -2
  35. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +3 -1
  36. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +7 -15
  37. package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +2 -1
  38. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +20 -15
  39. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +1 -2
  40. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +306 -0
  41. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +43 -4
  42. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +8 -0
  43. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +10 -0
  44. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +6 -2
  45. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +6 -0
  46. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +197 -60
  47. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +132 -83
  48. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +54 -62
  49. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +97 -0
  50. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +377 -0
  51. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +122 -24
  52. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +34 -14
  53. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +3 -3
  54. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +1 -2
  55. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +113 -26
  56. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +2 -2
  57. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +1 -2
  58. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HelpCenterRequest.kt +37 -0
  59. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +192 -0
  60. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +2 -1
  61. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/ToolkitRequests.kt +8 -0
  62. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +3 -0
  63. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +20 -0
  64. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +13 -0
  65. package/compose/src/commonTest/kotlin/vn/momo/kits/navigation/component/HelpCenterRequestTest.kt +44 -0
  66. package/compose/src/commonTest/kotlin/vn/momo/kits/navigation/component/ToolkitRequestsTest.kt +15 -0
  67. package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +3 -1
  68. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +4 -0
  69. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +138 -0
  70. package/gradle/libs.versions.toml +3 -14
  71. package/gradle/wrapper/gradle-wrapper.properties +0 -2
  72. package/gradle.properties +3 -5
  73. package/ios/Application/ApplicationEnvironment.swift +24 -6
  74. package/ios/Application/DesignSystemKits.swift +100 -0
  75. package/ios/Application/DesignSystemKitsPayload.swift +68 -0
  76. package/ios/Application/MaxApi.swift +30 -3
  77. package/ios/Avatar/Avatar.swift +4 -3
  78. package/ios/Badge/Badge.swift +6 -4
  79. package/ios/Badge/BadgeDot.swift +1 -1
  80. package/ios/Badge/BadgeRibbon.swift +8 -2
  81. package/ios/Button/Button.swift +15 -12
  82. package/ios/CalculatorKeyboard/CalculatorKeyboard.swift +64 -18
  83. package/ios/Carousel/Carousel.swift +29 -12
  84. package/ios/Checkbox/Checkbox.swift +7 -3
  85. package/ios/Chip/Chip.swift +5 -4
  86. package/ios/Collapse/Collapse.swift +70 -19
  87. package/ios/Colors+Radius+Spacing/Colors.swift +10 -1
  88. package/ios/Colors+Radius+Spacing/Radius.swift +6 -0
  89. package/ios/DateTimePicker/DateTimePicker.swift +54 -20
  90. package/ios/DateTimePicker/WheelPicker.swift +103 -105
  91. package/ios/Divider/Divider.swift +37 -4
  92. package/ios/Extensions/Color++.swift +38 -9
  93. package/ios/Extensions/OptionalEnvironmentObject.swift +12 -0
  94. package/ios/Extensions/SkeletonUICompat.swift +11 -0
  95. package/ios/Icon/Icon.swift +21 -23
  96. package/ios/Icon/Icons.swift +1329 -0
  97. package/ios/IconButton/IconButton.swift +15 -7
  98. package/ios/Image/Image.swift +7 -23
  99. package/ios/Information/Information.swift +27 -9
  100. package/ios/Input/ErrorView.swift +2 -2
  101. package/ios/Input/Input.swift +171 -148
  102. package/ios/Input/InputPhoneNumber.swift +10 -9
  103. package/ios/Input/InputSearch.swift +107 -78
  104. package/ios/Input/InputTextArea.swift +133 -148
  105. package/ios/InputDropDown/InputDropDown.swift +10 -7
  106. package/ios/InputMoney/InputMoney.swift +32 -11
  107. package/ios/InputOTP/InputOTP.swift +22 -12
  108. package/ios/Layout/Card.swift +50 -0
  109. package/ios/Layout/GridContext.swift +187 -3
  110. package/ios/Layout/Item.swift +5 -4
  111. package/ios/Layout/ScreenLayout.swift +415 -0
  112. package/ios/Layout/Section.swift +4 -100
  113. package/ios/Loader/Loader.swift +5 -9
  114. package/ios/Lottie/LottieView.swift +14 -2
  115. package/ios/{Application/Navigation/Overplay → Navigation}/BottomSheet.swift +169 -29
  116. package/ios/Navigation/BottomTab/BottomTab.swift +332 -0
  117. package/ios/{Application/Navigation → Navigation}/BottomTab/BottomTabBar.swift +18 -7
  118. package/ios/Navigation/BottomTab/DuoBarCompression.swift +104 -0
  119. package/ios/Navigation/BottomTab/DuoTabBar.swift +329 -0
  120. package/ios/{Application → Navigation/Component}/FloatingButton.swift +43 -54
  121. package/ios/Navigation/Component/Header.swift +545 -0
  122. package/ios/Navigation/Component/HeaderBackground.swift +154 -0
  123. package/ios/Navigation/Component/HeaderJourney.swift +73 -0
  124. package/ios/Navigation/Component/HeaderLocation.swift +48 -0
  125. package/ios/Navigation/Component/HeaderRight.swift +425 -0
  126. package/ios/Navigation/Component/HeaderTitle.swift +36 -0
  127. package/ios/{Application/Navigation → Navigation/Component}/HeaderUser.swift +21 -24
  128. package/ios/Navigation/Component/SideBar.swift +183 -0
  129. package/ios/{Application/Navigation/Overplay → Navigation/Component}/SnackBar.swift +8 -8
  130. package/ios/Navigation/HostBackGestureBridge.swift +78 -0
  131. package/ios/{Application/Navigation/Overplay → Navigation}/ModalScreen.swift +9 -2
  132. package/ios/Navigation/Navigation.swift +204 -0
  133. package/ios/Navigation/NavigationContainer.swift +195 -0
  134. package/ios/{Application/Navigation → Navigation}/Navigator.swift +207 -107
  135. package/ios/Navigation/StackScreen.swift +930 -0
  136. package/ios/Navigation/StackTransition.swift +400 -0
  137. package/ios/Navigation/SwipeBackHaptic.swift +33 -0
  138. package/ios/{Application/Navigation → Navigation}/Tracking/ScreenTracker.swift +12 -5
  139. package/ios/OTPKeyboard/OTPKeyboard.swift +8 -11
  140. package/ios/Pagination/PaginationScroll.swift +2 -2
  141. package/ios/Popup/PopupDisplay.swift +72 -135
  142. package/ios/Popup/PopupInput.swift +10 -13
  143. package/ios/Popup/PopupLayout.swift +138 -0
  144. package/ios/Popup/PopupNotify.swift +59 -69
  145. package/ios/Popup/PopupPromotion.swift +31 -47
  146. package/ios/PopupView/FullscreenPopup.swift +13 -19
  147. package/ios/PopupView/PopupView.swift +1 -1
  148. package/ios/PopupView/Utils++.swift +0 -8
  149. package/ios/ProgressInfo/ProgressInfo.swift +4 -2
  150. package/ios/Radio/Radio.swift +2 -1
  151. package/ios/Rating/Rating.swift +9 -1
  152. package/ios/Resources/dot_loading.json +558 -0
  153. package/ios/Resources/lottie_circle_loader.json +1 -0
  154. package/ios/ScrollIndicator/ScrollIndicator.swift +11 -10
  155. package/ios/Skeleton/Skeleton.swift +22 -27
  156. package/ios/Slider/Slider.swift +16 -18
  157. package/ios/Stepper/Stepper.swift +7 -3
  158. package/ios/Steps/Steps.swift +8 -6
  159. package/ios/SuggestAction/SuggestAction.swift +5 -0
  160. package/ios/Swipeable/SwipeCell.swift +10 -6
  161. package/ios/Switch/Switch.swift +22 -20
  162. package/ios/TabView/TabView.swift +102 -21
  163. package/ios/Tag/Tag.swift +15 -4
  164. package/ios/Template/Logo/Logo.swift +2 -1
  165. package/ios/Template/TrustBanner/TrustBanner.swift +109 -55
  166. package/ios/Title/Title.swift +6 -17
  167. package/ios/Tooltip/Tooltip.swift +29 -14
  168. package/ios/Typography/FontScale.swift +17 -14
  169. package/ios/Typography/Text.swift +156 -20
  170. package/ios/Typography/Typography.swift +2 -2
  171. package/ios/Uploader/Uploader.swift +3 -1
  172. package/ios/native-kits.podspec +2 -3
  173. package/ios-demo/MoMoUIKitsDemo.podspec +1 -3
  174. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ContentView.swift +1 -1
  175. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +80 -0
  176. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +2 -0
  177. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +173 -0
  178. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +77 -1
  179. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupDisplayDemo.swift +1 -0
  180. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +2 -2
  181. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +2 -0
  182. package/package.json +1 -1
  183. package/publish.sh +19 -2
  184. package/scripts/gen-ios-icons.mjs +36 -0
  185. package/compose/src/commonMain/kotlin/vn/momo/kits/application/AnimationSearchInput.kt +0 -52
  186. package/compose/src/commonMain/kotlin/vn/momo/kits/application/FloatingButton.kt +0 -183
  187. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Header.kt +0 -207
  188. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderAnimated.kt +0 -39
  189. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderBackground.kt +0 -86
  190. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderDefault.kt +0 -76
  191. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderExtended.kt +0 -76
  192. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderRight.kt +0 -293
  193. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderTitle.kt +0 -33
  194. package/compose/src/commonMain/kotlin/vn/momo/kits/application/NavigationContainer.kt +0 -110
  195. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Screen.kt +0 -378
  196. package/compose/src/commonMain/kotlin/vn/momo/kits/application/useHeaderSearchAnimation.kt +0 -69
  197. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitDesignSystem.kt +0 -18
  198. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitFontScale.kt +0 -22
  199. package/ios/Application/Components.swift +0 -270
  200. package/ios/Application/ComposeApi.swift +0 -22
  201. package/ios/Application/HeaderRight.swift +0 -306
  202. package/ios/Application/Navigation/BottomTab/BottomTab.swift +0 -104
  203. package/ios/Application/Navigation/HeaderBackProps.swift +0 -66
  204. package/ios/Application/Navigation/HeaderTitle.swift +0 -57
  205. package/ios/Application/Navigation/NavigationContainer.swift +0 -213
  206. package/ios/Application/Navigation/NavigationOptions.swift +0 -109
  207. package/ios/Application/Navigation/NavigatorManager.swift +0 -22
  208. package/ios/Application/Navigation/component/Header.swift +0 -178
  209. package/ios/Application/Navigation/component/HeaderBackground.swift +0 -83
  210. package/ios/Application/Navigation/component/HeaderColor.swift +0 -65
  211. package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +0 -11
  212. package/ios/Application/Navigation/component/NavigationFooter.swift +0 -90
  213. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +0 -196
  214. package/ios/Application/Screen.swift +0 -250
  215. package/ios/Application/StackScreen.swift +0 -492
  216. package/ios/Typography/FontScaleStore.swift +0 -58
  217. package/ios/Typography/KitDesignSystem.swift +0 -17
  218. /package/ios/{Application/Navigation → Navigation}/BottomTab/CurvedContainer.swift +0 -0
@@ -1,27 +1,35 @@
1
1
  package vn.momo.kits.navigation.bottomtab
2
2
 
3
+ import androidx.compose.animation.core.Animatable
4
+ import androidx.compose.animation.core.AnimationVector1D
5
+ import androidx.compose.animation.core.Easing
3
6
  import androidx.compose.animation.core.tween
4
- import androidx.compose.animation.fadeIn
5
- import androidx.compose.animation.fadeOut
6
- import androidx.compose.animation.scaleIn
7
- import androidx.compose.animation.scaleOut
8
7
  import androidx.compose.foundation.background
9
8
  import androidx.compose.foundation.layout.*
10
9
  import androidx.compose.runtime.Composable
10
+ import androidx.compose.runtime.CompositionLocalProvider
11
11
  import androidx.compose.runtime.DisposableEffect
12
12
  import androidx.compose.runtime.LaunchedEffect
13
+ import androidx.compose.runtime.getValue
14
+ import androidx.compose.runtime.key
15
+ import androidx.compose.runtime.mutableIntStateOf
16
+ import androidx.compose.runtime.mutableStateOf
13
17
  import androidx.compose.runtime.remember
18
+ import androidx.compose.runtime.saveable.rememberSaveable
19
+ import androidx.compose.runtime.saveable.rememberSaveableStateHolder
20
+ import androidx.compose.runtime.setValue
21
+ import androidx.compose.runtime.staticCompositionLocalOf
14
22
  import androidx.compose.ui.Alignment
15
23
  import androidx.compose.ui.Modifier
24
+ import androidx.compose.ui.graphics.graphicsLayer
25
+ import androidx.compose.ui.layout.layout
16
26
  import androidx.compose.ui.unit.dp
17
- import androidx.navigation.compose.NavHost
18
- import androidx.navigation.compose.composable
19
- import androidx.navigation.compose.currentBackStackEntryAsState
20
- import androidx.navigation.compose.rememberNavController
27
+ import androidx.compose.ui.zIndex
21
28
  import vn.momo.kits.const.AppNavigationBar
22
29
  import vn.momo.kits.const.AppTheme
23
30
  import vn.momo.kits.const.Spacing
24
31
  import vn.momo.kits.navigation.KeyboardOptions
32
+ import vn.momo.kits.navigation.LocalMaxApi
25
33
  import vn.momo.kits.navigation.LocalNavigation
26
34
  import vn.momo.kits.navigation.LocalNavigator
27
35
  import vn.momo.kits.navigation.NavigationOptions
@@ -29,6 +37,21 @@ import vn.momo.kits.navigation.StackScreen
29
37
  import vn.momo.kits.navigation.component.HeaderType
30
38
  import vn.momo.kits.platform.BackHandler
31
39
  import vn.momo.kits.platform.reportFloatingArrowBack
40
+ import vn.momo.kits.layout.getScreenLayout
41
+ import kotlinx.coroutines.launch
42
+ // AI-GENERATED START: use the typed tab haptic pattern
43
+ import vn.momo.maxapi.contract.device.vibration.model.VibrateStyle
44
+ // AI-GENERATED END: use the typed tab haptic pattern
45
+
46
+ private val inOutQuad = Easing { t -> if (t < 0.5f) 2 * t * t else 1 - 2 * (1 - t) * (1 - t) }
47
+ internal val tabTimingSpec = tween<Float>(durationMillis = 200, easing = inOutQuad)
48
+
49
+ internal val LocalBottomTabFocused = staticCompositionLocalOf { true }
50
+
51
+ private val Hidden = Modifier.layout { measurable, constraints ->
52
+ val placeable = measurable.measure(constraints)
53
+ layout(placeable.width, placeable.height) {}
54
+ }
32
55
 
33
56
  private var bottomTabOptionItems : MutableList<NavigationOptions?> = mutableListOf()
34
57
  internal fun setBottomTabOption(index: Int, options: NavigationOptions){
@@ -46,21 +69,21 @@ internal fun getBottomTabOption(index: Int): NavigationOptions? {
46
69
  fun BottomTab(
47
70
  items: List<BottomTabItem>,
48
71
  floatingButton: BottomTabFloatingButton? = null,
72
+ // iPhone Duo: when the side bar can't hold both bars, which one starts compressed;
73
+ // tapping the compressed one swaps them
74
+ compressionBehavior: DuoBarCompressionBehavior = DuoBarCompressionBehavior.TOOLBAR,
49
75
  initialRouteName: String? = null,
50
76
  onTabPress: ((index: Int, switchTab: () -> Unit) -> Unit)? = null,
51
77
  backToInitialTab: Boolean = true,
52
78
  ) {
53
79
  val navigation = LocalNavigation.current
54
80
  val navigator = LocalNavigator.current
55
- val navController = rememberNavController()
56
- val startRoute = remember {
57
- "option${items.indexOfFirst { it.name == initialRouteName }.coerceAtLeast(0)}"
58
- }
59
-
60
- bottomTabOptionItems = items.mapIndexed { index, item ->
61
- item.options ?: NavigationOptions()
62
- }.toMutableList()
81
+ val maxApi = LocalMaxApi.current
82
+ val startIndex = remember { items.indexOfFirst { it.name == initialRouteName }.coerceAtLeast(0) }
63
83
 
84
+ remember(items) {
85
+ bottomTabOptionItems = items.map { it.options ?: NavigationOptions() }.toMutableList()
86
+ }
64
87
 
65
88
  LaunchedEffect(Unit){
66
89
  navigation.markAsBottomTabRoot()
@@ -70,98 +93,124 @@ fun BottomTab(
70
93
  )
71
94
  }
72
95
 
73
- val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
74
- val onStartTab = currentRoute == null || currentRoute == startRoute
75
- val isFirstTab = onStartTab || !backToInitialTab
76
- val backToStartTab = { navController.popBackStack(startRoute, inclusive = false) }
96
+ var selectedIndex by rememberSaveable { mutableIntStateOf(startIndex) }
97
+ var loadedTabs by rememberSaveable { mutableStateOf(listOf(startIndex)) }
98
+ val tabStateHolder = rememberSaveableStateHolder()
99
+ val selectTab: (Int) -> Unit = { index ->
100
+ if (index !in loadedTabs) loadedTabs = loadedTabs + index
101
+ selectedIndex = index
102
+ }
103
+
104
+ val onFirstTab = selectedIndex == 0
105
+ val isFirstTab = onFirstTab || !backToInitialTab
106
+ val backToFirstTab = { selectTab(0) }
107
+
108
+ var lastFocusedIndex by rememberSaveable { mutableStateOf<Int?>(null) }
109
+ val tabProgress = remember { mutableMapOf<Int, Animatable<Float, AnimationVector1D>>() }
110
+ fun progressOf(index: Int) = tabProgress.getOrPut(index) {
111
+ Animatable(if (index == lastFocusedIndex) 1f else 0f)
112
+ }
113
+ LaunchedEffect(selectedIndex) {
114
+ lastFocusedIndex = selectedIndex
115
+ tabProgress.forEach { (index, progress) ->
116
+ if (index != selectedIndex) launch { progress.animateTo(0f, tabTimingSpec) }
117
+ }
118
+ progressOf(selectedIndex).animateTo(1f, tabTimingSpec)
119
+ }
77
120
 
78
121
  DisposableEffect(isFirstTab) {
79
122
  reportFloatingArrowBack(!isFirstTab)
80
- navigator.tabBackHandler = if (isFirstTab) null else ({ backToStartTab() })
81
- // both live outside this composition
123
+ navigator.tabBackHandler = if (isFirstTab) null else ({ backToFirstTab() })
124
+
82
125
  onDispose {
83
126
  reportFloatingArrowBack(false)
84
127
  navigator.tabBackHandler = null
85
128
  }
86
129
  }
87
130
 
131
+ val layout = getScreenLayout()
132
+ val hasQr = floatingButton != null
133
+ val compression = rememberDuoBarCompression(compressionBehavior, items.size, hasQr, layout)
134
+ val railReserve = layout.sideBar?.takeIf { layout.isIPhoneDuo }?.let {
135
+ it.bottom + duoRailHeight(items.size, hasQr, compressed = compression.tabBarCompressed) + DUO_GROUP_GAP
136
+ } ?: 0.dp
137
+ val onTabSelected: (Int) -> Unit = { index ->
138
+ // AI-GENERATED START: preserve the light haptic on tab selection
139
+ runCatching { maxApi?.vibrate(VibrateStyle.LIGHT) }
140
+ // AI-GENERATED END: preserve the light haptic on tab selection
141
+ val switchTab = {
142
+ if (index != selectedIndex) selectTab(index)
143
+ }
144
+ onTabPress?.invoke(index, switchTab) ?: switchTab()
145
+ }
146
+
88
147
  Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) {
89
148
  Box(modifier = Modifier
90
149
  .fillMaxSize()
150
+ .background(AppTheme.current.colors.background.default)
91
151
  ) {
92
- NavHost(
93
- navController = navController,
94
- startDestination = startRoute
152
+ CompositionLocalProvider(
153
+ LocalDuoRailReserve provides railReserve,
154
+ LocalDuoBarCompression provides compression,
95
155
  ) {
96
- items.forEachIndexed { index, item ->
97
- composable(
98
- route = "option$index",
99
- enterTransition = {
100
- fadeIn(animationSpec = tween(200)) +
101
- scaleIn(
102
- initialScale = 0.97f,
103
- animationSpec = tween(200)
104
- )
105
- },
106
- exitTransition = {
107
- fadeOut(animationSpec = tween(200)) +
108
- scaleOut(
109
- targetScale = 0.97f,
110
- animationSpec = tween(200)
111
- )
112
- },
113
- popEnterTransition = {
114
- fadeIn(animationSpec = tween(200)) +
115
- scaleIn(
116
- initialScale = 0.97f,
117
- animationSpec = tween(200)
118
- )
119
- },
120
- popExitTransition = {
121
- fadeOut(animationSpec = tween(200)) +
122
- scaleOut(
123
- targetScale = 0.97f,
124
- animationSpec = tween(200)
156
+ loadedTabs.forEach { index ->
157
+ val item = items.getOrNull(index) ?: return@forEach
158
+ val focused = index == selectedIndex
159
+ val progress = progressOf(index)
160
+ key(index) {
161
+ Box(
162
+ Modifier
163
+ .fillMaxSize()
164
+ .zIndex(if (focused) 0f else -1f)
165
+ .then(if (focused) Modifier.graphicsLayer {
166
+ alpha = 0.3f + 0.7f * progress.value
167
+ scaleX = 0.97f + 0.03f * progress.value
168
+ scaleY = scaleX
169
+ } else Hidden)
170
+ ) {
171
+ CompositionLocalProvider(LocalBottomTabFocused provides focused) {
172
+ tabStateHolder.SaveableStateProvider(index) {
173
+ StackScreen(
174
+ content = item.screen,
175
+ navigationOptions = getBottomTabOption(index),
176
+ bottomTabIndex = index,
177
+ onBackHandler = {
178
+ if (backToInitialTab && index != 0) {
179
+ backToFirstTab()
180
+ } else {
181
+ navigator.pop()
182
+ }
183
+ }
125
184
  )
126
- }
127
- ) {
128
- val option = getBottomTabOption(index)
129
-
130
- StackScreen(
131
- content = item.screen,
132
- navigationOptions = option,
133
- bottomTabIndex = index,
134
- onBackHandler = {
135
- if (backToInitialTab && "option$index" != startRoute) {
136
- backToStartTab()
137
- } else {
138
- navigator.pop()
139
185
  }
140
186
  }
141
- )
187
+ }
142
188
  }
143
189
  }
144
190
  }
145
191
  }
146
- BackHandler(enabled = !onStartTab) {
147
- if (backToInitialTab) backToStartTab() else navigator.pop()
192
+ BackHandler(enabled = !onFirstTab) {
193
+ if (backToInitialTab) backToFirstTab() else navigator.pop()
148
194
  }
149
- Column {
150
- BottomTabBar(
195
+ if (layout.isIPhoneDuo) {
196
+ DuoTabBar(
197
+ layout = layout,
151
198
  items = items,
152
199
  floatingButton = floatingButton,
153
- navController = navController,
154
- onTabSelected = { index ->
155
- val switchTab = {
156
- val targetRoute = "option$index"
157
- if (navController.currentBackStackEntry?.destination?.route != targetRoute) {
158
- navController.navigate(targetRoute)
159
- }
160
- }
161
- onTabPress?.invoke(index, switchTab) ?: switchTab()
162
- }
200
+ selectedIndex = selectedIndex,
201
+ onTabSelected = onTabSelected,
202
+ compression = compression,
163
203
  )
164
- Spacer(modifier = Modifier.fillMaxWidth().height(AppNavigationBar.current + Spacing.S).background(AppTheme.current.colors.background.surface))
204
+ } else {
205
+ Column {
206
+ BottomTabBar(
207
+ items = items,
208
+ floatingButton = floatingButton,
209
+ selectedIndex = selectedIndex,
210
+ onTabSelected = onTabSelected,
211
+ )
212
+ Spacer(modifier = Modifier.fillMaxWidth().height(AppNavigationBar.current + Spacing.S).background(AppTheme.current.colors.background.surface))
213
+ }
165
214
  }
166
215
  }
167
216
  }
@@ -1,15 +1,18 @@
1
1
  package vn.momo.kits.navigation.bottomtab
2
2
 
3
- import androidx.compose.animation.core.FastOutSlowInEasing
4
- import androidx.compose.animation.core.animateDpAsState
5
- import androidx.compose.animation.core.tween
3
+ import androidx.compose.animation.core.Animatable
6
4
  import androidx.compose.foundation.background
7
5
  import androidx.compose.foundation.border
8
6
  import androidx.compose.foundation.layout.*
9
7
  import androidx.compose.foundation.shape.CircleShape
10
8
  import androidx.compose.foundation.shape.RoundedCornerShape
11
9
  import androidx.compose.runtime.Composable
10
+ import androidx.compose.runtime.LaunchedEffect
12
11
  import androidx.compose.runtime.getValue
12
+ import androidx.compose.runtime.mutableIntStateOf
13
+ import androidx.compose.runtime.remember
14
+ import androidx.compose.runtime.saveable.rememberSaveable
15
+ import androidx.compose.runtime.setValue
13
16
  import androidx.compose.ui.Alignment
14
17
  import androidx.compose.ui.Modifier
15
18
  import androidx.compose.ui.draw.clip
@@ -17,18 +20,18 @@ import androidx.compose.ui.graphics.Brush
17
20
  import androidx.compose.ui.graphics.Color
18
21
  import androidx.compose.ui.text.style.TextAlign
19
22
  import androidx.compose.ui.text.style.TextOverflow
23
+ import androidx.compose.ui.unit.Dp
24
+ import androidx.compose.ui.unit.IntOffset
20
25
  import androidx.compose.ui.unit.dp
21
- import androidx.navigation.NavController
22
- import androidx.navigation.compose.currentBackStackEntryAsState
23
26
  import vn.momo.kits.application.IsShowBaseLineDebug
24
27
  import vn.momo.kits.components.*
25
28
  import vn.momo.kits.const.*
26
29
  import vn.momo.kits.modifier.InternalApi
27
30
  import vn.momo.kits.modifier.conditional
28
- import vn.momo.kits.modifier.noFeedbackClickable
29
- import vn.momo.kits.platform.getScreenDimensions
31
+ import vn.momo.kits.modifier.activeOpacityClickable
32
+ import vn.momo.kits.layout.getScreenLayout
33
+ import vn.momo.kits.utils.horizontalSafeAreaPadding
30
34
 
31
- internal val floatingButtonWidth = 75.dp
32
35
  @InternalApi
33
36
  const val BOTTOM_TAB_BAR_HEIGHT = 64
34
37
 
@@ -36,28 +39,24 @@ const val BOTTOM_TAB_BAR_HEIGHT = 64
36
39
  internal fun BottomTabBar(
37
40
  items: List<BottomTabItem>,
38
41
  floatingButton: BottomTabFloatingButton? = null,
39
- navController: NavController,
42
+ selectedIndex: Int,
40
43
  onTabSelected: (Int) -> Unit,
41
44
  ) {
42
- val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
43
- val selectedIndex = items.indexOfFirst { "option${items.indexOf(it)}" == currentRoute }
44
45
 
45
- val screenWidth = getScreenDimensions().width.dp
46
+ val layout = getScreenLayout()
46
47
  val mid = items.size / 2
47
48
 
48
- val tabWidth = if (floatingButton != null) {
49
- (screenWidth - floatingButtonWidth) / items.size
50
- } else {
51
- screenWidth / items.size
52
- }
53
- val adjustSize = if(floatingButton != null && selectedIndex >= mid) floatingButtonWidth else 0.dp
54
- val targetOffset = tabWidth * selectedIndex + tabWidth / 2 - screenWidth / 2 + adjustSize
49
+ val floating = floatingButton?.takeIf { items.size % 2 == 0 }
50
+ val slotWidth = layout.contentWidth / (items.size + if (floating != null) 1 else 0)
51
+ fun slotOf(index: Int) = if (floating != null && index >= mid) index + 1 else index
55
52
 
56
- val indicatorOffsetX by animateDpAsState(
57
- targetValue = targetOffset,
58
- animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing),
59
- label = "IndicatorX"
60
- )
53
+ var indicatorSlot by rememberSaveable { mutableIntStateOf(0) }
54
+ val indicatorX = remember { Animatable(indicatorSlot.toFloat()) }
55
+ LaunchedEffect(selectedIndex) {
56
+ val target = slotOf(selectedIndex)
57
+ indicatorSlot = target
58
+ indicatorX.animateTo(target.toFloat(), tabTimingSpec)
59
+ }
61
60
 
62
61
  Box(
63
62
  contentAlignment = Alignment.BottomCenter
@@ -69,20 +68,21 @@ internal fun BottomTabBar(
69
68
  .background(AppTheme.current.colors.background.surface)
70
69
  .conditional(IsShowBaseLineDebug) {
71
70
  border(1.dp, Colors.blue_03)
72
- },
71
+ }
72
+ .horizontalSafeAreaPadding(),
73
73
  horizontalArrangement = Arrangement.SpaceAround,
74
74
  ) {
75
- if (floatingButton == null) {
75
+ if (floating == null) {
76
76
  RenderTabBarItem(items, 0, items.size, selectedIndex, onTabSelected)
77
77
  } else {
78
78
  RenderTabBarItem(items, 0, mid, selectedIndex, onTabSelected)
79
- Spacer(modifier = Modifier.width(floatingButtonWidth).padding(horizontal = Spacing.XXS))
79
+ Spacer(modifier = Modifier.width(slotWidth))
80
80
  RenderTabBarItem(items, mid, items.size, selectedIndex, onTabSelected)
81
81
  }
82
82
  }
83
83
 
84
84
  Box(modifier = Modifier
85
- .offset(x = indicatorOffsetX, y = (-BOTTOM_TAB_BAR_HEIGHT + 2).dp)
85
+ .offset { IntOffset((layout.insetLeft + slotWidth * (indicatorX.value + 0.5f) - layout.width / 2).roundToPx(), (-BOTTOM_TAB_BAR_HEIGHT + 2).dp.roundToPx()) }
86
86
  .height(2.dp)
87
87
  .width(44.dp)
88
88
  .background(
@@ -101,7 +101,11 @@ internal fun BottomTabBar(
101
101
  )
102
102
  )
103
103
  )
104
- floatingButton?.let { FloatingButton(it) }
104
+ floating?.let {
105
+ Box(Modifier.offset(x = (layout.insetLeft - layout.insetRight) / 2)) {
106
+ FloatingButton(it, slotWidth)
107
+ }
108
+ }
105
109
  }
106
110
  }
107
111
 
@@ -126,17 +130,10 @@ internal fun RowScope.RenderTabBarItem(
126
130
 
127
131
  @Composable
128
132
  internal fun TabBarItem(item: BottomTabItem, selected: Boolean, onClick: () -> Unit) {
129
- fun isNumber(label: String): Boolean {
130
- val numberRegex = "^\\d+$".toRegex()
131
- return numberRegex.matches(label)
132
- }
133
- fun formatLabel(label: String): String? {
134
- if (isNumber(label) && label.toInt() == 0) {
135
- return null
136
- }
137
- return label
138
- }
139
- val badgeLabel = item.badgeLabel?.let { formatLabel(it) }
133
+ // RN parity: "0" hides the badge, "" renders a dot.
134
+ val badgeLabel = item.badgeLabel?.takeUnless { it.toIntOrNull() == 0 }
135
+ val isNumber = badgeLabel?.toIntOrNull() != null
136
+ val showDot = badgeLabel?.isEmpty() ?: item.showDot
140
137
 
141
138
  Box(modifier = Modifier
142
139
  .fillMaxSize()
@@ -144,7 +141,7 @@ internal fun TabBarItem(item: BottomTabItem, selected: Boolean, onClick: () -> U
144
141
  .conditional(IsShowBaseLineDebug) {
145
142
  border(1.dp, Colors.blue_03)
146
143
  }
147
- .noFeedbackClickable {
144
+ .activeOpacityClickable {
148
145
  onClick()
149
146
  },
150
147
  contentAlignment = Alignment.BottomStart
@@ -152,18 +149,22 @@ internal fun TabBarItem(item: BottomTabItem, selected: Boolean, onClick: () -> U
152
149
  Column(
153
150
  modifier = Modifier
154
151
  .fillMaxSize()
155
- .padding(horizontal = Spacing.S, vertical = Spacing.S)
156
- .noFeedbackClickable {
157
- onClick()
158
- },
152
+ .padding(horizontal = Spacing.S, vertical = Spacing.S),
159
153
  horizontalAlignment = Alignment.CenterHorizontally,
160
154
  verticalArrangement = Arrangement.Center
161
155
  ) {
162
- Icon(
163
- source = item.icon,
164
- size = 28.dp,
165
- modifier = Modifier.padding(Spacing.XS),
166
- color = if (selected) AppTheme.current.colors.primary else AppTheme.current.colors.text.hint)
156
+ Box(modifier = Modifier.padding(Spacing.XS)) {
157
+ Icon(
158
+ source = item.icon,
159
+ size = 28.dp,
160
+ color = if (selected) AppTheme.current.colors.primary else AppTheme.current.colors.text.hint)
161
+ val badgeAnchor = Modifier.matchParentSize().wrapContentSize(Alignment.TopEnd, unbounded = true)
162
+ if (showDot) {
163
+ BadgeDot(size = DotSize.Small, modifier = badgeAnchor.offset(x = 4.dp, y = (-4).dp))
164
+ } else if (!badgeLabel.isNullOrEmpty()) {
165
+ Badge(badgeLabel, modifier = badgeAnchor.offset(x = if (isNumber) 8.dp else 12.dp, y = (-8).dp))
166
+ }
167
+ }
167
168
  Text(
168
169
  text = item.label,
169
170
  color = if (selected) AppTheme.current.colors.primary else AppTheme.current.colors.text.hint,
@@ -172,28 +173,19 @@ internal fun TabBarItem(item: BottomTabItem, selected: Boolean, onClick: () -> U
172
173
  overflow = TextOverflow.Ellipsis
173
174
  )
174
175
  }
175
- if (badgeLabel != null) {
176
- if (badgeLabel.isEmpty())
177
- BadgeDot(
178
- size = DotSize.Small,
179
- modifier = Modifier.offset(x = 50.dp, y = (-48).dp)
180
- )
181
- else
182
- Badge(badgeLabel, modifier = Modifier.offset(x = 44.dp, y = (-42).dp))
183
- }
184
176
  }
185
177
  }
186
178
 
187
179
  @Composable
188
- internal fun FloatingButton(data: BottomTabFloatingButton) {
180
+ internal fun FloatingButton(data: BottomTabFloatingButton, width: Dp) {
189
181
  Column(
190
182
  modifier = Modifier
191
- .width(floatingButtonWidth)
183
+ .width(width)
192
184
  .padding(horizontal = Spacing.XXS, vertical = Spacing.S)
193
185
  .conditional(IsShowBaseLineDebug) {
194
186
  border(1.dp, Colors.blue_03)
195
187
  }
196
- .noFeedbackClickable {
188
+ .activeOpacityClickable {
197
189
  data.onPress()
198
190
  },
199
191
  verticalArrangement = Arrangement.Bottom,
@@ -0,0 +1,97 @@
1
+ package vn.momo.kits.navigation.bottomtab
2
+
3
+ import androidx.compose.runtime.Composable
4
+ import androidx.compose.runtime.Stable
5
+ import androidx.compose.runtime.compositionLocalOf
6
+ import androidx.compose.runtime.getValue
7
+ import androidx.compose.runtime.mutableStateOf
8
+ import androidx.compose.runtime.remember
9
+ import androidx.compose.runtime.setValue
10
+ import androidx.compose.ui.unit.Dp
11
+ import vn.momo.kits.const.Spacing
12
+ import vn.momo.kits.layout.DuoMetrics
13
+ import vn.momo.kits.layout.ScreenLayout
14
+
15
+ // iPhone Duo: which side bar bar starts compressed when the strip cannot hold both whole.
16
+ enum class DuoBarCompressionBehavior { TOOLBAR, TAB_BAR }
17
+
18
+ private enum class DuoBar { TOOLBAR, TAB_BAR }
19
+
20
+ @Stable
21
+ internal class DuoBarCompression(
22
+ val toolbarCompressed: Boolean = false,
23
+ val tabBarCompressed: Boolean = false,
24
+ // the open rail needs the toolbar button slot too: only Back stays
25
+ val toolbarHidden: Boolean = false,
26
+ val expandToolbar: () -> Unit = {},
27
+ val expandTabBar: () -> Unit = {},
28
+ // a rail opened from the compressed state closes once a tab is picked
29
+ val onTabSelected: () -> Unit = {},
30
+ // side bar columns report their full height so the tab bar knows what is left
31
+ val setToolbarHeight: (Dp) -> Unit = {},
32
+ )
33
+
34
+ internal val LocalDuoBarCompression = compositionLocalOf { DuoBarCompression() }
35
+
36
+ // space kept between the top and the bottom groups (HIG Vertical controls)
37
+ internal val DUO_GROUP_GAP = Spacing.M
38
+ // Back, then the right toolkit: help + home capsule
39
+ private val DEFAULT_TOOLBAR_HEIGHT = DuoMetrics.controlSize + Spacing.M + DuoMetrics.itemPitch * 2
40
+ // Back, then the single button the toolbar folds into
41
+ private val COMPRESSED_TOOLBAR_HEIGHT = DuoMetrics.controlSize * 2 + Spacing.M
42
+
43
+ // Port of RN DuoBarCompression (HIG, Designing for iPhone Duo: Vertical controls):
44
+ // - both fit: both stay whole
45
+ // - one fits whole beside the other compressed: one is compressed, tapping it swaps them
46
+ // - otherwise both rest compressed; tapping one opens it for a moment (the rail closes
47
+ // again once a tab is picked)
48
+ @Composable
49
+ internal fun rememberDuoBarCompression(
50
+ behavior: DuoBarCompressionBehavior,
51
+ tabCount: Int,
52
+ hasQr: Boolean,
53
+ layout: ScreenLayout,
54
+ ): DuoBarCompression {
55
+ var toolbarHeight by remember { mutableStateOf(DEFAULT_TOOLBAR_HEIGHT) }
56
+ var expanded by remember(behavior) {
57
+ mutableStateOf(if (behavior == DuoBarCompressionBehavior.TOOLBAR) DuoBar.TAB_BAR else DuoBar.TOOLBAR)
58
+ }
59
+ var opened by remember { mutableStateOf<DuoBar?>(null) }
60
+ val setToolbarHeight = remember { { height: Dp -> toolbarHeight = height } }
61
+ val sideBar = layout.sideBar
62
+
63
+ return remember(sideBar, layout.height, tabCount, hasQr, toolbarHeight, expanded, opened) {
64
+ if (sideBar == null) return@remember DuoBarCompression()
65
+ val room = layout.height - sideBar.bottom - sideBar.top - DUO_GROUP_GAP
66
+ val tabBar = duoRailHeight(tabCount, hasQr)
67
+ val compressedTabBar = duoRailHeight(1, hasQr, compressed = true)
68
+ fun fits(toolbar: Dp, tabs: Dp) = toolbar + tabs <= room
69
+
70
+ when {
71
+ fits(toolbarHeight, tabBar) -> DuoBarCompression(setToolbarHeight = setToolbarHeight)
72
+ fits(COMPRESSED_TOOLBAR_HEIGHT, tabBar) && fits(toolbarHeight, compressedTabBar) -> DuoBarCompression(
73
+ toolbarCompressed = expanded == DuoBar.TAB_BAR,
74
+ tabBarCompressed = expanded == DuoBar.TOOLBAR,
75
+ expandToolbar = { expanded = DuoBar.TOOLBAR },
76
+ expandTabBar = { expanded = DuoBar.TAB_BAR },
77
+ setToolbarHeight = setToolbarHeight,
78
+ )
79
+ // an opened rail always shows, even when only the group gap is short: it closes once a tab is picked
80
+ opened == DuoBar.TAB_BAR -> DuoBarCompression(
81
+ toolbarCompressed = true,
82
+ toolbarHidden = true,
83
+ expandToolbar = { opened = DuoBar.TOOLBAR },
84
+ expandTabBar = { opened = DuoBar.TAB_BAR },
85
+ onTabSelected = { opened = null },
86
+ setToolbarHeight = setToolbarHeight,
87
+ )
88
+ else -> DuoBarCompression(
89
+ toolbarCompressed = opened != DuoBar.TOOLBAR,
90
+ tabBarCompressed = true,
91
+ expandToolbar = { opened = DuoBar.TOOLBAR },
92
+ expandTabBar = { opened = DuoBar.TAB_BAR },
93
+ setToolbarHeight = setToolbarHeight,
94
+ )
95
+ }
96
+ }
97
+ }