@momo-kits/native-kits 0.167.1-duo.8-debug → 0.167.1-duo.9

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 (254) hide show
  1. package/CLAUDE.md +4 -1
  2. package/ios/Application/ApplicationEnvironment.swift +11 -0
  3. package/ios/Application/ComposeApi.swift +2 -1
  4. package/ios/Application/{Components.swift → LegacyHeader.swift} +6 -62
  5. package/ios/Application/Screen.swift +3 -2
  6. package/ios/Avatar/Avatar.swift +4 -3
  7. package/ios/Badge/Badge.swift +6 -4
  8. package/ios/Badge/BadgeDot.swift +1 -1
  9. package/ios/Badge/BadgeRibbon.swift +0 -1
  10. package/ios/Button/Button.swift +15 -12
  11. package/ios/CalculatorKeyboard/CalculatorKeyboard.swift +12 -20
  12. package/ios/Carousel/Carousel.swift +29 -12
  13. package/ios/Checkbox/Checkbox.swift +7 -3
  14. package/ios/Chip/Chip.swift +5 -4
  15. package/ios/Collapse/Collapse.swift +70 -19
  16. package/ios/Colors+Radius+Spacing/Colors.swift +9 -0
  17. package/ios/DateTimePicker/DateTimePicker.swift +54 -20
  18. package/ios/DateTimePicker/WheelPicker.swift +103 -105
  19. package/ios/Divider/Divider.swift +37 -4
  20. package/ios/Extensions/Color++.swift +18 -0
  21. package/ios/Icon/Icon.swift +7 -1
  22. package/ios/IconButton/IconButton.swift +15 -7
  23. package/ios/Image/Image.swift +7 -34
  24. package/ios/Information/Information.swift +27 -9
  25. package/ios/Input/ErrorView.swift +2 -2
  26. package/ios/Input/Input.swift +160 -147
  27. package/ios/Input/InputPhoneNumber.swift +12 -9
  28. package/ios/Input/InputSearch.swift +102 -75
  29. package/ios/Input/InputTextArea.swift +128 -145
  30. package/ios/InputDropDown/InputDropDown.swift +10 -7
  31. package/ios/InputMoney/InputMoney.swift +33 -11
  32. package/ios/InputOTP/InputOTP.swift +22 -12
  33. package/ios/Layout/Card.swift +50 -0
  34. package/ios/Layout/GridContext.swift +123 -3
  35. package/ios/Layout/Item.swift +5 -4
  36. package/ios/Layout/Section.swift +4 -100
  37. package/ios/Loader/Loader.swift +5 -9
  38. package/ios/Lottie/LottieView.swift +10 -0
  39. package/ios/{Application/Navigation/Overplay → Navigation}/BottomSheet.swift +61 -9
  40. package/ios/{Application/Navigation → Navigation}/BottomTab/BottomTab.swift +60 -34
  41. package/ios/{Application/Navigation → Navigation}/BottomTab/BottomTabBar.swift +11 -6
  42. package/ios/{Application → Navigation/Component}/FloatingButton.swift +14 -3
  43. package/ios/Navigation/Component/Header.swift +493 -0
  44. package/ios/Navigation/Component/HeaderBackground.swift +134 -0
  45. package/ios/Navigation/Component/HeaderJourney.swift +73 -0
  46. package/ios/Navigation/Component/HeaderLocation.swift +48 -0
  47. package/ios/Navigation/Component/HeaderRight.swift +386 -0
  48. package/ios/Navigation/Component/HeaderTitle.swift +36 -0
  49. package/ios/{Application/Navigation → Navigation/Component}/HeaderUser.swift +20 -23
  50. package/ios/{Application/Navigation/component → Navigation/Component}/SideBar.swift +49 -28
  51. package/ios/{Application/Navigation/Overplay → Navigation/Component}/SnackBar.swift +5 -5
  52. package/ios/Navigation/HostBackGestureBridge.swift +57 -0
  53. package/ios/Navigation/Navigation.swift +190 -0
  54. package/ios/{Application/Navigation → Navigation}/NavigationContainer.swift +46 -41
  55. package/ios/{Application/Navigation → Navigation}/Navigator.swift +65 -9
  56. package/ios/{Application → Navigation}/StackScreen.swift +236 -103
  57. package/ios/{Application/Navigation → Navigation}/Tracking/ScreenTracker.swift +12 -5
  58. package/ios/OTPKeyboard/OTPKeyboard.swift +8 -11
  59. package/ios/Pagination/PaginationScroll.swift +2 -2
  60. package/ios/Popup/PopupDisplay.swift +79 -159
  61. package/ios/Popup/PopupInput.swift +10 -13
  62. package/ios/Popup/PopupLayout.swift +97 -2
  63. package/ios/Popup/PopupNotify.swift +27 -55
  64. package/ios/Popup/PopupPromotion.swift +22 -50
  65. package/ios/PopupView/FullscreenPopup.swift +13 -19
  66. package/ios/PopupView/PopupView.swift +1 -1
  67. package/ios/PopupView/Utils++.swift +0 -8
  68. package/ios/ProgressInfo/ProgressInfo.swift +4 -2
  69. package/ios/Radio/Radio.swift +2 -1
  70. package/ios/Rating/Rating.swift +9 -1
  71. package/ios/ScrollIndicator/ScrollIndicator.swift +11 -10
  72. package/ios/Skeleton/Skeleton.swift +22 -27
  73. package/ios/Slider/Slider.swift +16 -18
  74. package/ios/Stepper/Stepper.swift +6 -1
  75. package/ios/Steps/Steps.swift +8 -6
  76. package/ios/SuggestAction/SuggestAction.swift +5 -0
  77. package/ios/Swipeable/SwipeCell.swift +10 -6
  78. package/ios/Switch/Switch.swift +22 -20
  79. package/ios/TabView/TabView.swift +102 -21
  80. package/ios/Tag/Tag.swift +15 -4
  81. package/ios/Template/Logo/Logo.swift +2 -1
  82. package/ios/Template/TrustBanner/TrustBanner.swift +41 -27
  83. package/ios/Title/Title.swift +6 -17
  84. package/ios/Tooltip/Tooltip.swift +29 -14
  85. package/ios/Typography/Text.swift +61 -19
  86. package/ios/Uploader/Uploader.swift +3 -1
  87. package/ios/native-kits.podspec +1 -0
  88. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ContentView.swift +1 -1
  89. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +77 -1
  90. package/package.json +1 -1
  91. package/build.gradle.kts +0 -11
  92. package/compose/build.gradle.kts +0 -204
  93. package/compose/build.gradle.kts.backup +0 -204
  94. package/compose/compose.podspec +0 -47
  95. package/compose/src/androidMain/kotlin/vn/momo/kits/const/HostFont.android.kt +0 -19
  96. package/compose/src/androidMain/kotlin/vn/momo/kits/navigation/ScrollToTop.android.kt +0 -6
  97. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/BackGesture.android.kt +0 -12
  98. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/OsFontScale.android.kt +0 -7
  99. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +0 -153
  100. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +0 -6
  101. package/compose/src/commonMain/kotlin/vn/momo/kits/application/AnimationSearchInput.kt +0 -52
  102. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +0 -124
  103. package/compose/src/commonMain/kotlin/vn/momo/kits/application/FloatingButton.kt +0 -183
  104. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Header.kt +0 -207
  105. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderAnimated.kt +0 -39
  106. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderBackground.kt +0 -86
  107. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderDefault.kt +0 -76
  108. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderExtended.kt +0 -76
  109. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderRight.kt +0 -293
  110. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderTitle.kt +0 -33
  111. package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +0 -831
  112. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Localize.kt +0 -269
  113. package/compose/src/commonMain/kotlin/vn/momo/kits/application/NavigationContainer.kt +0 -110
  114. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Screen.kt +0 -378
  115. package/compose/src/commonMain/kotlin/vn/momo/kits/application/WidgetContainer.kt +0 -56
  116. package/compose/src/commonMain/kotlin/vn/momo/kits/application/useHeaderSearchAnimation.kt +0 -69
  117. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Avatar.kt +0 -157
  118. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +0 -82
  119. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeDot.kt +0 -32
  120. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeRibbon.kt +0 -338
  121. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BaselineView.kt +0 -175
  122. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +0 -356
  123. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +0 -113
  124. package/compose/src/commonMain/kotlin/vn/momo/kits/components/CheckBox.kt +0 -89
  125. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +0 -128
  126. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Collapse.kt +0 -209
  127. package/compose/src/commonMain/kotlin/vn/momo/kits/components/CupertinoOverscroll.kt +0 -542
  128. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +0 -49
  129. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +0 -76
  130. package/compose/src/commonMain/kotlin/vn/momo/kits/components/IconButton.kt +0 -125
  131. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Image.kt +0 -231
  132. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +0 -189
  133. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +0 -475
  134. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputDropDown.kt +0 -114
  135. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +0 -266
  136. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +0 -240
  137. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +0 -223
  138. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +0 -264
  139. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +0 -185
  140. package/compose/src/commonMain/kotlin/vn/momo/kits/components/LazyColumnWithBouncing.kt +0 -343
  141. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Loader.kt +0 -108
  142. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationDot.kt +0 -56
  143. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationNumber.kt +0 -41
  144. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationScroll.kt +0 -84
  145. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationWhiteDot.kt +0 -40
  146. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +0 -361
  147. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +0 -108
  148. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +0 -310
  149. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +0 -59
  150. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Rating.kt +0 -87
  151. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ScaleSizeScope.kt +0 -21
  152. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Skeleton.kt +0 -90
  153. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +0 -323
  154. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Stepper.kt +0 -220
  155. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Steps.kt +0 -473
  156. package/compose/src/commonMain/kotlin/vn/momo/kits/components/SuggestAction.kt +0 -122
  157. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +0 -199
  158. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +0 -91
  159. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +0 -552
  160. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +0 -88
  161. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +0 -125
  162. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Title.kt +0 -207
  163. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +0 -498
  164. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TrustBanner.kt +0 -176
  165. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Uploader.kt +0 -182
  166. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePicker.kt +0 -193
  167. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerTypes.kt +0 -29
  168. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +0 -234
  169. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +0 -188
  170. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Colors.kt +0 -325
  171. package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +0 -16
  172. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitDesignSystem.kt +0 -18
  173. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitFontScale.kt +0 -22
  174. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Radius.kt +0 -12
  175. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Spacing.kt +0 -16
  176. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Theme.kt +0 -188
  177. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +0 -297
  178. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Card.kt +0 -54
  179. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +0 -50
  180. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +0 -232
  181. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Section.kt +0 -41
  182. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/AutomationId.kt +0 -52
  183. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Clickable.kt +0 -66
  184. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Conditional.kt +0 -11
  185. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/DeprecatedModifier.kt +0 -104
  186. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Shadow.kt +0 -48
  187. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Size.kt +0 -51
  188. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +0 -338
  189. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/HostBackGestureBridge.kt +0 -58
  190. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +0 -137
  191. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +0 -106
  192. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +0 -205
  193. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +0 -393
  194. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ScrollToTop.kt +0 -8
  195. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +0 -708
  196. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackTransition.kt +0 -148
  197. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/SwipeBackHaptic.kt +0 -35
  198. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +0 -203
  199. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +0 -232
  200. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/CurvedContainer.kt +0 -86
  201. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +0 -98
  202. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +0 -312
  203. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/FloatingButton.kt +0 -169
  204. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +0 -408
  205. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +0 -92
  206. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +0 -105
  207. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +0 -74
  208. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +0 -366
  209. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +0 -37
  210. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +0 -363
  211. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +0 -185
  212. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +0 -132
  213. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/tracking/ScreenTracker.kt +0 -127
  214. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/BackGesture.kt +0 -12
  215. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ComposeLottieAnimation.kt +0 -91
  216. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/OsFontScale.kt +0 -9
  217. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +0 -74
  218. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +0 -13
  219. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Icons.kt +0 -1329
  220. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Resources.kt +0 -55
  221. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Tracking.kt +0 -15
  222. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +0 -118
  223. package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +0 -40
  224. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/HostBackGesture.ios.kt +0 -45
  225. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/ScrollToTop.ios.kt +0 -32
  226. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/BackGesture.ios.kt +0 -38
  227. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/OsFontScale.ios.kt +0 -66
  228. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +0 -217
  229. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +0 -86
  230. package/gradle/libs.versions.toml +0 -69
  231. package/gradle/wrapper/gradle-wrapper.jar +0 -0
  232. package/gradle/wrapper/gradle-wrapper.properties +0 -8
  233. package/gradle.properties +0 -26
  234. package/gradlew +0 -252
  235. package/gradlew.bat +0 -94
  236. package/ios/Application/HeaderRight.swift +0 -324
  237. package/ios/Application/Navigation/HeaderBackProps.swift +0 -66
  238. package/ios/Application/Navigation/HeaderTitle.swift +0 -57
  239. package/ios/Application/Navigation/NavigationOptions.swift +0 -109
  240. package/ios/Application/Navigation/NavigatorManager.swift +0 -29
  241. package/ios/Application/Navigation/component/DuoScrollGradient.swift +0 -54
  242. package/ios/Application/Navigation/component/Header.swift +0 -183
  243. package/ios/Application/Navigation/component/HeaderBackground.swift +0 -84
  244. package/ios/Application/Navigation/component/HeaderColor.swift +0 -65
  245. package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +0 -11
  246. package/ios/Application/Navigation/component/NavigationFooter.swift +0 -90
  247. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +0 -280
  248. package/ios-demo/README.md +0 -120
  249. package/settings.gradle.kts +0 -64
  250. /package/ios/{Application/Layout → Layout}/ScreenLayout.swift +0 -0
  251. /package/ios/{Application/Navigation → Navigation}/BottomTab/CurvedContainer.swift +0 -0
  252. /package/ios/{Application/Navigation → Navigation}/BottomTab/DuoBarCompression.swift +0 -0
  253. /package/ios/{Application/Navigation → Navigation}/BottomTab/DuoTabBar.swift +0 -0
  254. /package/ios/{Application/Navigation/Overplay → Navigation}/ModalScreen.swift +0 -0
@@ -1,13 +1,10 @@
1
1
  //
2
2
  // StackScreen.swift
3
- // Pods
3
+ // MoMoUIKits
4
4
  //
5
- // SwiftUI mirror of Compose's `StackScreen`. This is a refactor of `Screen`:
6
- // the body is decomposed into the same focused pieces Compose uses — header
7
- // layer, scrollable content (`StackScreenContent`), floating button
8
- // (`FloatingContent`) and footer (`NavigationFooter`) — instead of one
9
- // monolithic, partially-duplicated view. The public init matches `Screen`
10
- // so call sites (e.g. NavigationContainer's ScreenHost) can swap over.
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.
11
8
  //
12
9
 
13
10
  import SwiftUI
@@ -34,7 +31,9 @@ public struct StackScreen<Content: View>: View {
34
31
  var useAvoidKeyboard: Bool
35
32
  var keyboardShouldPersistTaps: Bool
36
33
  var footer: (() -> AnyView)?
37
- var headerRight: (()->any View)? = {HeaderRight()}
34
+ var footerTransparent: Bool
35
+ var footerUseMargin: Bool
36
+ var headerRight: (() -> any View)? = { HeaderRight() }
38
37
  var animatedHeader: AnimatedHeader? = nil
39
38
  var layoutOffset: CGFloat
40
39
  var inputSearchProps: InputSearchProps?
@@ -62,7 +61,9 @@ public struct StackScreen<Content: View>: View {
62
61
  useAvoidKeyboard: Bool = true,
63
62
  keyboardShouldPersistTaps: Bool = false,
64
63
  footer: (() -> AnyView)? = nil,
65
- headerRight: (()->any View)? = {HeaderRight()},
64
+ footerTransparent: Bool = false,
65
+ footerUseMargin: Bool = true,
66
+ headerRight: (() -> any View)? = { HeaderRight() },
66
67
  animatedHeader: AnimatedHeader? = nil,
67
68
  layoutOffset: CGFloat = 56,
68
69
  inputSearchProps: InputSearchProps? = nil,
@@ -89,6 +90,8 @@ public struct StackScreen<Content: View>: View {
89
90
  self.useAvoidKeyboard = useAvoidKeyboard
90
91
  self.keyboardShouldPersistTaps = keyboardShouldPersistTaps
91
92
  self.footer = footer
93
+ self.footerTransparent = footerTransparent
94
+ self.footerUseMargin = footerUseMargin
92
95
  self.headerRight = headerRight
93
96
  self.animatedHeader = animatedHeader
94
97
  self.layoutOffset = layoutOffset
@@ -105,32 +108,46 @@ public struct StackScreen<Content: View>: View {
105
108
  @State private var headerHide: CGFloat = 0
106
109
  @State private var lastScrollOffset: CGFloat = 0
107
110
  @State private var headerSettle: Task<Void, Never>?
111
+ @State private var footerHeight: CGFloat = 0
108
112
 
109
113
  @Environment(\.applicationEnvironment) private var appEnv
110
114
  @Environment(\.screenLayout) private var layout
111
115
  @Environment(\.duoTabBarInsets) private var duoTabBarInsets
112
- @State private var footerHeight: CGFloat = 0
116
+ @Environment(\.appTheme) private var theme
117
+ @OptionalEnvironmentObject private var navigation: Navigation?
113
118
 
114
- // Auto-tracking state (mirrors Compose's `remember { ScreenTrackingState() }` +
115
- // the once-computed push/back `action`).
119
+ // Auto-tracking state (Compose's `remember { ScreenTrackingState() }` + the once-computed action).
116
120
  @State private var trackingState = ScreenTrackingState()
117
121
  @State private var trackingAction: String = ScreenTracker.getLastScreenName() != nil ? "push" : "back"
118
122
 
119
- /// Header background fade tied to scroll: 0 → transparent (over a hero image
120
- /// or transparent header), 1 → opaque surface with the drop shadow.
121
- private var scrollOpacity: CGFloat { min(scrollOffset / 114, 1) }
123
+ /// Compose's `headerOpacity()`: 0 over a banner or transparent header, 1 once the bar is opaque.
124
+ private var opacity: CGFloat {
125
+ headerOpacity(
126
+ scrollOffset: scrollOffset,
127
+ isAnimated: animatedHeader != nil,
128
+ layoutOffset: layoutOffset,
129
+ transparent: headerTransparent
130
+ )
131
+ }
132
+
133
+ private var headerColor: HeaderColor {
134
+ getHeaderColor(
135
+ isAnimated: animatedHeader != nil,
136
+ isSurface: animatedHeader?.isSurface ?? true,
137
+ opacity: opacity,
138
+ tintColor: tintColor
139
+ )
140
+ }
122
141
 
123
- // MARK: - Search header (mirrors Compose's getInputSearchType / SearchAnimated)
142
+ // MARK: - Search header (Compose's getInputSearchType / SearchAnimated)
124
143
 
125
144
  private enum InputSearchType { case none, header, animated }
126
145
 
127
146
  private var inputSearchType: InputSearchType {
128
- guard inputSearchProps != nil else { return .none }
147
+ guard inputSearchProps != nil, headerType != .none else { return .none }
129
148
  return useAnimated ? .animated : .header
130
149
  }
131
150
 
132
- private static var headerHeight: CGFloat { 52 }
133
-
134
151
  private var toolkit: (data: HeaderRightData?, tintColor: Color?)? { toolkitHeaderRight(headerRight) }
135
152
 
136
153
  private var showsSideBar: Bool {
@@ -176,6 +193,8 @@ public struct StackScreen<Content: View>: View {
176
193
  }
177
194
  }
178
195
 
196
+ private var keyboardSize: CGFloat { useAvoidKeyboard ? max(keyboardHeight, 0) : 0 }
197
+
179
198
  /// What the footer, tab bar or home indicator takes from the bottom of the window, gap included.
180
199
  private var bottomBarsHeight: CGFloat {
181
200
  if footer != nil { return footerHeight + NavigationFooter.safeAreaBottom + Spacing.XL }
@@ -183,35 +202,43 @@ public struct StackScreen<Content: View>: View {
183
202
  return NavigationFooter.navigationBarInset + Spacing.XL
184
203
  }
185
204
 
205
+ /// Top of the footer (or the navigation bar) from the window bottom; snack bars sit above it.
206
+ private var bottomObstruction: CGFloat {
207
+ guard footer != nil else { return keyboardSize + NavigationFooter.navigationBarInset }
208
+ return footerHeight + NavigationFooter.safeAreaBottom - NavigationFooter.verticalOffset(keyboardSize: keyboardSize)
209
+ }
210
+
211
+ private func reportBottomObstruction() {
212
+ // bottom tab children leave overplays to the screen hosting the tabs, as in Compose
213
+ guard let navigation, navigation.bottomTabIndex == -1 else { return }
214
+ navigation.bottomObstruction = bottomObstruction
215
+ }
216
+
186
217
  /// Keyboard frames are in screen coordinates; UIScreen stays on the outer display on iPhone Duo.
187
218
  private var keyboardReferenceHeight: CGFloat {
188
219
  layout.isIPhoneDuo ? layout.height : UIScreen.main.bounds.height
189
220
  }
190
221
 
191
- /// Extra top space reserved inside the content so the animated search bar has
192
- /// room below the header at scroll 0; it shrinks to 0 as the bar slides up.
222
+ /// Room for the animated search below the header at scroll 0; shrinks as the bar slides up.
193
223
  private var animatedSearchInset: CGFloat {
194
224
  guard inputSearchType == .animated else { return 0 }
195
225
  let scroll = max(scrollOffset, 0)
196
- return min(max(Self.headerHeight - scroll, 0), Self.headerHeight)
226
+ return min(max(HEADER_HEIGHT - scroll, 0), HEADER_HEIGHT)
197
227
  }
198
228
 
199
229
  /// Title fades out as the animated search bar rises into the header.
200
230
  private var titleOpacity: CGFloat {
201
231
  guard inputSearchType == .animated else { return 1 }
202
232
  let scroll = max(scrollOffset, 0)
203
- return 1 - min(max(scroll / Self.headerHeight, 0), 1)
233
+ return 1 - min(max(scroll / HEADER_HEIGHT, 0), 1)
204
234
  }
205
235
 
206
236
  // MARK: - Body
207
237
 
208
238
  public var body: some View {
209
239
  GeometryReader { _ in
210
- let keyboardSize: CGFloat = useAvoidKeyboard ? max(keyboardHeight, 0) : 0
211
-
212
240
  ZStack(alignment: .top) {
213
- // Background color
214
- (backgroundColor ?? Color.white)
241
+ (backgroundColor ?? theme.colors.background.default)
215
242
  .edgesIgnoringSafeArea(.all)
216
243
 
217
244
  VStack(spacing: 0) {
@@ -229,18 +256,22 @@ public struct StackScreen<Content: View>: View {
229
256
  .padding(.bottom, footer == nil ? duoTabBarInsets.overlay : 0)
230
257
  }
231
258
 
232
- NavigationFooter(footerComponent: footer, keyboardSize: keyboardSize)
233
- .padding(.bottom, duoTabBarInsets.overlay)
234
- .background(
235
- GeometryReader { proxy in
236
- Color.clear
237
- .onAppear { footerHeight = proxy.size.height }
238
- .onChange(of: proxy.size.height) { footerHeight = $0 }
239
- }
240
- )
259
+ NavigationFooter(
260
+ footerComponent: footer,
261
+ keyboardSize: keyboardSize,
262
+ transparent: footerTransparent,
263
+ useMargin: footerUseMargin
264
+ )
265
+ .padding(.bottom, duoTabBarInsets.overlay)
266
+ .background(
267
+ GeometryReader { proxy in
268
+ Color.clear
269
+ .onAppear { footerHeight = proxy.size.height }
270
+ .onChange(of: proxy.size.height) { footerHeight = $0 }
271
+ }
272
+ )
241
273
  }
242
274
 
243
- // Animated search bar overlay (mirrors Compose's SearchAnimated layer).
244
275
  if inputSearchType == .animated, let props = inputSearchProps {
245
276
  SearchAnimatedHeader(
246
277
  props: props,
@@ -248,7 +279,6 @@ public struct StackScreen<Content: View>: View {
248
279
  hiddenBack: !isBack,
249
280
  hasHeaderRight: headerRight != nil,
250
281
  headerRightWidth: headerRightWidth,
251
- headerHeight: Self.headerHeight,
252
282
  useSideBar: layout.sideBar != nil,
253
283
  hasToolkit: toolkit != nil
254
284
  )
@@ -259,12 +289,7 @@ public struct StackScreen<Content: View>: View {
259
289
  sideBar: sideBar,
260
290
  goBack: goBack,
261
291
  toolkit: toolkit,
262
- headerColor: getHeaderColor(
263
- isAnimated: animatedHeader != nil,
264
- isSurface: animatedHeader?.isSurface ?? true,
265
- opacity: scrollOpacity,
266
- tintColor: tintColor
267
- ),
292
+ headerColor: headerColor,
268
293
  bottomInset: bottomBarsHeight
269
294
  )
270
295
  }
@@ -284,10 +309,11 @@ public struct StackScreen<Content: View>: View {
284
309
  keyboardHeight = keyboardReferenceHeight - endFrame.origin.y
285
310
  }
286
311
  }
287
- // Mirrors Compose's `hideKeyboardOnTap()`: a simultaneous gesture so
288
- // taps still reach buttons/fields while also dismissing the keyboard.
312
+ // Compose's `hideKeyboardOnTap()`: simultaneous, so taps still reach buttons and fields.
289
313
  .modifier(HideKeyboardOnTap(enabled: keyboardShouldPersistTaps))
290
314
  }
315
+ .onAppear(perform: reportBottomObstruction)
316
+ .onChange(of: bottomObstruction) { _ in reportBottomObstruction() }
291
317
  .task(id: screenName) {
292
318
  ScreenTracker.trackScreenNavigated(
293
319
  maxApi: appEnv.maxApi,
@@ -301,7 +327,7 @@ public struct StackScreen<Content: View>: View {
301
327
  } catch {
302
328
  return
303
329
  }
304
- let loadTime = Date().timeIntervalSince1970 * 1000 - trackingState.time
330
+ let loadTime = ScreenTracker.now() - trackingState.time
305
331
  ScreenTracker.trackScreenDisplayed(
306
332
  maxApi: appEnv.maxApi,
307
333
  context: appEnv.applicationContext,
@@ -309,7 +335,7 @@ public struct StackScreen<Content: View>: View {
309
335
  duration: loadTime,
310
336
  state: trackingState
311
337
  )
312
- let interactionTime = Date().timeIntervalSince1970 * 1000 - trackingState.time
338
+ let interactionTime = ScreenTracker.now() - trackingState.time
313
339
  ScreenTracker.trackScreenInteracted(
314
340
  maxApi: appEnv.maxApi,
315
341
  context: appEnv.applicationContext,
@@ -326,10 +352,8 @@ public struct StackScreen<Content: View>: View {
326
352
  @ViewBuilder
327
353
  private var headerAndContent: some View {
328
354
  if let animatedHeader = animatedHeader {
329
- // Animated (hero) header: the image is the top of the scroll content
330
- // and the body overlaps its lower part; the header bar background
331
- // fades in over the image as the screen scrolls (mirrors Compose's
332
- // ScreenContent for HeaderType.Animated).
355
+ // The banner is the top of the scroll content and the body overlaps its lower part;
356
+ // the bar fades in over it (Compose's ScreenContent for HeaderType.Animated).
333
357
  ZStack(alignment: .top) {
334
358
  StackScreenContent(
335
359
  scrollable: scrollable,
@@ -342,7 +366,7 @@ public struct StackScreen<Content: View>: View {
342
366
  .edgesIgnoringSafeArea(.all)
343
367
 
344
368
  header
345
- .background(
369
+ .background(alignment: .top) {
346
370
  Group {
347
371
  if layout.sideBar != nil {
348
372
  // glass shows once the banner has scrolled under the header
@@ -351,29 +375,29 @@ public struct StackScreen<Content: View>: View {
351
375
  visible: scrollOffset > heroGlassStart(animatedHeader)
352
376
  ))
353
377
  } else {
354
- Colors.black01.opacity(scrollOpacity)
378
+ HeaderBarBackground(opacity: opacity)
355
379
  }
356
380
  }
357
381
  .edgesIgnoringSafeArea([.top, .horizontal])
358
- )
382
+ }
359
383
  .offset(y: -headerHide)
360
384
  .zIndex(100)
361
385
  }
362
386
  } else if headerTransparent && fullScreenContent {
387
+ // Compose's HeaderType.Transparent(isFullScreenContent): content starts under a clear bar
363
388
  ZStack(alignment: .top) {
364
389
  header
365
- .background(
366
- NavigationHeaderBackground(
367
- headerType: headerType,
368
- scrollState: scrollOffset,
369
- headerTransparent: headerTransparent,
370
- fullScreenContent: fullScreenContent
371
- )
372
- )
373
390
  .zIndex(100)
374
391
 
375
- StackScreenContent(scrollable: scrollable, scrollOffset: $scrollOffset, scrollToTopCallback: scrollToTopCallback, animatedSearchInset: animatedSearchInset, bottomInset: duoTabBarInsets.overlay, content: { duoInsetContent })
376
- .edgesIgnoringSafeArea(.all)
392
+ StackScreenContent(
393
+ scrollable: scrollable,
394
+ scrollOffset: $scrollOffset,
395
+ scrollToTopCallback: scrollToTopCallback,
396
+ animatedSearchInset: animatedSearchInset,
397
+ bottomInset: duoTabBarInsets.overlay,
398
+ content: { duoInsetContent }
399
+ )
400
+ .edgesIgnoringSafeArea(.all)
377
401
  }
378
402
  } else if useDuoGlass {
379
403
  ZStack(alignment: .top) {
@@ -404,17 +428,29 @@ public struct StackScreen<Content: View>: View {
404
428
  }
405
429
  } else {
406
430
  header
407
- .background(
408
- NavigationHeaderBackground(
409
- headerType: headerType,
410
- scrollState: scrollOffset,
411
- headerTransparent: headerTransparent,
412
- fullScreenContent: fullScreenContent
413
- )
431
+ // top-aligned: an extended band runs past the bar, under the content
432
+ .background(alignment: .top) {
433
+ ZStack(alignment: .top) {
434
+ NavigationHeaderBackground(
435
+ headerType: headerType,
436
+ scrollState: scrollOffset,
437
+ headerTransparent: headerTransparent,
438
+ fullScreenContent: fullScreenContent,
439
+ backgroundColor: backgroundColor
440
+ )
441
+ HeaderBarBackground(opacity: opacity, transparent: headerTransparent || headerType == .none)
442
+ }
414
443
  .edgesIgnoringSafeArea([.top, .horizontal])
415
- )
444
+ }
416
445
 
417
- StackScreenContent(scrollable: scrollable, scrollOffset: $scrollOffset, scrollToTopCallback: scrollToTopCallback, animatedSearchInset: animatedSearchInset, bottomInset: duoTabBarInsets.overlay, content: content)
446
+ StackScreenContent(
447
+ scrollable: scrollable,
448
+ scrollOffset: $scrollOffset,
449
+ scrollToTopCallback: scrollToTopCallback,
450
+ animatedSearchInset: animatedSearchInset,
451
+ bottomInset: duoTabBarInsets.overlay,
452
+ content: content
453
+ )
418
454
  }
419
455
  }
420
456
 
@@ -430,9 +466,8 @@ public struct StackScreen<Content: View>: View {
430
466
  max(layout.width / animatedHeader.aspectRatio.value - layout.headerHeight, 0)
431
467
  }
432
468
 
433
- /// Hero image + body for an animated header. The image fills the width at its
434
- /// aspect-ratio height; the body is offset below the header band so it overlaps
435
- /// the lower part of the image (mirrors Compose's image box + padded content).
469
+ /// The banner fills the width at its aspect ratio; the body starts `layoutOffset` below the
470
+ /// header band so it overlaps the banner's lower part.
436
471
  @ViewBuilder
437
472
  private func animatedHeroBody(_ animatedHeader: AnimatedHeader) -> some View {
438
473
  let width = layout.width
@@ -458,11 +493,10 @@ public struct StackScreen<Content: View>: View {
458
493
  headerTitle: headerTitle,
459
494
  headerRight: headerRight,
460
495
  goBack: goBack,
461
- opacity: scrollOpacity,
496
+ opacity: opacity,
462
497
  animatedHeader: animatedHeader,
463
498
  scrollState: scrollOffset,
464
- // For the animated search the bar is rendered by the overlay, so the
465
- // header shows the (fading) title instead of an inline search field.
499
+ // the animated search draws its own field over the (fading) title
466
500
  inputSearchProps: inputSearchType == .header ? inputSearchProps : nil,
467
501
  tintColor: tintColor,
468
502
  titleOpacity: titleOpacity
@@ -470,18 +504,26 @@ public struct StackScreen<Content: View>: View {
470
504
  }
471
505
  }
472
506
 
507
+ // MARK: - Bottom-tab root preference
508
+
509
+ /// Set by `BottomTab` so the `StackScreen` hosting it keeps the full width and leaves the side
510
+ /// bar to the tabs (Compose's `Navigation.markAsBottomTabRoot()`).
511
+ public struct IsBottomTabRootKey: PreferenceKey {
512
+ public static var defaultValue: Bool = false
513
+ public static func reduce(value: inout Bool, nextValue: () -> Bool) {
514
+ value = value || nextValue()
515
+ }
516
+ }
517
+
473
518
  // MARK: - Screen content (scrollable body)
474
519
 
475
- /// Mirrors Compose's `ScreenContent`/`MainContent`: renders the screen body and,
476
- /// when scrollable, tracks the scroll offset so the header/FAB can react to it.
520
+ /// Compose's `MainContent` / `ScreenContent`: the body, tracking the scroll offset when scrollable.
477
521
  private struct StackScreenContent<Content: View>: View {
478
522
  let scrollable: Bool
479
523
  @Binding var scrollOffset: CGFloat
480
- /// Mirrors Compose's `ScrollData.scrollToTopCallback`; fired alongside the
481
- /// scroll-to-top triggered by a status-bar tap.
524
+ /// Compose's `ScrollData.scrollToTopCallback`, fired with the status-bar tap scroll-to-top.
482
525
  let scrollToTopCallback: (() -> Void)?
483
- /// Extra top space reserved for the animated search bar (mirrors Compose's
484
- /// MainContent animated top spacer). Zero for non-animated screens.
526
+ /// Room for the animated search below the header (Compose's MainContent spacer).
485
527
  var animatedSearchInset: CGFloat = 0
486
528
  /// Room for a header the content scrolls under (iPhone Duo glass header).
487
529
  var topInset: CGFloat = 0
@@ -497,9 +539,8 @@ private struct StackScreenContent<Content: View>: View {
497
539
  ScrollViewReader { proxy in
498
540
  ScrollView(.vertical, showsIndicators: false) {
499
541
  VStack(spacing: 0) {
500
- // Top probe + scroll-to-top anchor. Measured in the scroll's
501
- // own coordinate space so the offset is a clean 0-based
502
- // "pixels scrolled" value, independent of the inset below.
542
+ // Top probe + scroll-to-top anchor, measured in the scroll's own space so the
543
+ // offset is a clean 0-based "points scrolled", independent of the insets below.
503
544
  Color.clear
504
545
  .frame(height: 0)
505
546
  .id(topAnchorID)
@@ -527,8 +568,7 @@ private struct StackScreenContent<Content: View>: View {
527
568
  }
528
569
  }
529
570
  .coordinateSpace(name: scrollSpace)
530
- // Status-bar tap (via MoMoStatusBarTap swizzle) scrolls to top and
531
- // notifies the consumer, mirroring Compose's scrollToTopCallback.
571
+ // Status-bar tap (MoMoStatusBarTap swizzle) scrolls to top and notifies the consumer.
532
572
  .onReceive(NotificationCenter.default.publisher(for: MoMoStatusBarTap.notificationName)) { _ in
533
573
  withAnimation {
534
574
  proxy.scrollTo(topAnchorID, anchor: .top)
@@ -546,28 +586,26 @@ private struct StackScreenContent<Content: View>: View {
546
586
 
547
587
  // MARK: - Animated search header
548
588
 
549
- /// Mirrors Compose's `SearchAnimated`: the search bar slides from below the
550
- /// header (scroll 0) up into the header band as the user scrolls, while its
551
- /// horizontal insets grow to make room for the back button and header-right.
552
- /// Note: Compose's scroll-end snap and background-color lerp are not ported.
589
+ /// Compose's `SearchAnimated`: the search slides from below the header (scroll 0) up into the bar
590
+ /// while its insets grow to clear the back button and header right.
553
591
  private struct SearchAnimatedHeader: View {
554
592
  let props: InputSearchProps
555
593
  let scrollOffset: CGFloat
556
594
  let hiddenBack: Bool
557
595
  let hasHeaderRight: Bool
558
596
  let headerRightWidth: CGFloat
559
- let headerHeight: CGFloat
560
597
  var useSideBar: Bool = false
561
598
  var hasToolkit: Bool = false
562
599
 
563
600
  @Environment(\.screenLayout) private var layout
601
+ @Environment(\.appTheme) private var theme
564
602
 
565
603
  var body: some View {
566
604
  let scroll = max(scrollOffset, 0)
567
605
 
568
606
  // Insets are measured from the safe-area top; collapsed, the search is centered in the bar row.
569
607
  let rowTop = layout.headerStatusBarHeight - layout.insets.top
570
- let minTop = rowTop + (layout.headerBarHeight - headerHeight) / 2
608
+ let minTop = rowTop + (layout.headerBarHeight - HEADER_HEIGHT) / 2
571
609
  let maxTop = layout.headerHeight - layout.insets.top
572
610
  let minSide = Spacing.M
573
611
  let maxStart: CGFloat = hiddenBack || useSideBar ? Spacing.M : 48
@@ -581,8 +619,8 @@ private struct SearchAnimatedHeader: View {
581
619
 
582
620
  VStack(spacing: 0) {
583
621
  Spacer().frame(height: top)
584
- InputSearch(props: props)
585
- .frame(height: headerHeight)
622
+ InputSearch(props: headerSearchProps(props, scroll: scroll, layout: layout, theme: theme))
623
+ .frame(height: HEADER_HEIGHT)
586
624
  .padding(.leading, start)
587
625
  .padding(.trailing, end)
588
626
  Spacer(minLength: 0)
@@ -593,7 +631,7 @@ private struct SearchAnimatedHeader: View {
593
631
 
594
632
  // MARK: - Floating content
595
633
 
596
- /// Mirrors Compose's `FloatingContent`: no FAB props means nothing renders.
634
+ /// Compose's `FloatingContent`: no FAB props means nothing renders.
597
635
  private struct FloatingContent: View {
598
636
  let fabProps: FabProps?
599
637
  let keyboardSize: CGFloat
@@ -624,11 +662,106 @@ private struct FloatingContent: View {
624
662
  }
625
663
  }
626
664
 
665
+ // MARK: - Footer
666
+
667
+ /// Compose's `Footer`: the band pinned under the content — surface background, S/M padding,
668
+ /// the navigation-bar reserve, and a 6pt shadow cast up onto the content.
669
+ /// Named `NavigationFooter` to coexist with the deprecated `Screen`'s footer.
670
+ public struct NavigationFooter: View {
671
+ /// Nothing renders when nil.
672
+ var footerComponent: (() -> AnyView)?
673
+ /// Current keyboard height, measured from the bottom of the screen.
674
+ var keyboardSize: CGFloat
675
+ var transparent: Bool
676
+ var useMargin: Bool
677
+
678
+ public init(
679
+ footerComponent: (() -> AnyView)?,
680
+ keyboardSize: CGFloat = 0,
681
+ transparent: Bool = false,
682
+ useMargin: Bool = true
683
+ ) {
684
+ self.footerComponent = footerComponent
685
+ self.keyboardSize = keyboardSize
686
+ self.transparent = transparent
687
+ self.useMargin = useMargin
688
+ }
689
+
690
+ @Environment(\.appTheme) private var theme
691
+
692
+ /// Read from the window, not from an enclosing `GeometryProxy`: the proxy's bottom
693
+ /// inset grows to the keyboard height while the keyboard is up.
694
+ static var safeAreaBottom: CGFloat {
695
+ UIApplication.shared.connectedScenes
696
+ .compactMap { ($0 as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom }
697
+ .first ?? 0
698
+ }
699
+
700
+ /// Compose's `AppNavigationBar.current` (`getNavigationBarHeight` clamps iOS to 21pt).
701
+ static var navigationBarInset: CGFloat { min(safeAreaBottom, 21) }
702
+
703
+ /// Compose's `bottomPadding + Spacing.S` with the keyboard down. The footer is laid
704
+ /// out inside the safe area, which already covers part of that gap.
705
+ static var bottomPadding: CGFloat {
706
+ max(navigationBarInset + Spacing.S - safeAreaBottom, 0)
707
+ }
708
+
709
+ /// Deliberately an offset and not padding: growing the footer feeds back into SwiftUI's
710
+ /// keyboard avoidance, so a padding-based lift never settles at the right place.
711
+ /// Negative lifts the band over the keyboard; positive shaves the few points by which
712
+ /// the safe area over-reserves compared to Compose. `FloatingContent` rides the same
713
+ /// amount so the FAB keeps its distance above the band.
714
+ ///
715
+ /// Natural position is `safeAreaBottom + bottomPadding` above the bottom of the screen;
716
+ /// Compose puts it at `(AppNavigationBar - keyboard).coerceAtLeast(0) + Spacing.S` on
717
+ /// top of the `imePadding()` lift — i.e. `max(navigationBar, keyboard) + Spacing.S`.
718
+ static func verticalOffset(keyboardSize: CGFloat) -> CGFloat {
719
+ (safeAreaBottom + bottomPadding) - (max(navigationBarInset, keyboardSize) + Spacing.S)
720
+ }
721
+
722
+ public var body: some View {
723
+ if let footerComponent = footerComponent {
724
+ let margin: CGFloat = useMargin ? Spacing.S : 0
725
+ VStack(alignment: .leading, spacing: 0) {
726
+ footerComponent()
727
+ }
728
+ .frame(maxWidth: .infinity, alignment: .leading)
729
+ .padding(.top, margin)
730
+ .padding(.horizontal, useMargin ? Spacing.M : 0)
731
+ // without the margin the Spacing.S under the content goes too: negative, so the
732
+ // content sinks into the reserve while the frame keeps its bottom edge
733
+ .padding(.bottom, Self.bottomPadding - (Spacing.S - margin))
734
+ // Compose paints the surface all the way to the bottom of the screen; the
735
+ // footer's own frame stops at the safe area, so only the fill bleeds down.
736
+ .background(
737
+ (transparent ? Color.clear : theme.colors.background.surface)
738
+ .ignoresSafeArea(.container, edges: [.bottom, .horizontal])
739
+ )
740
+ .overlay(alignment: .top) {
741
+ if !transparent { shadow }
742
+ }
743
+ .offset(y: Self.verticalOffset(keyboardSize: keyboardSize))
744
+ }
745
+ }
746
+
747
+ /// Compose offsets the shadow box by -6dp, so it lands on the content above the
748
+ /// footer rather than on the footer surface itself.
749
+ private var shadow: some View {
750
+ LinearGradient(
751
+ colors: [Color.clear, Color.black.opacity(0.05)],
752
+ startPoint: .top,
753
+ endPoint: .bottom
754
+ )
755
+ .frame(height: 6)
756
+ .offset(y: -6)
757
+ .allowsHitTesting(false)
758
+ }
759
+ }
760
+
627
761
  // MARK: - Hide keyboard on tap
628
762
 
629
- /// Mirrors Compose's `Modifier.hideKeyboardOnTap()`. When enabled, a tap
630
- /// anywhere resigns the first responder. Uses `simultaneousGesture` so the tap
631
- /// is not consumed — buttons and text fields keep working.
763
+ /// Compose's `Modifier.hideKeyboardOnTap()`. Uses `simultaneousGesture` so the tap is not
764
+ /// consumed — buttons and text fields keep working.
632
765
  private struct HideKeyboardOnTap: ViewModifier {
633
766
  let enabled: Bool
634
767
 
@@ -9,7 +9,7 @@
9
9
  import Foundation
10
10
 
11
11
  public final class ScreenTrackingState {
12
- public var time: Double = Date().timeIntervalSince1970 * 1000
12
+ public var time: Double = ScreenTracker.now()
13
13
  public var releaseNavigated: Bool = false
14
14
  public var releaseLoad: Bool = false
15
15
  public var releaseInteraction: Bool = false
@@ -22,6 +22,12 @@ public final class ScreenTrackingState {
22
22
  public enum ScreenTracker {
23
23
  private static var lastScreenName: String?
24
24
 
25
+ /// Epoch milliseconds, the clock Compose tracks with.
26
+ public static func now() -> Double { Date().timeIntervalSince1970 * 1000 }
27
+
28
+ /// Compose sends whole milliseconds (`Long`).
29
+ private static func millis(_ value: Double) -> Int64 { Int64(value.rounded()) }
30
+
25
31
  public static func trackScreenNavigated(
26
32
  maxApi: MaxApi?,
27
33
  context: MiniAppContext?,
@@ -35,7 +41,8 @@ public enum ScreenTracker {
35
41
  lastScreenName = screenName
36
42
 
37
43
  var params = buildBaseParams(context: context, screenName: screenName)
38
- params["pre_screen_name"] = previousScreen
44
+ // keeps the key with a null value; a subscript assignment of nil would drop it
45
+ params.updateValue(previousScreen, forKey: "pre_screen_name")
39
46
  params["state"] = "navigated"
40
47
  params["action"] = action
41
48
  maxApi?.track(eventName: "auto_screen_navigated", params: params)
@@ -55,7 +62,7 @@ public enum ScreenTracker {
55
62
 
56
63
  var params = buildBaseParams(context: context, screenName: screenName)
57
64
  params["state"] = "load"
58
- params["duration"] = duration
65
+ params["duration"] = millis(duration)
59
66
  maxApi?.track(eventName: "auto_screen_displayed", params: params)
60
67
 
61
68
  state.releaseLoad = true
@@ -73,8 +80,8 @@ public enum ScreenTracker {
73
80
 
74
81
  var params = buildBaseParams(context: context, screenName: screenName)
75
82
  params["state"] = "interaction"
76
- params["duration"] = duration
77
- params["total_duration"] = totalDuration
83
+ params["duration"] = millis(duration)
84
+ params["total_duration"] = millis(totalDuration)
78
85
  maxApi?.track(eventName: "auto_screen_interacted", params: params)
79
86
 
80
87
  state.releaseInteraction = true