@momo-kits/native-kits 0.300.3 → 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 (272) hide show
  1. package/CLAUDE.md +51 -7
  2. package/README.md +2 -2
  3. package/build.gradle.kts +13 -0
  4. package/compose/build.gradle.kts +197 -0
  5. package/compose/build.gradle.kts.backup +197 -0
  6. package/compose/compose.podspec +47 -0
  7. package/compose/src/androidMain/kotlin/vn/momo/kits/const/HostFont.android.kt +20 -0
  8. package/compose/src/androidMain/kotlin/vn/momo/kits/navigation/ScrollToTop.android.kt +6 -0
  9. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/BackGesture.android.kt +12 -0
  10. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/OsFontScale.android.kt +7 -0
  11. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +153 -0
  12. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +6 -0
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +110 -0
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/application/DesignSystemKits.kt +62 -0
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +1071 -0
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Localize.kt +269 -0
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/application/WidgetContainer.kt +65 -0
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Avatar.kt +157 -0
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +81 -0
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeDot.kt +32 -0
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeRibbon.kt +338 -0
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BaselineView.kt +175 -0
  23. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +357 -0
  24. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +115 -0
  25. package/compose/src/commonMain/kotlin/vn/momo/kits/components/CheckBox.kt +89 -0
  26. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +128 -0
  27. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Collapse.kt +209 -0
  28. package/compose/src/commonMain/kotlin/vn/momo/kits/components/CupertinoOverscroll.kt +542 -0
  29. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +49 -0
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +86 -0
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/components/IconButton.kt +125 -0
  32. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Image.kt +231 -0
  33. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +189 -0
  34. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +478 -0
  35. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputDropDown.kt +114 -0
  36. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +265 -0
  37. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +235 -0
  38. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +223 -0
  39. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +265 -0
  40. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +185 -0
  41. package/compose/src/commonMain/kotlin/vn/momo/kits/components/LazyColumnWithBouncing.kt +343 -0
  42. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Loader.kt +108 -0
  43. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationDot.kt +56 -0
  44. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationNumber.kt +41 -0
  45. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationScroll.kt +84 -0
  46. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationWhiteDot.kt +40 -0
  47. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +361 -0
  48. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +108 -0
  49. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +308 -0
  50. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +59 -0
  51. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Rating.kt +87 -0
  52. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ScaleSizeScope.kt +21 -0
  53. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Skeleton.kt +90 -0
  54. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +330 -0
  55. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Stepper.kt +220 -0
  56. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Steps.kt +473 -0
  57. package/compose/src/commonMain/kotlin/vn/momo/kits/components/SuggestAction.kt +122 -0
  58. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +207 -0
  59. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +92 -0
  60. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +560 -0
  61. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +86 -0
  62. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +125 -0
  63. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Title.kt +207 -0
  64. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +501 -0
  65. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TrustBanner.kt +176 -0
  66. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Uploader.kt +182 -0
  67. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePicker.kt +193 -0
  68. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerTypes.kt +29 -0
  69. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +236 -0
  70. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +180 -0
  71. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Colors.kt +325 -0
  72. package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +17 -0
  73. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Radius.kt +12 -0
  74. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Spacing.kt +16 -0
  75. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Theme.kt +194 -0
  76. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +287 -0
  77. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Card.kt +54 -0
  78. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +50 -0
  79. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +306 -0
  80. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Section.kt +41 -0
  81. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/AutomationId.kt +52 -0
  82. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Clickable.kt +66 -0
  83. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Conditional.kt +11 -0
  84. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/DeprecatedModifier.kt +104 -0
  85. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Shadow.kt +48 -0
  86. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Size.kt +51 -0
  87. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +338 -0
  88. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/HostBackGestureBridge.kt +58 -0
  89. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +137 -0
  90. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +116 -0
  91. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +215 -0
  92. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +404 -0
  93. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ScrollToTop.kt +8 -0
  94. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +732 -0
  95. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackTransition.kt +148 -0
  96. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/SwipeBackHaptic.kt +40 -0
  97. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +233 -0
  98. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +217 -0
  99. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/CurvedContainer.kt +86 -0
  100. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +97 -0
  101. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +377 -0
  102. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/FloatingButton.kt +169 -0
  103. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +455 -0
  104. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +103 -0
  105. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +106 -0
  106. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +74 -0
  107. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +413 -0
  108. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +39 -0
  109. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +363 -0
  110. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HelpCenterRequest.kt +37 -0
  111. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +192 -0
  112. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +133 -0
  113. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/ToolkitRequests.kt +41 -0
  114. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/tracking/ScreenTracker.kt +135 -0
  115. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/BackGesture.kt +12 -0
  116. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ComposeLottieAnimation.kt +91 -0
  117. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/OsFontScale.kt +9 -0
  118. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +74 -0
  119. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +20 -0
  120. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Icons.kt +1329 -0
  121. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Resources.kt +55 -0
  122. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Tracking.kt +15 -0
  123. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +118 -0
  124. package/compose/src/commonTest/kotlin/vn/momo/kits/navigation/component/HelpCenterRequestTest.kt +44 -0
  125. package/compose/src/commonTest/kotlin/vn/momo/kits/navigation/component/ToolkitRequestsTest.kt +62 -0
  126. package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +42 -0
  127. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/HostBackGesture.ios.kt +45 -0
  128. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/ScrollToTop.ios.kt +36 -0
  129. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/BackGesture.ios.kt +38 -0
  130. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/OsFontScale.ios.kt +66 -0
  131. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +217 -0
  132. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +138 -0
  133. package/gradle/libs.versions.toml +68 -0
  134. package/gradle/wrapper/gradle-wrapper.jar +0 -0
  135. package/gradle/wrapper/gradle-wrapper.properties +8 -0
  136. package/gradle.properties +26 -0
  137. package/gradlew +252 -0
  138. package/gradlew.bat +94 -0
  139. package/ios/Application/ApplicationEnvironment.swift +24 -6
  140. package/ios/Application/DesignSystemKits.swift +100 -0
  141. package/ios/Application/DesignSystemKitsPayload.swift +68 -0
  142. package/ios/Application/MaxApi.swift +30 -3
  143. package/ios/Avatar/Avatar.swift +4 -3
  144. package/ios/Badge/Badge.swift +6 -4
  145. package/ios/Badge/BadgeDot.swift +1 -1
  146. package/ios/Badge/BadgeRibbon.swift +8 -2
  147. package/ios/Button/Button.swift +15 -12
  148. package/ios/CalculatorKeyboard/CalculatorKeyboard.swift +64 -18
  149. package/ios/Carousel/Carousel.swift +29 -12
  150. package/ios/Checkbox/Checkbox.swift +7 -3
  151. package/ios/Chip/Chip.swift +5 -4
  152. package/ios/Collapse/Collapse.swift +70 -19
  153. package/ios/Colors+Radius+Spacing/Colors.swift +10 -1
  154. package/ios/Colors+Radius+Spacing/Radius.swift +6 -0
  155. package/ios/DateTimePicker/DateTimePicker.swift +54 -20
  156. package/ios/DateTimePicker/WheelPicker.swift +103 -105
  157. package/ios/Divider/Divider.swift +37 -4
  158. package/ios/Extensions/Color++.swift +38 -9
  159. package/ios/Extensions/OptionalEnvironmentObject.swift +12 -0
  160. package/ios/Extensions/SkeletonUICompat.swift +11 -0
  161. package/ios/Icon/Icon.swift +21 -23
  162. package/ios/Icon/Icons.swift +1329 -0
  163. package/ios/IconButton/IconButton.swift +15 -7
  164. package/ios/Image/Image.swift +7 -23
  165. package/ios/Information/Information.swift +27 -9
  166. package/ios/Input/ErrorView.swift +2 -2
  167. package/ios/Input/Input.swift +171 -148
  168. package/ios/Input/InputPhoneNumber.swift +10 -9
  169. package/ios/Input/InputSearch.swift +107 -78
  170. package/ios/Input/InputTextArea.swift +133 -148
  171. package/ios/InputDropDown/InputDropDown.swift +10 -7
  172. package/ios/InputMoney/InputMoney.swift +32 -11
  173. package/ios/InputOTP/InputOTP.swift +22 -12
  174. package/ios/Layout/Card.swift +50 -0
  175. package/ios/Layout/GridContext.swift +187 -3
  176. package/ios/Layout/Item.swift +5 -4
  177. package/ios/Layout/ScreenLayout.swift +415 -0
  178. package/ios/Layout/Section.swift +4 -100
  179. package/ios/Loader/Loader.swift +5 -9
  180. package/ios/Lottie/LottieView.swift +14 -2
  181. package/ios/{Application/Navigation/Overplay → Navigation}/BottomSheet.swift +169 -29
  182. package/ios/Navigation/BottomTab/BottomTab.swift +332 -0
  183. package/ios/{Application/Navigation → Navigation}/BottomTab/BottomTabBar.swift +18 -7
  184. package/ios/Navigation/BottomTab/DuoBarCompression.swift +104 -0
  185. package/ios/Navigation/BottomTab/DuoTabBar.swift +329 -0
  186. package/ios/{Application → Navigation/Component}/FloatingButton.swift +43 -54
  187. package/ios/Navigation/Component/Header.swift +545 -0
  188. package/ios/Navigation/Component/HeaderBackground.swift +154 -0
  189. package/ios/Navigation/Component/HeaderJourney.swift +73 -0
  190. package/ios/Navigation/Component/HeaderLocation.swift +48 -0
  191. package/ios/Navigation/Component/HeaderRight.swift +425 -0
  192. package/ios/Navigation/Component/HeaderTitle.swift +36 -0
  193. package/ios/{Application/Navigation → Navigation/Component}/HeaderUser.swift +21 -24
  194. package/ios/Navigation/Component/SideBar.swift +183 -0
  195. package/ios/{Application/Navigation/Overplay → Navigation/Component}/SnackBar.swift +8 -8
  196. package/ios/Navigation/HostBackGestureBridge.swift +78 -0
  197. package/ios/{Application/Navigation/Overplay → Navigation}/ModalScreen.swift +9 -2
  198. package/ios/Navigation/Navigation.swift +204 -0
  199. package/ios/Navigation/NavigationContainer.swift +195 -0
  200. package/ios/{Application/Navigation → Navigation}/Navigator.swift +207 -107
  201. package/ios/Navigation/StackScreen.swift +930 -0
  202. package/ios/Navigation/StackTransition.swift +400 -0
  203. package/ios/Navigation/SwipeBackHaptic.swift +33 -0
  204. package/ios/{Application/Navigation → Navigation}/Tracking/ScreenTracker.swift +12 -5
  205. package/ios/OTPKeyboard/OTPKeyboard.swift +8 -11
  206. package/ios/Pagination/PaginationScroll.swift +2 -2
  207. package/ios/Popup/PopupDisplay.swift +72 -135
  208. package/ios/Popup/PopupInput.swift +10 -13
  209. package/ios/Popup/PopupLayout.swift +138 -0
  210. package/ios/Popup/PopupNotify.swift +59 -69
  211. package/ios/Popup/PopupPromotion.swift +31 -47
  212. package/ios/PopupView/FullscreenPopup.swift +13 -19
  213. package/ios/PopupView/PopupView.swift +1 -1
  214. package/ios/PopupView/Utils++.swift +0 -8
  215. package/ios/ProgressInfo/ProgressInfo.swift +4 -2
  216. package/ios/Radio/Radio.swift +2 -1
  217. package/ios/Rating/Rating.swift +9 -1
  218. package/ios/Resources/dot_loading.json +558 -0
  219. package/ios/Resources/lottie_circle_loader.json +1 -0
  220. package/ios/ScrollIndicator/ScrollIndicator.swift +11 -10
  221. package/ios/Skeleton/Skeleton.swift +22 -27
  222. package/ios/Slider/Slider.swift +16 -18
  223. package/ios/Stepper/Stepper.swift +7 -3
  224. package/ios/Steps/Steps.swift +8 -6
  225. package/ios/SuggestAction/SuggestAction.swift +5 -0
  226. package/ios/Swipeable/SwipeCell.swift +10 -6
  227. package/ios/Switch/Switch.swift +22 -20
  228. package/ios/TabView/TabView.swift +102 -21
  229. package/ios/Tag/Tag.swift +15 -4
  230. package/ios/Template/Logo/Logo.swift +2 -1
  231. package/ios/Template/TrustBanner/TrustBanner.swift +109 -55
  232. package/ios/Title/Title.swift +6 -17
  233. package/ios/Tooltip/Tooltip.swift +29 -14
  234. package/ios/Typography/FontScale.swift +17 -14
  235. package/ios/Typography/Text.swift +156 -20
  236. package/ios/Typography/Typography.swift +2 -2
  237. package/ios/Uploader/Uploader.swift +3 -1
  238. package/ios/native-kits.podspec +2 -3
  239. package/ios-demo/MoMoUIKitsDemo.podspec +1 -3
  240. package/ios-demo/README.md +120 -0
  241. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ContentView.swift +1 -1
  242. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +80 -0
  243. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +2 -0
  244. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +173 -0
  245. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +77 -1
  246. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupDisplayDemo.swift +1 -0
  247. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +2 -2
  248. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +2 -0
  249. package/package.json +1 -1
  250. package/publish.sh +19 -2
  251. package/scripts/gen-ios-icons.mjs +36 -0
  252. package/settings.gradle.kts +64 -0
  253. package/ios/Application/Components.swift +0 -270
  254. package/ios/Application/ComposeApi.swift +0 -22
  255. package/ios/Application/HeaderRight.swift +0 -306
  256. package/ios/Application/Navigation/BottomTab/BottomTab.swift +0 -104
  257. package/ios/Application/Navigation/HeaderBackProps.swift +0 -66
  258. package/ios/Application/Navigation/HeaderTitle.swift +0 -57
  259. package/ios/Application/Navigation/NavigationContainer.swift +0 -213
  260. package/ios/Application/Navigation/NavigationOptions.swift +0 -109
  261. package/ios/Application/Navigation/NavigatorManager.swift +0 -22
  262. package/ios/Application/Navigation/component/Header.swift +0 -178
  263. package/ios/Application/Navigation/component/HeaderBackground.swift +0 -83
  264. package/ios/Application/Navigation/component/HeaderColor.swift +0 -65
  265. package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +0 -11
  266. package/ios/Application/Navigation/component/NavigationFooter.swift +0 -90
  267. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +0 -196
  268. package/ios/Application/Screen.swift +0 -250
  269. package/ios/Application/StackScreen.swift +0 -492
  270. package/ios/Typography/FontScaleStore.swift +0 -58
  271. package/ios/Typography/KitDesignSystem.swift +0 -17
  272. /package/ios/{Application/Navigation → Navigation}/BottomTab/CurvedContainer.swift +0 -0
@@ -0,0 +1,930 @@
1
+ //
2
+ // StackScreen.swift
3
+ // MoMoUIKits
4
+ //
5
+ // SwiftUI mirror of Compose's `navigation/StackScreen.kt`: header layer, scrollable content
6
+ // (`StackScreenContent`), animated search, floating button, footer (`NavigationFooter`) and the
7
+ // iPhone Duo side bar.
8
+ //
9
+
10
+ import SwiftUI
11
+
12
+ public struct StackScreen<Content: View>: View {
13
+ // MARK: - Properties
14
+
15
+ var backgroundColor: Color?
16
+ // AI-GENERATED START: custom screen background view (Compose backgroundComponent)
17
+ var background: (() -> AnyView)?
18
+ // AI-GENERATED END: custom screen background view (Compose backgroundComponent)
19
+ var headerTransparent: Bool = false
20
+ var fullScreenContent: Bool = false
21
+ var tintColor: Color? = Colors.black17
22
+ var isBack: Bool
23
+ var headerType: HeaderType
24
+ var useAnimated: Bool = false
25
+ var verticalAlignment: VerticalAlignment
26
+ var horizontalAlignment: HorizontalAlignment
27
+ var title: String
28
+ var headerTitle: HeaderTitle?
29
+ var titlePosition: TitlePosition
30
+ var goBack: (() -> Void)?
31
+ var scrollable: Bool
32
+ var scrollToTopCallback: (() -> Void)?
33
+ var onContentLayout: ((CGRect) -> Void)?
34
+ var useAvoidKeyboard: Bool
35
+ var keyboardShouldPersistTaps: Bool
36
+ var footer: (() -> AnyView)?
37
+ var footerTransparent: Bool
38
+ var footerUseMargin: Bool
39
+ var headerRight: (() -> any View)? = { HeaderRight() }
40
+ var animatedHeader: AnimatedHeader? = nil
41
+ var layoutOffset: CGFloat
42
+ var inputSearchProps: InputSearchProps?
43
+ var fabProps: FabProps?
44
+ var screenName: String = ""
45
+ var content: () -> Content
46
+
47
+ public init(
48
+ backgroundColor: Color? = nil,
49
+ // AI-GENERATED START: custom screen background view (Compose backgroundComponent)
50
+ background: (() -> AnyView)? = nil,
51
+ // AI-GENERATED END: custom screen background view (Compose backgroundComponent)
52
+ headerTransparent: Bool = false,
53
+ fullScreenContent: Bool = false,
54
+ tintColor: Color = Colors.black17,
55
+ isBack: Bool = true,
56
+ headerType: HeaderType = .default,
57
+ useAnimated: Bool = false,
58
+ verticalAlignment: VerticalAlignment = .top,
59
+ horizontalAlignment: HorizontalAlignment = .leading,
60
+ title: String = "Stack",
61
+ headerTitle: HeaderTitle? = nil,
62
+ titlePosition: TitlePosition = .left,
63
+ goBack: (() -> Void)? = nil,
64
+ scrollable: Bool = true,
65
+ scrollToTopCallback: (() -> Void)? = nil,
66
+ onContentLayout: ((CGRect) -> Void)? = nil,
67
+ useAvoidKeyboard: Bool = true,
68
+ keyboardShouldPersistTaps: Bool = false,
69
+ footer: (() -> AnyView)? = nil,
70
+ footerTransparent: Bool = false,
71
+ footerUseMargin: Bool = true,
72
+ headerRight: (() -> any View)? = { HeaderRight() },
73
+ animatedHeader: AnimatedHeader? = nil,
74
+ layoutOffset: CGFloat = 56,
75
+ inputSearchProps: InputSearchProps? = nil,
76
+ fabProps: FabProps? = nil,
77
+ screenName: String = "",
78
+ @ViewBuilder content: @escaping () -> Content
79
+ ) {
80
+ self.backgroundColor = backgroundColor
81
+ // AI-GENERATED START: custom screen background view (Compose backgroundComponent)
82
+ self.background = background
83
+ // AI-GENERATED END: custom screen background view (Compose backgroundComponent)
84
+ self.headerTransparent = headerTransparent
85
+ self.fullScreenContent = fullScreenContent
86
+ self.tintColor = tintColor
87
+ self.isBack = isBack
88
+ self.headerType = headerType
89
+ self.useAnimated = useAnimated
90
+ self.verticalAlignment = verticalAlignment
91
+ self.horizontalAlignment = horizontalAlignment
92
+ self.title = title
93
+ self.headerTitle = headerTitle
94
+ self.titlePosition = titlePosition
95
+ self.goBack = goBack
96
+ self.scrollable = scrollable
97
+ self.scrollToTopCallback = scrollToTopCallback
98
+ self.onContentLayout = onContentLayout
99
+ self.useAvoidKeyboard = useAvoidKeyboard
100
+ self.keyboardShouldPersistTaps = keyboardShouldPersistTaps
101
+ self.footer = footer
102
+ self.footerTransparent = footerTransparent
103
+ self.footerUseMargin = footerUseMargin
104
+ self.headerRight = headerRight
105
+ self.animatedHeader = animatedHeader
106
+ self.layoutOffset = layoutOffset
107
+ self.inputSearchProps = inputSearchProps
108
+ self.fabProps = fabProps
109
+ self.screenName = screenName
110
+ self.content = content
111
+ }
112
+
113
+ @State private var scrollOffset: CGFloat = 0
114
+ @State private var keyboardHeight: CGFloat = 0
115
+ @State private var headerRightWidth: CGFloat = 0
116
+ @State private var isBottomTabRoot: Bool = false
117
+ @State private var headerHide: CGFloat = 0
118
+ @State private var lastScrollOffset: CGFloat = 0
119
+ @State private var footerHeight: CGFloat = 0
120
+
121
+ @Environment(\.applicationEnvironment) private var appEnv
122
+ @Environment(\.screenLayout) private var layout
123
+ @Environment(\.duoTabBarInsets) private var duoTabBarInsets
124
+ @Environment(\.duoBarCompression) private var compression
125
+ @Environment(\.appTheme) private var theme
126
+ @OptionalEnvironmentObject private var navigation: Navigation?
127
+
128
+ @State private var tracking: Task<Void, Never>?
129
+
130
+ /// Compose's `headerOpacity()`: 0 over a banner or transparent header, 1 once the bar is opaque.
131
+ private var opacity: CGFloat {
132
+ headerOpacity(
133
+ scrollOffset: scrollOffset,
134
+ isAnimated: animatedHeader != nil,
135
+ layoutOffset: layoutOffset,
136
+ transparent: headerTransparent
137
+ )
138
+ }
139
+
140
+ private var headerColor: HeaderColor {
141
+ getHeaderColor(
142
+ isAnimated: animatedHeader != nil,
143
+ isSurface: animatedHeader?.isSurface ?? true,
144
+ opacity: opacity,
145
+ tintColor: tintColor
146
+ )
147
+ }
148
+
149
+ // MARK: - Search header (Compose's getInputSearchType / SearchAnimated)
150
+
151
+ private enum InputSearchType { case none, header, animated }
152
+
153
+ private var inputSearchType: InputSearchType {
154
+ guard inputSearchProps != nil, headerType != .none else { return .none }
155
+ return useAnimated ? .animated : .header
156
+ }
157
+
158
+ private var toolkit: (data: HeaderRightData?, tintColor: Color?)? { toolkitHeaderRight(headerRight) }
159
+
160
+ private var showsSideBar: Bool {
161
+ layout.sideBar != nil && headerType != .none && !isBottomTabRoot && (goBack != nil || toolkit != nil)
162
+ }
163
+
164
+ /// `HeaderSideBar` reports its own height; an empty column reports 0, or the rail keeps the previous tab's.
165
+ private func reportEmptySideBar() {
166
+ if layout.sideBar != nil && !showsSideBar { compression.setToolbarHeight(0) }
167
+ }
168
+
169
+ /// iPhone Duo: content scrolls under a glass header instead of starting below it.
170
+ private var useDuoGlass: Bool {
171
+ layout.sideBar != nil && headerType != .none && animatedHeader == nil
172
+ && inputSearchProps == nil && !headerTransparent && !fullScreenContent
173
+ }
174
+
175
+ /// iPhone Duo: the header leaves with the content and slides back in on a reverse scroll
176
+ /// (RN DuoQuickReturnHeader). The side bar is drawn apart from the header, so it stays put.
177
+ private var useQuickReturn: Bool {
178
+ layout.sideBar != nil && scrollable && (useDuoGlass || animatedHeader != nil)
179
+ }
180
+
181
+ private func updateHeaderHide(_ offset: CGFloat) {
182
+ let delta = offset - lastScrollOffset
183
+ lastScrollOffset = offset
184
+ guard useQuickReturn, offset > 0 else {
185
+ if headerHide != 0 { headerHide = 0 }
186
+ return
187
+ }
188
+ headerHide = min(max(headerHide + delta, 0), layout.headerHeight)
189
+ }
190
+
191
+ /// A half-shown header settles fully in or out once the scroll comes to rest.
192
+ private func settleHeader() {
193
+ let height = layout.headerHeight
194
+ // within the first header height the header still sits on the content
195
+ guard useQuickReturn, scrollOffset >= height, headerHide > 0, headerHide < height else { return }
196
+ withAnimation(.easeOut(duration: 0.2)) {
197
+ headerHide = headerHide > height / 2 ? height : 0
198
+ }
199
+ }
200
+
201
+ /// Compose snaps the animated search fully in or out when the scroll stops.
202
+ private var searchSnapDistance: CGFloat? { inputSearchType == .animated ? HEADER_HEIGHT : nil }
203
+
204
+ private var keyboardSize: CGFloat { useAvoidKeyboard ? max(keyboardHeight, 0) : 0 }
205
+
206
+ /// What the footer, tab bar or home indicator takes from the bottom of the window, gap included.
207
+ private var bottomBarsHeight: CGFloat {
208
+ let bars = footer != nil ? footerHeight + NavigationFooter.safeAreaBottom : NavigationFooter.navigationBarInset
209
+ return max(bars + Spacing.XL, duoTabBarInsets.sideBarReserve)
210
+ }
211
+
212
+ /// Top of the footer (or the navigation bar) from the window bottom; snack bars sit above it.
213
+ private var bottomObstruction: CGFloat {
214
+ guard footer != nil else { return keyboardSize + NavigationFooter.navigationBarInset }
215
+ return footerHeight + NavigationFooter.safeAreaBottom - NavigationFooter.verticalOffset(keyboardSize: keyboardSize)
216
+ }
217
+
218
+ private func reportBottomObstruction() {
219
+ // bottom tab children leave overplays to the screen hosting the tabs, as in Compose
220
+ guard let navigation, navigation.bottomTabIndex == -1 else { return }
221
+ navigation.bottomObstruction = bottomObstruction
222
+ }
223
+
224
+ /// Keyboard frames are in screen coordinates; UIScreen stays on the outer display on iPhone Duo.
225
+ private var keyboardReferenceHeight: CGFloat {
226
+ layout.isIPhoneDuo ? layout.height : UIScreen.main.bounds.height
227
+ }
228
+
229
+ /// Room for the animated search below the header at scroll 0; shrinks as the bar slides up.
230
+ private var animatedSearchInset: CGFloat {
231
+ guard inputSearchType == .animated else { return 0 }
232
+ let scroll = max(scrollOffset, 0)
233
+ return min(max(HEADER_HEIGHT - scroll, 0), HEADER_HEIGHT)
234
+ }
235
+
236
+ /// Title fades out as the animated search bar rises into the header.
237
+ private var titleOpacity: CGFloat {
238
+ guard inputSearchType == .animated else { return 1 }
239
+ let scroll = max(scrollOffset, 0)
240
+ return 1 - min(max(scroll / HEADER_HEIGHT, 0), 1)
241
+ }
242
+
243
+ // MARK: - Body
244
+
245
+ public var body: some View {
246
+ GeometryReader { _ in
247
+ ZStack(alignment: .top) {
248
+ // AI-GENERATED START: custom screen background view (Compose backgroundComponent)
249
+ Group {
250
+ if let background {
251
+ background()
252
+ } else {
253
+ backgroundColor ?? theme.colors.background.default
254
+ }
255
+ }
256
+ .edgesIgnoringSafeArea(.all)
257
+ // AI-GENERATED END: custom screen background view (Compose backgroundComponent)
258
+
259
+ VStack(spacing: 0) {
260
+ ZStack(alignment: fabProps?.position == .center ? .bottom : .bottomTrailing) {
261
+ VStack(alignment: horizontalAlignment, spacing: 0) {
262
+ headerAndContent
263
+ }
264
+
265
+ FloatingContent(
266
+ fabProps: fabProps,
267
+ keyboardSize: keyboardSize,
268
+ hasFooter: footer != nil,
269
+ scrollOffset: scrollOffset
270
+ )
271
+ .padding(.bottom, footer == nil ? duoTabBarInsets.overlay : 0)
272
+ }
273
+
274
+ NavigationFooter(
275
+ footerComponent: footer,
276
+ keyboardSize: keyboardSize,
277
+ transparent: footerTransparent,
278
+ useMargin: footerUseMargin
279
+ )
280
+ .padding(.bottom, duoTabBarInsets.overlay)
281
+ .background(
282
+ GeometryReader { proxy in
283
+ Color.clear
284
+ .onAppear { footerHeight = proxy.size.height }
285
+ .onChange(of: proxy.size.height) { footerHeight = $0 }
286
+ }
287
+ )
288
+ }
289
+
290
+ if inputSearchType == .animated, let props = inputSearchProps {
291
+ SearchAnimatedHeader(
292
+ props: props,
293
+ scrollOffset: scrollOffset,
294
+ hiddenBack: goBack == nil,
295
+ hasHeaderRight: headerRight != nil,
296
+ headerRightWidth: headerRightWidth,
297
+ useSideBar: layout.sideBar != nil,
298
+ hasToolkit: toolkit != nil
299
+ )
300
+ }
301
+
302
+ if showsSideBar, let sideBar = layout.sideBar {
303
+ HeaderSideBar(
304
+ sideBar: sideBar,
305
+ goBack: goBack,
306
+ toolkit: toolkit,
307
+ headerColor: headerColor,
308
+ bottomInset: bottomBarsHeight
309
+ )
310
+ }
311
+ }
312
+ .ignoresSafeArea(.keyboard, edges: .bottom)
313
+ .onChange(of: scrollOffset) { updateHeaderHide($0) }
314
+ .onAppear { reportEmptySideBar() }
315
+ .onChange(of: showsSideBar) { _ in reportEmptySideBar() }
316
+ .onPreferenceChange(HeaderRightWidthKey.self) { width in
317
+ if headerRightWidth != width { headerRightWidth = width }
318
+ }
319
+ .onPreferenceChange(IsBottomTabRootKey.self) { isRoot in
320
+ if isBottomTabRoot != isRoot { isBottomTabRoot = isRoot }
321
+ }
322
+ .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in
323
+ guard useAvoidKeyboard,
324
+ let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
325
+ withAnimation(.easeOut(duration: 0.25)) {
326
+ keyboardHeight = keyboardReferenceHeight - endFrame.origin.y
327
+ }
328
+ }
329
+ // Compose's `hideKeyboardOnTap()`: simultaneous, so taps still reach buttons and fields.
330
+ .modifier(HideKeyboardOnTap(enabled: keyboardShouldPersistTaps))
331
+ }
332
+ .onAppear(perform: reportBottomObstruction)
333
+ .onChange(of: bottomObstruction) { _ in reportBottomObstruction() }
334
+ .onAppear(perform: startTracking)
335
+ .onChange(of: screenName) { _ in startTracking() }
336
+ .onDisappear { tracking?.cancel() }
337
+ }
338
+
339
+ /// Compose tracks from a `LaunchedEffect` with `remember`ed state, so every visit — first show, a
340
+ /// pop back, a tab switch — starts over with fresh state. A covered hosting controller keeps its
341
+ /// `@State` and doesn't re-run `.task`, so the run hangs off appear / disappear instead.
342
+ private func startTracking() {
343
+ tracking?.cancel()
344
+ let state = ScreenTrackingState()
345
+ let action = ScreenTracker.getLastScreenName() != nil ? "push" : "back"
346
+ let maxApi = appEnv.maxApi
347
+ let context = appEnv.applicationContext
348
+ let screenName = screenName
349
+ tracking = Task { @MainActor in
350
+ ScreenTracker.trackScreenNavigated(
351
+ maxApi: maxApi,
352
+ context: context,
353
+ screenName: screenName,
354
+ action: action,
355
+ state: state
356
+ )
357
+ do {
358
+ try await Task.sleep(nanoseconds: 2_000_000_000)
359
+ } catch {
360
+ return
361
+ }
362
+ let loadTime = ScreenTracker.now() - state.time
363
+ ScreenTracker.trackScreenDisplayed(
364
+ maxApi: maxApi,
365
+ context: context,
366
+ screenName: screenName,
367
+ duration: loadTime,
368
+ state: state
369
+ )
370
+ let interactionTime = ScreenTracker.now() - state.time
371
+ ScreenTracker.trackScreenInteracted(
372
+ maxApi: maxApi,
373
+ context: context,
374
+ screenName: screenName,
375
+ duration: interactionTime - loadTime,
376
+ totalDuration: interactionTime,
377
+ state: state
378
+ )
379
+ }
380
+ }
381
+
382
+ // MARK: - Header + content layer
383
+
384
+ @ViewBuilder
385
+ private var headerAndContent: some View {
386
+ if let animatedHeader = animatedHeader {
387
+ // The banner is the top of the scroll content and the body overlaps its lower part;
388
+ // the bar fades in over it (Compose's ScreenContent for HeaderType.Animated).
389
+ ZStack(alignment: .top) {
390
+ StackScreenContent(
391
+ scrollable: scrollable,
392
+ scrollOffset: $scrollOffset,
393
+ scrollToTopCallback: scrollToTopCallback,
394
+ animatedSearchInset: animatedSearchInset,
395
+ bottomInset: duoTabBarInsets.overlay,
396
+ snapDistance: searchSnapDistance,
397
+ onScrollIdle: settleHeader,
398
+ content: { animatedHeroBody(animatedHeader) }
399
+ )
400
+ .edgesIgnoringSafeArea(.all)
401
+
402
+ header
403
+ .background(alignment: .top) {
404
+ Group {
405
+ if layout.sideBar != nil {
406
+ // the bar shows once the banner has scrolled under the header
407
+ DuoHeaderBar(scrollOffset: scrollOffset, fadeStart: heroGlassStart(animatedHeader))
408
+ } else {
409
+ HeaderBarBackground(opacity: opacity)
410
+ }
411
+ }
412
+ .edgesIgnoringSafeArea([.top, .horizontal])
413
+ }
414
+ .offset(y: -headerHide)
415
+ .zIndex(100)
416
+ }
417
+ } else if headerTransparent && fullScreenContent {
418
+ // Compose's HeaderType.Transparent(isFullScreenContent): content starts under a clear bar
419
+ ZStack(alignment: .top) {
420
+ header
421
+ .zIndex(100)
422
+
423
+ StackScreenContent(
424
+ scrollable: scrollable,
425
+ scrollOffset: $scrollOffset,
426
+ scrollToTopCallback: scrollToTopCallback,
427
+ animatedSearchInset: animatedSearchInset,
428
+ bottomInset: duoTabBarInsets.overlay,
429
+ snapDistance: searchSnapDistance,
430
+ onScrollIdle: settleHeader,
431
+ content: { duoInsetContent }
432
+ )
433
+ .edgesIgnoringSafeArea(.all)
434
+ }
435
+ } else if useDuoGlass {
436
+ ZStack(alignment: .top) {
437
+ DuoScrollGradient(
438
+ scrollOffset: scrollOffset,
439
+ headerHeight: layout.headerHeight,
440
+ extended: headerType == .extended,
441
+ followScroll: useQuickReturn
442
+ )
443
+
444
+ StackScreenContent(
445
+ scrollable: scrollable,
446
+ scrollOffset: $scrollOffset,
447
+ scrollToTopCallback: scrollToTopCallback,
448
+ topInset: layout.headerHeight - layout.insets.top,
449
+ bottomInset: duoTabBarInsets.overlay,
450
+ snapDistance: searchSnapDistance,
451
+ onScrollIdle: settleHeader,
452
+ content: content
453
+ )
454
+
455
+ header
456
+ .background(
457
+ DuoHeaderBar(scrollOffset: scrollOffset)
458
+ .edgesIgnoringSafeArea([.top, .horizontal])
459
+ )
460
+ .offset(y: -headerHide)
461
+ .zIndex(100)
462
+ }
463
+ } else {
464
+ header
465
+ // top-aligned: an extended band runs past the bar, under the content
466
+ .background(alignment: .top) {
467
+ ZStack(alignment: .top) {
468
+ if layout.sideBar != nil && inputSearchType != .none && !headerTransparent {
469
+ // RN HeaderExtendHeader with search on the side bar layout: the
470
+ // gradient fades out within the header box, as without the search
471
+ DuoScrollGradient(
472
+ scrollOffset: scrollOffset,
473
+ headerHeight: layout.headerHeight,
474
+ extended: headerType == .extended
475
+ )
476
+ } else {
477
+ NavigationHeaderBackground(
478
+ headerType: headerType,
479
+ scrollState: scrollOffset,
480
+ headerTransparent: headerTransparent,
481
+ fullScreenContent: fullScreenContent,
482
+ backgroundColor: backgroundColor
483
+ )
484
+ }
485
+ HeaderBarBackground(opacity: opacity, transparent: headerTransparent || headerType == .none)
486
+ }
487
+ .edgesIgnoringSafeArea([.top, .horizontal])
488
+ }
489
+
490
+ StackScreenContent(
491
+ scrollable: scrollable,
492
+ scrollOffset: $scrollOffset,
493
+ scrollToTopCallback: scrollToTopCallback,
494
+ animatedSearchInset: animatedSearchInset,
495
+ bottomInset: duoTabBarInsets.overlay,
496
+ snapDistance: searchSnapDistance,
497
+ onScrollIdle: settleHeader,
498
+ content: content
499
+ )
500
+ }
501
+ }
502
+
503
+ /// Edge-to-edge branches lay out outside the safe area; iPhone Duo content still keeps
504
+ /// clear of the side strips, as RN's Screen does.
505
+ private var duoInsetContent: some View {
506
+ content()
507
+ .padding(.leading, layout.insetLeft)
508
+ .padding(.trailing, layout.insetRight)
509
+ }
510
+
511
+ private func heroGlassStart(_ animatedHeader: AnimatedHeader) -> CGFloat {
512
+ max(layout.width / animatedHeader.aspectRatio.value - layout.headerHeight, 0)
513
+ }
514
+
515
+ /// The banner fills the width at its aspect ratio; the body starts `layoutOffset` below the
516
+ /// header band so it overlaps the banner's lower part.
517
+ @ViewBuilder
518
+ private func animatedHeroBody(_ animatedHeader: AnimatedHeader) -> some View {
519
+ let width = layout.width
520
+ let imageHeight = width / animatedHeader.aspectRatio.value
521
+ let contentTopInset = layout.headerHeight + layoutOffset
522
+
523
+ ZStack(alignment: .top) {
524
+ animatedHeader.composable(scrollOffset)
525
+ .frame(width: width, height: imageHeight)
526
+ .clipped()
527
+
528
+ duoInsetContent
529
+ .padding(.top, contentTopInset)
530
+ }
531
+ .frame(maxWidth: .infinity, alignment: .top)
532
+ }
533
+
534
+ private var header: NavigationHeader {
535
+ NavigationHeader(
536
+ headerType: headerType,
537
+ titlePosition: titlePosition,
538
+ title: title,
539
+ headerTitle: headerTitle,
540
+ headerRight: headerRight,
541
+ goBack: goBack,
542
+ opacity: opacity,
543
+ animatedHeader: animatedHeader,
544
+ scrollState: scrollOffset,
545
+ // the animated search draws its own field over the (fading) title
546
+ inputSearchProps: inputSearchType == .header ? inputSearchProps : nil,
547
+ tintColor: tintColor,
548
+ titleOpacity: titleOpacity
549
+ )
550
+ }
551
+ }
552
+
553
+ // MARK: - Bottom-tab root preference
554
+
555
+ /// Set by `BottomTab` so the `StackScreen` hosting it keeps the full width and leaves the side
556
+ /// bar to the tabs (Compose's `Navigation.markAsBottomTabRoot()`).
557
+ public struct IsBottomTabRootKey: PreferenceKey {
558
+ public static var defaultValue: Bool = false
559
+ public static func reduce(value: inout Bool, nextValue: () -> Bool) {
560
+ value = value || nextValue()
561
+ }
562
+ }
563
+
564
+ // MARK: - Screen content (scrollable body)
565
+
566
+ /// Compose's `MainContent` / `ScreenContent`: the body, tracking the scroll offset when scrollable.
567
+ private struct StackScreenContent<Content: View>: View {
568
+ let scrollable: Bool
569
+ @Binding var scrollOffset: CGFloat
570
+ /// Compose's `ScrollData.scrollToTopCallback`, fired with the status-bar tap scroll-to-top.
571
+ let scrollToTopCallback: (() -> Void)?
572
+ /// Room for the animated search below the header (Compose's MainContent spacer).
573
+ var animatedSearchInset: CGFloat = 0
574
+ /// Room for a header the content scrolls under (iPhone Duo glass header).
575
+ var topInset: CGFloat = 0
576
+ /// Room for a bar that overlays the content (iPhone Duo floating tab bar).
577
+ var bottomInset: CGFloat = 0
578
+ /// Compose's animated-search snap: a scroll that comes to rest part way through this distance
579
+ /// finishes the move, up or back down.
580
+ var snapDistance: CGFloat? = nil
581
+ var onScrollIdle: (() -> Void)? = nil
582
+ let content: () -> Content
583
+
584
+ private let topAnchorID = "stack_screen_top_anchor"
585
+ private let scrollSpace = "stack_screen_scroll_space"
586
+
587
+ @State private var scrollView = WeakScrollView()
588
+
589
+ private func scrollDidRest() {
590
+ onScrollIdle?()
591
+ guard let snapDistance, scrollOffset > 0, scrollOffset < snapDistance,
592
+ let scrollView = scrollView.value else { return }
593
+ // UIKit, not `ScrollViewProxy.scrollTo`: SwiftUI keeps re-applying a scrollTo target while the
594
+ // content above it resizes, which this content does as the search inset shrinks with the scroll
595
+ let target = scrollOffset < snapDistance / 2 ? 0 : snapDistance
596
+ scrollView.setContentOffset(
597
+ CGPoint(x: scrollView.contentOffset.x, y: target - scrollView.adjustedContentInset.top),
598
+ animated: true
599
+ )
600
+ }
601
+
602
+ var body: some View {
603
+ if scrollable {
604
+ ScrollViewReader { proxy in
605
+ ScrollView(.vertical, showsIndicators: false) {
606
+ VStack(spacing: 0) {
607
+ // Top probe + scroll-to-top anchor, measured in the scroll's own space so the
608
+ // offset is a clean 0-based "points scrolled", independent of the insets below.
609
+ Color.clear
610
+ .frame(height: 0)
611
+ .id(topAnchorID)
612
+ .background(GeometryReader { geo in
613
+ Color.clear
614
+ .onAppear {
615
+ scrollOffset = -geo.frame(in: .named(scrollSpace)).minY
616
+ }
617
+ .onChange(of: geo.frame(in: .named(scrollSpace)).minY) { minY in
618
+ scrollOffset = -minY
619
+ }
620
+ })
621
+ .background(ScrollViewProbe { scrollView.value = $0 })
622
+
623
+ if animatedSearchInset + topInset > 0 {
624
+ Spacer().frame(height: animatedSearchInset + topInset)
625
+ }
626
+
627
+ VStack {
628
+ content()
629
+ }
630
+
631
+ if bottomInset > 0 {
632
+ Spacer().frame(height: bottomInset)
633
+ }
634
+ }
635
+ }
636
+ .coordinateSpace(name: scrollSpace)
637
+ .modifier(ScrollRest(offset: scrollOffset, onRest: scrollDidRest))
638
+ // Status-bar tap (MoMoStatusBarTap swizzle) scrolls to top and notifies the consumer.
639
+ .onReceive(NotificationCenter.default.publisher(for: MoMoStatusBarTap.notificationName)) { _ in
640
+ withAnimation {
641
+ proxy.scrollTo(topAnchorID, anchor: .top)
642
+ }
643
+ scrollToTopCallback?()
644
+ }
645
+ }
646
+ } else {
647
+ content()
648
+ .padding(.top, topInset)
649
+ .padding(.bottom, bottomInset)
650
+ }
651
+ }
652
+ }
653
+
654
+ private final class WeakScrollView {
655
+ weak var value: UIScrollView?
656
+ }
657
+
658
+ /// Finds the UIScrollView behind the enclosing SwiftUI `ScrollView` once placed in its content.
659
+ private struct ScrollViewProbe: UIViewRepresentable {
660
+ let found: (UIScrollView) -> Void
661
+
662
+ func makeUIView(context: Context) -> Probe { Probe(found: found) }
663
+
664
+ func updateUIView(_ uiView: Probe, context: Context) {}
665
+
666
+ final class Probe: UIView {
667
+ let found: (UIScrollView) -> Void
668
+
669
+ init(found: @escaping (UIScrollView) -> Void) {
670
+ self.found = found
671
+ super.init(frame: .zero)
672
+ isUserInteractionEnabled = false
673
+ }
674
+
675
+ required init?(coder: NSCoder) { nil }
676
+
677
+ override func didMoveToWindow() {
678
+ super.didMoveToWindow()
679
+ var view = superview
680
+ while let current = view, !(current is UIScrollView) { view = current.superview }
681
+ if let scrollView = view as? UIScrollView { found(scrollView) }
682
+ }
683
+ }
684
+ }
685
+
686
+ /// Calls `onRest` once the scroll comes to rest. Scroll phases need iOS 18; before that the scroll
687
+ /// counts as ended once the offset stops changing, which a finger held still also looks like.
688
+ private struct ScrollRest: ViewModifier {
689
+ let offset: CGFloat
690
+ let onRest: () -> Void
691
+
692
+ @State private var pending: Task<Void, Never>?
693
+
694
+ func body(content: Content) -> some View {
695
+ #if compiler(>=6.0)
696
+ if #available(iOS 18.0, *) {
697
+ content.onScrollPhaseChange { _, phase in
698
+ if phase == .idle { onRest() }
699
+ }
700
+ } else {
701
+ debounced(content)
702
+ }
703
+ #else
704
+ debounced(content)
705
+ #endif
706
+ }
707
+
708
+ private func debounced(_ content: Content) -> some View {
709
+ content
710
+ .onChange(of: offset) { _ in
711
+ pending?.cancel()
712
+ pending = Task { @MainActor in
713
+ try? await Task.sleep(nanoseconds: 150_000_000)
714
+ guard !Task.isCancelled else { return }
715
+ onRest()
716
+ }
717
+ }
718
+ .onDisappear { pending?.cancel() }
719
+ }
720
+ }
721
+
722
+ // MARK: - Animated search header
723
+
724
+ /// Compose's `SearchAnimated`: the search slides from below the header (scroll 0) up into the bar
725
+ /// while its insets grow to clear the back button and header right.
726
+ private struct SearchAnimatedHeader: View {
727
+ let props: InputSearchProps
728
+ let scrollOffset: CGFloat
729
+ let hiddenBack: Bool
730
+ let hasHeaderRight: Bool
731
+ let headerRightWidth: CGFloat
732
+ var useSideBar: Bool = false
733
+ var hasToolkit: Bool = false
734
+
735
+ @Environment(\.screenLayout) private var layout
736
+ @Environment(\.appTheme) private var theme
737
+ @OptionalEnvironmentObject private var navigator: Navigator?
738
+
739
+ var body: some View {
740
+ let scroll = max(scrollOffset, 0)
741
+
742
+ // Insets are measured from the safe-area top; collapsed, the search is centered in the bar row.
743
+ let rowTop = layout.headerStatusBarHeight - layout.insets.top
744
+ let minTop = rowTop + (layout.headerBarHeight - HEADER_HEIGHT) / 2
745
+ let maxTop = layout.headerHeight - layout.insets.top
746
+ let minSide = Spacing.M
747
+ // collapsed on the iPhone Duo status bar row, the search stays between its clusters (48pt back there)
748
+ let clearance = layout.statusBarClearance
749
+ let backWidth: CGFloat = clearance != nil && !useSideBar ? DuoMetrics.controlSize : 28
750
+ let maxStart = (clearance?.left ?? 0) + (hiddenBack || useSideBar ? Spacing.M : backWidth + Spacing.M + Spacing.S)
751
+ let maxEnd = (clearance?.right ?? 0) + (useSideBar && hasToolkit
752
+ ? Spacing.M
753
+ : headerRightWidth + (hasHeaderRight ? Spacing.M * 2 : Spacing.M))
754
+
755
+ // one progress for all three, so the field docks with its final insets and never covers the toolkit
756
+ let progress = min(scroll / HEADER_HEIGHT, 1)
757
+ let top = maxTop + (minTop - maxTop) * progress
758
+ let start = minSide + (maxStart - minSide) * progress
759
+ let end = minSide + (maxEnd - minSide) * progress
760
+
761
+ // padding, not a Spacer: on the iPhone Duo status bar row the collapsed top is above the safe area
762
+ InputSearch(props: headerSearchProps(
763
+ props,
764
+ scroll: scroll,
765
+ layout: layout,
766
+ theme: theme,
767
+ pop: navigator.map { navigator in { navigator.pop() } }
768
+ ))
769
+ .frame(height: HEADER_HEIGHT)
770
+ .padding(.leading, start)
771
+ .padding(.trailing, end)
772
+ .padding(.top, top)
773
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
774
+ }
775
+ }
776
+
777
+ // MARK: - Floating content
778
+
779
+ /// Compose's `FloatingContent`: no FAB props means nothing renders.
780
+ private struct FloatingContent: View {
781
+ let fabProps: FabProps?
782
+ let keyboardSize: CGFloat
783
+ /// With a footer the FAB is anchored to the footer's *layout* top, which does not move
784
+ /// when the band offsets itself over the keyboard — so it rides the same offset.
785
+ let hasFooter: Bool
786
+ let scrollOffset: CGFloat
787
+
788
+ /// Compose keeps the FAB `Spacing.M + AppNavigationBar` above the bottom of the screen
789
+ /// and lifts it with `imePadding()`; here it sits `Spacing.M` above the safe area and
790
+ /// nothing lifts it, so the difference is applied as an offset (see NavigationFooter).
791
+ private var verticalOffset: CGFloat {
792
+ if hasFooter { return NavigationFooter.verticalOffset(keyboardSize: keyboardSize) }
793
+ return NavigationFooter.safeAreaBottom - NavigationFooter.navigationBarInset - keyboardSize
794
+ }
795
+
796
+ var body: some View {
797
+ if let fabProps = fabProps {
798
+ FloatingButton(
799
+ props: fabProps,
800
+ keyboardOffset: 0,
801
+ scrollOffset: scrollOffset
802
+ )
803
+ .padding(.horizontal, Spacing.M)
804
+ .offset(y: verticalOffset)
805
+ .ignoresSafeArea(.keyboard, edges: .bottom)
806
+ }
807
+ }
808
+ }
809
+
810
+ // MARK: - Footer
811
+
812
+ /// Compose's `Footer`: the band pinned under the content — surface background, S/M padding,
813
+ /// the navigation-bar reserve, and a 6pt shadow cast up onto the content.
814
+ public struct NavigationFooter: View {
815
+ /// Nothing renders when nil.
816
+ var footerComponent: (() -> AnyView)?
817
+ /// Current keyboard height, measured from the bottom of the screen.
818
+ var keyboardSize: CGFloat
819
+ var transparent: Bool
820
+ var useMargin: Bool
821
+
822
+ public init(
823
+ footerComponent: (() -> AnyView)?,
824
+ keyboardSize: CGFloat = 0,
825
+ transparent: Bool = false,
826
+ useMargin: Bool = true
827
+ ) {
828
+ self.footerComponent = footerComponent
829
+ self.keyboardSize = keyboardSize
830
+ self.transparent = transparent
831
+ self.useMargin = useMargin
832
+ }
833
+
834
+ @Environment(\.appTheme) private var theme
835
+ @Environment(\.screenLayout) private var layout
836
+
837
+ /// Read from the window, not from an enclosing `GeometryProxy`: the proxy's bottom
838
+ /// inset grows to the keyboard height while the keyboard is up.
839
+ static var safeAreaBottom: CGFloat {
840
+ UIApplication.shared.connectedScenes
841
+ .compactMap { ($0 as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom }
842
+ .first ?? 0
843
+ }
844
+
845
+ /// Compose's `AppNavigationBar.current` (`getNavigationBarHeight` clamps iOS to 21pt).
846
+ static var navigationBarInset: CGFloat { min(safeAreaBottom, 21) }
847
+
848
+ /// Compose's `bottomPadding + Spacing.S` with the keyboard down. The footer is laid
849
+ /// out inside the safe area, which already covers part of that gap.
850
+ static var bottomPadding: CGFloat {
851
+ max(navigationBarInset + Spacing.S - safeAreaBottom, 0)
852
+ }
853
+
854
+ /// Deliberately an offset and not padding: growing the footer feeds back into SwiftUI's
855
+ /// keyboard avoidance, so a padding-based lift never settles at the right place.
856
+ /// Negative lifts the band over the keyboard; positive shaves the few points by which
857
+ /// the safe area over-reserves compared to Compose. `FloatingContent` rides the same
858
+ /// amount so the FAB keeps its distance above the band.
859
+ ///
860
+ /// Natural position is `safeAreaBottom + bottomPadding` above the bottom of the screen;
861
+ /// Compose puts it at `(AppNavigationBar - keyboard).coerceAtLeast(0) + Spacing.S` on
862
+ /// top of the `imePadding()` lift — i.e. `max(navigationBar, keyboard) + Spacing.S`.
863
+ static func verticalOffset(keyboardSize: CGFloat) -> CGFloat {
864
+ (safeAreaBottom + bottomPadding) - (max(navigationBarInset, keyboardSize) + Spacing.S)
865
+ }
866
+
867
+ public var body: some View {
868
+ if let footerComponent = footerComponent {
869
+ let margin: CGFloat = useMargin ? Spacing.S : 0
870
+ VStack(alignment: .leading, spacing: 0) {
871
+ footerComponent()
872
+ }
873
+ .frame(maxWidth: .infinity, alignment: .leading)
874
+ .padding(.top, margin)
875
+ .padding(.horizontal, useMargin ? Spacing.M : 0)
876
+ // without the margin the Spacing.S under the content goes too: negative, so the
877
+ // content sinks into the reserve while the frame keeps its bottom edge
878
+ .padding(.bottom, Self.bottomPadding - (Spacing.S - margin))
879
+ // Compose paints the surface all the way to the bottom of the screen; the
880
+ // footer's own frame stops at the safe area, so only the fill bleeds down.
881
+ // With side insets (iPhone Duo) it stays inside them, off the side bar.
882
+ .background(
883
+ (transparent ? Color.clear : theme.colors.background.surface)
884
+ .ignoresSafeArea(.container, edges: hasSideInsets ? .bottom : [.bottom, .horizontal])
885
+ )
886
+ .overlay(alignment: .top) {
887
+ if !transparent { shadow }
888
+ }
889
+ .offset(y: Self.verticalOffset(keyboardSize: keyboardSize))
890
+ }
891
+ }
892
+
893
+ private var hasSideInsets: Bool { layout.insetLeft > 0 || layout.insetRight > 0 }
894
+
895
+ /// Compose offsets the shadow box by -6dp, so it lands on the content above the
896
+ /// footer rather than on the footer surface itself.
897
+ private var shadow: some View {
898
+ LinearGradient(
899
+ colors: [Color.clear, Color.black.opacity(0.05)],
900
+ startPoint: .top,
901
+ endPoint: .bottom
902
+ )
903
+ .frame(height: 6)
904
+ .offset(y: -6)
905
+ .allowsHitTesting(false)
906
+ }
907
+ }
908
+
909
+ // MARK: - Hide keyboard on tap
910
+
911
+ /// Compose's `Modifier.hideKeyboardOnTap()`. Uses `simultaneousGesture` so the tap is not
912
+ /// consumed — buttons and text fields keep working.
913
+ private struct HideKeyboardOnTap: ViewModifier {
914
+ let enabled: Bool
915
+
916
+ func body(content: Content) -> some View {
917
+ if enabled {
918
+ content.simultaneousGesture(
919
+ TapGesture().onEnded {
920
+ UIApplication.shared.sendAction(
921
+ #selector(UIResponder.resignFirstResponder),
922
+ to: nil, from: nil, for: nil
923
+ )
924
+ }
925
+ )
926
+ } else {
927
+ content
928
+ }
929
+ }
930
+ }