@momo-kits/native-kits 0.162.44 → 0.162.45-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 (263) hide show
  1. package/CLAUDE.md +45 -3
  2. package/README.md +2 -2
  3. package/build.gradle.kts +11 -0
  4. package/compose/build.gradle.kts +198 -0
  5. package/compose/build.gradle.kts.backup +198 -0
  6. package/compose/compose.podspec +47 -0
  7. package/compose/src/androidMain/kotlin/vn/momo/kits/const/HostFont.android.kt +19 -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 +124 -0
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +1071 -0
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Localize.kt +269 -0
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/application/WidgetContainer.kt +56 -0
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Avatar.kt +157 -0
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +81 -0
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeDot.kt +32 -0
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeRibbon.kt +338 -0
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BaselineView.kt +175 -0
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +357 -0
  23. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +115 -0
  24. package/compose/src/commonMain/kotlin/vn/momo/kits/components/CheckBox.kt +89 -0
  25. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +128 -0
  26. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Collapse.kt +209 -0
  27. package/compose/src/commonMain/kotlin/vn/momo/kits/components/CupertinoOverscroll.kt +542 -0
  28. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +49 -0
  29. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +86 -0
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/components/IconButton.kt +125 -0
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Image.kt +231 -0
  32. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +189 -0
  33. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +478 -0
  34. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputDropDown.kt +114 -0
  35. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +265 -0
  36. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +235 -0
  37. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +223 -0
  38. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +265 -0
  39. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +185 -0
  40. package/compose/src/commonMain/kotlin/vn/momo/kits/components/LazyColumnWithBouncing.kt +343 -0
  41. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Loader.kt +108 -0
  42. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationDot.kt +56 -0
  43. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationNumber.kt +41 -0
  44. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationScroll.kt +84 -0
  45. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationWhiteDot.kt +40 -0
  46. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +361 -0
  47. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +108 -0
  48. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +308 -0
  49. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +59 -0
  50. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Rating.kt +87 -0
  51. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ScaleSizeScope.kt +21 -0
  52. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Skeleton.kt +90 -0
  53. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +330 -0
  54. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Stepper.kt +220 -0
  55. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Steps.kt +473 -0
  56. package/compose/src/commonMain/kotlin/vn/momo/kits/components/SuggestAction.kt +122 -0
  57. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +207 -0
  58. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +92 -0
  59. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +560 -0
  60. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +86 -0
  61. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +125 -0
  62. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Title.kt +207 -0
  63. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +501 -0
  64. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TrustBanner.kt +176 -0
  65. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Uploader.kt +182 -0
  66. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePicker.kt +193 -0
  67. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerTypes.kt +29 -0
  68. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +236 -0
  69. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +180 -0
  70. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Colors.kt +325 -0
  71. package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +16 -0
  72. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitDesignSystem.kt +18 -0
  73. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitFontScale.kt +22 -0
  74. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Radius.kt +12 -0
  75. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Spacing.kt +16 -0
  76. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Theme.kt +194 -0
  77. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +283 -0
  78. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Card.kt +54 -0
  79. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +50 -0
  80. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +314 -0
  81. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Section.kt +41 -0
  82. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/AutomationId.kt +52 -0
  83. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Clickable.kt +66 -0
  84. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Conditional.kt +11 -0
  85. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/DeprecatedModifier.kt +104 -0
  86. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Shadow.kt +48 -0
  87. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Size.kt +51 -0
  88. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +338 -0
  89. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/HostBackGestureBridge.kt +58 -0
  90. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +137 -0
  91. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +116 -0
  92. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +205 -0
  93. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +399 -0
  94. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ScrollToTop.kt +8 -0
  95. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +732 -0
  96. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackTransition.kt +148 -0
  97. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/SwipeBackHaptic.kt +35 -0
  98. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +228 -0
  99. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +217 -0
  100. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/CurvedContainer.kt +86 -0
  101. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +97 -0
  102. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +377 -0
  103. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/FloatingButton.kt +169 -0
  104. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +455 -0
  105. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +103 -0
  106. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +106 -0
  107. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +74 -0
  108. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +399 -0
  109. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +39 -0
  110. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +363 -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/tracking/ScreenTracker.kt +127 -0
  114. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/BackGesture.kt +12 -0
  115. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ComposeLottieAnimation.kt +91 -0
  116. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/OsFontScale.kt +9 -0
  117. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +74 -0
  118. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +13 -0
  119. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Icons.kt +1329 -0
  120. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Resources.kt +55 -0
  121. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Tracking.kt +15 -0
  122. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +118 -0
  123. package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +40 -0
  124. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/HostBackGesture.ios.kt +45 -0
  125. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/ScrollToTop.ios.kt +32 -0
  126. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/BackGesture.ios.kt +38 -0
  127. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/OsFontScale.ios.kt +66 -0
  128. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +217 -0
  129. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +94 -0
  130. package/gradle/libs.versions.toml +69 -0
  131. package/gradle/wrapper/gradle-wrapper.jar +0 -0
  132. package/gradle/wrapper/gradle-wrapper.properties +8 -0
  133. package/gradle.properties +26 -0
  134. package/gradlew +252 -0
  135. package/gradlew.bat +94 -0
  136. package/ios/Application/ApplicationEnvironment.swift +17 -0
  137. package/ios/Application/MaxApi.swift +30 -3
  138. package/ios/Avatar/Avatar.swift +4 -3
  139. package/ios/Badge/Badge.swift +6 -4
  140. package/ios/Badge/BadgeDot.swift +1 -1
  141. package/ios/Badge/BadgeRibbon.swift +0 -1
  142. package/ios/Button/Button.swift +15 -12
  143. package/ios/CalculatorKeyboard/CalculatorKeyboard.swift +64 -18
  144. package/ios/Carousel/Carousel.swift +29 -12
  145. package/ios/Checkbox/Checkbox.swift +7 -3
  146. package/ios/Chip/Chip.swift +5 -4
  147. package/ios/Collapse/Collapse.swift +70 -19
  148. package/ios/Colors+Radius+Spacing/Colors.swift +10 -1
  149. package/ios/Colors+Radius+Spacing/Radius.swift +6 -0
  150. package/ios/DateTimePicker/DateTimePicker.swift +54 -20
  151. package/ios/DateTimePicker/WheelPicker.swift +103 -105
  152. package/ios/Divider/Divider.swift +37 -4
  153. package/ios/Extensions/Color++.swift +38 -9
  154. package/ios/Extensions/OptionalEnvironmentObject.swift +12 -0
  155. package/ios/Extensions/SkeletonUICompat.swift +11 -0
  156. package/ios/Icon/Icon.swift +21 -23
  157. package/ios/Icon/Icons.swift +1329 -0
  158. package/ios/IconButton/IconButton.swift +15 -7
  159. package/ios/Image/Image.swift +7 -23
  160. package/ios/Information/Information.swift +27 -9
  161. package/ios/Input/ErrorView.swift +2 -2
  162. package/ios/Input/Input.swift +160 -147
  163. package/ios/Input/InputPhoneNumber.swift +12 -9
  164. package/ios/Input/InputSearch.swift +107 -77
  165. package/ios/Input/InputTextArea.swift +128 -145
  166. package/ios/InputDropDown/InputDropDown.swift +10 -7
  167. package/ios/InputMoney/InputMoney.swift +33 -11
  168. package/ios/InputOTP/InputOTP.swift +22 -12
  169. package/ios/Layout/Card.swift +50 -0
  170. package/ios/Layout/GridContext.swift +187 -3
  171. package/ios/Layout/Item.swift +5 -4
  172. package/ios/Layout/ScreenLayout.swift +385 -0
  173. package/ios/Layout/Section.swift +4 -100
  174. package/ios/Loader/Loader.swift +5 -9
  175. package/ios/Lottie/LottieView.swift +14 -2
  176. package/ios/{Application/Navigation/Overplay → Navigation}/BottomSheet.swift +169 -29
  177. package/ios/Navigation/BottomTab/BottomTab.swift +332 -0
  178. package/ios/{Application/Navigation → Navigation}/BottomTab/BottomTabBar.swift +18 -7
  179. package/ios/Navigation/BottomTab/DuoBarCompression.swift +104 -0
  180. package/ios/Navigation/BottomTab/DuoTabBar.swift +329 -0
  181. package/ios/{Application → Navigation/Component}/FloatingButton.swift +43 -54
  182. package/ios/Navigation/Component/Header.swift +545 -0
  183. package/ios/Navigation/Component/HeaderBackground.swift +154 -0
  184. package/ios/Navigation/Component/HeaderJourney.swift +73 -0
  185. package/ios/Navigation/Component/HeaderLocation.swift +48 -0
  186. package/ios/Navigation/Component/HeaderRight.swift +425 -0
  187. package/ios/Navigation/Component/HeaderTitle.swift +36 -0
  188. package/ios/{Application/Navigation → Navigation/Component}/HeaderUser.swift +21 -24
  189. package/ios/Navigation/Component/SideBar.swift +183 -0
  190. package/ios/{Application/Navigation/Overplay → Navigation/Component}/SnackBar.swift +8 -8
  191. package/ios/Navigation/HostBackGestureBridge.swift +78 -0
  192. package/ios/{Application/Navigation/Overplay → Navigation}/ModalScreen.swift +9 -2
  193. package/ios/Navigation/Navigation.swift +204 -0
  194. package/ios/Navigation/NavigationContainer.swift +195 -0
  195. package/ios/{Application/Navigation → Navigation}/Navigator.swift +207 -107
  196. package/ios/Navigation/StackScreen.swift +930 -0
  197. package/ios/Navigation/StackTransition.swift +400 -0
  198. package/ios/Navigation/SwipeBackHaptic.swift +33 -0
  199. package/ios/{Application/Navigation → Navigation}/Tracking/ScreenTracker.swift +12 -5
  200. package/ios/OTPKeyboard/OTPKeyboard.swift +8 -11
  201. package/ios/Pagination/PaginationScroll.swift +2 -2
  202. package/ios/Popup/PopupDisplay.swift +72 -135
  203. package/ios/Popup/PopupInput.swift +10 -13
  204. package/ios/Popup/PopupLayout.swift +140 -0
  205. package/ios/Popup/PopupNotify.swift +59 -69
  206. package/ios/Popup/PopupPromotion.swift +31 -47
  207. package/ios/PopupView/FullscreenPopup.swift +13 -19
  208. package/ios/PopupView/PopupView.swift +1 -1
  209. package/ios/PopupView/Utils++.swift +0 -8
  210. package/ios/ProgressInfo/ProgressInfo.swift +4 -2
  211. package/ios/Radio/Radio.swift +2 -1
  212. package/ios/Rating/Rating.swift +9 -1
  213. package/ios/Resources/dot_loading.json +558 -0
  214. package/ios/Resources/lottie_circle_loader.json +1 -0
  215. package/ios/ScrollIndicator/ScrollIndicator.swift +11 -10
  216. package/ios/Skeleton/Skeleton.swift +22 -27
  217. package/ios/Slider/Slider.swift +16 -18
  218. package/ios/Stepper/Stepper.swift +6 -1
  219. package/ios/Steps/Steps.swift +8 -6
  220. package/ios/SuggestAction/SuggestAction.swift +5 -0
  221. package/ios/Swipeable/SwipeCell.swift +10 -6
  222. package/ios/Switch/Switch.swift +22 -20
  223. package/ios/TabView/TabView.swift +102 -21
  224. package/ios/Tag/Tag.swift +15 -4
  225. package/ios/Template/Logo/Logo.swift +2 -1
  226. package/ios/Template/TrustBanner/TrustBanner.swift +106 -54
  227. package/ios/Title/Title.swift +6 -17
  228. package/ios/Tooltip/Tooltip.swift +29 -14
  229. package/ios/Typography/FontScale.swift +3 -3
  230. package/ios/Typography/Text.swift +81 -19
  231. package/ios/Uploader/Uploader.swift +3 -1
  232. package/ios/native-kits.podspec +2 -1
  233. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  234. package/ios-demo/README.md +120 -0
  235. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ContentView.swift +1 -1
  236. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +80 -0
  237. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +2 -0
  238. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +173 -0
  239. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +77 -1
  240. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupDisplayDemo.swift +1 -0
  241. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +2 -2
  242. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +2 -0
  243. package/package.json +1 -1
  244. package/scripts/gen-ios-icons.mjs +36 -0
  245. package/settings.gradle.kts +64 -0
  246. package/ios/Application/Components.swift +0 -270
  247. package/ios/Application/ComposeApi.swift +0 -22
  248. package/ios/Application/HeaderRight.swift +0 -306
  249. package/ios/Application/Navigation/BottomTab/BottomTab.swift +0 -104
  250. package/ios/Application/Navigation/HeaderBackProps.swift +0 -66
  251. package/ios/Application/Navigation/HeaderTitle.swift +0 -57
  252. package/ios/Application/Navigation/NavigationContainer.swift +0 -213
  253. package/ios/Application/Navigation/NavigationOptions.swift +0 -109
  254. package/ios/Application/Navigation/NavigatorManager.swift +0 -22
  255. package/ios/Application/Navigation/component/Header.swift +0 -178
  256. package/ios/Application/Navigation/component/HeaderBackground.swift +0 -83
  257. package/ios/Application/Navigation/component/HeaderColor.swift +0 -65
  258. package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +0 -11
  259. package/ios/Application/Navigation/component/NavigationFooter.swift +0 -90
  260. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +0 -196
  261. package/ios/Application/Screen.swift +0 -250
  262. package/ios/Application/StackScreen.swift +0 -492
  263. /package/ios/{Application/Navigation → Navigation}/BottomTab/CurvedContainer.swift +0 -0
@@ -0,0 +1,50 @@
1
+ import SwiftUI
2
+
3
+ /// Rounded surface laid out on the kit grid (8pt gutter), inset by the screen margin.
4
+ /// Mirrors `vn.momo.kits.layout.Card`.
5
+ public struct CardView<Content: View>: View {
6
+ @Environment(\.appTheme) private var theme
7
+
8
+ // MARK: Lifecycle
9
+
10
+ public init(
11
+ useShadow: Bool = false,
12
+ backgroundImage: String? = nil,
13
+ backgroundColor: Color? = nil,
14
+ contentPadding: EdgeInsets = EdgeInsets(top: Spacing.M, leading: Spacing.M, bottom: Spacing.M, trailing: Spacing.M),
15
+ @ViewBuilder content: @escaping () -> Content
16
+ ) {
17
+ self.useShadow = useShadow
18
+ self.backgroundImage = backgroundImage
19
+ self.backgroundColor = backgroundColor
20
+ self.contentPadding = contentPadding
21
+ self.content = content
22
+ }
23
+
24
+ // MARK: Public
25
+
26
+ public var body: some View {
27
+ GridFlow(gutter: Spacing.S, content: content)
28
+ .padding(contentPadding)
29
+ .background(background)
30
+ .background(backgroundColor ?? theme.colors.background.surface)
31
+ .clipShape(RoundedRectangle(cornerRadius: Radius.M))
32
+ // Approximates Compose `shadow(6.dp)` elevation.
33
+ .shadow(color: Color.black.opacity(useShadow ? 0.12 : 0), radius: useShadow ? 6 : 0, x: 0, y: useShadow ? 3 : 0)
34
+ .padding(.horizontal, Spacing.M)
35
+ }
36
+
37
+ // MARK: Private
38
+
39
+ private let useShadow: Bool
40
+ private let backgroundImage: String?
41
+ private let backgroundColor: Color?
42
+ private let contentPadding: EdgeInsets
43
+ private let content: () -> Content
44
+
45
+ @ViewBuilder private var background: some View {
46
+ if let backgroundImage {
47
+ ImageView(backgroundImage, contentMode: .fill).clipped()
48
+ }
49
+ }
50
+ }
@@ -1,20 +1,31 @@
1
1
  import SwiftUI
2
2
 
3
- /// Grid metrics published by a layout container (`SectionView`) so span-based children
3
+ /// Grid metrics published by a layout container (`SectionView`, `CardView`) so span-based children
4
4
  /// can size themselves. Mirrors `vn.momo.kits.layout.GridContext`.
5
5
  public struct GridContext: Equatable {
6
6
  public let numberOfColumns: Int
7
7
  public let gutter: CGFloat
8
8
  public let sizePerSpan: CGFloat
9
+ /// Pixels per point; span sizes are floored onto the pixel grid when it is set.
10
+ public let displayScale: CGFloat
9
11
 
10
- public init(numberOfColumns: Int = 12, gutter: CGFloat = Spacing.M, sizePerSpan: CGFloat = 0) {
12
+ public init(
13
+ numberOfColumns: Int = 12,
14
+ gutter: CGFloat = Spacing.M,
15
+ sizePerSpan: CGFloat = 0,
16
+ displayScale: CGFloat = 0
17
+ ) {
11
18
  self.numberOfColumns = numberOfColumns
12
19
  self.gutter = gutter
13
20
  self.sizePerSpan = sizePerSpan
21
+ self.displayScale = displayScale
14
22
  }
15
23
 
16
24
  public func sizeForSpan(_ span: Int) -> CGFloat {
17
- sizePerSpan * CGFloat(span) + gutter * CGFloat(span - 1)
25
+ let size = sizePerSpan * CGFloat(span) + gutter * CGFloat(span - 1)
26
+ guard displayScale > 0 else { return size }
27
+ // Compose floors in pixels so a full row of spans never overflows by a sub-pixel.
28
+ return floor(size * displayScale) / displayScale
18
29
  }
19
30
  }
20
31
 
@@ -28,3 +39,176 @@ public extension EnvironmentValues {
28
39
  set { self[GridContextKey.self] = newValue }
29
40
  }
30
41
  }
42
+
43
+ /// Measures the width it is given, publishes the grid for it and wraps children in a flow row.
44
+ /// Shared body of `SectionView` and `CardView` (Compose `gridColumns()` + `gridContext()` + `FlowRow`).
45
+ struct GridFlow<Content: View>: View {
46
+ let gutter: CGFloat
47
+ let content: () -> Content
48
+
49
+ @Environment(\.screenLayout) private var screenLayout
50
+ @Environment(\.displayScale) private var displayScale
51
+ /// The Compose `BoxWithConstraints` width.
52
+ @State private var availableWidth: CGFloat = 0
53
+
54
+ var body: some View {
55
+ flow
56
+ .environment(\.gridContext, grid)
57
+ .frame(maxWidth: .infinity, alignment: .topLeading)
58
+ .background(widthReader)
59
+ }
60
+
61
+ private var grid: GridContext {
62
+ let columns = screenLayout.contentWidth > 600 ? 24 : 12
63
+ let column = (availableWidth - gutter * CGFloat(columns - 1)) / CGFloat(columns)
64
+ return GridContext(
65
+ numberOfColumns: columns,
66
+ gutter: gutter,
67
+ sizePerSpan: max(0, column),
68
+ displayScale: displayScale
69
+ )
70
+ }
71
+
72
+ @ViewBuilder private var flow: some View {
73
+ if #available(iOS 16.0, *) {
74
+ FlowRowLayout(gutter: gutter) { content() }
75
+ } else {
76
+ _VariadicView.Tree(FlowRowFallback(gutter: gutter, maxWidth: availableWidth)) { content() }
77
+ }
78
+ }
79
+
80
+ private var widthReader: some View {
81
+ GeometryReader { proxy in
82
+ Color.clear.preference(key: GridWidthKey.self, value: proxy.size.width)
83
+ }
84
+ .onPreferenceChange(GridWidthKey.self) { availableWidth = $0 }
85
+ }
86
+ }
87
+
88
+ private struct GridWidthKey: PreferenceKey {
89
+ static var defaultValue: CGFloat = 0
90
+
91
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
92
+ value = max(value, nextValue())
93
+ }
94
+ }
95
+
96
+ /// iOS 15 has no `Layout`: the children are measured, then pinned to the same rows as
97
+ /// `FlowRowLayout` through alignment guides.
98
+ private struct FlowRowFallback: _VariadicView_UnaryViewRoot {
99
+ let gutter: CGFloat
100
+ let maxWidth: CGFloat
101
+
102
+ func body(children: _VariadicView.Children) -> some View {
103
+ FlowRowPlacement(children: children, gutter: gutter, maxWidth: maxWidth)
104
+ }
105
+ }
106
+
107
+ private struct FlowRowPlacement: View {
108
+ let children: _VariadicView.Children
109
+ let gutter: CGFloat
110
+ let maxWidth: CGFloat
111
+ @State private var sizes: [AnyHashable: CGSize] = [:]
112
+
113
+ var body: some View {
114
+ let placement = place()
115
+ ZStack(alignment: .topLeading) {
116
+ ForEach(children) { child in
117
+ child
118
+ // FlowRowLayout proposes no height
119
+ .fixedSize(horizontal: false, vertical: true)
120
+ .background(
121
+ GeometryReader { proxy in
122
+ Color.clear.preference(key: FlowChildSizesKey.self, value: [child.id: proxy.size])
123
+ }
124
+ )
125
+ .alignmentGuide(.leading) { _ in -(placement.origins[child.id]?.x ?? 0) }
126
+ .alignmentGuide(.top) { _ in -(placement.origins[child.id]?.y ?? 0) }
127
+ }
128
+ }
129
+ .frame(height: placement.height, alignment: .topLeading)
130
+ .onPreferenceChange(FlowChildSizesKey.self) { sizes = $0 }
131
+ }
132
+
133
+ private func place() -> (origins: [AnyHashable: CGPoint], height: CGFloat) {
134
+ var origins: [AnyHashable: CGPoint] = [:]
135
+ var x: CGFloat = 0
136
+ var y: CGFloat = 0
137
+ var rowHeight: CGFloat = 0
138
+ for child in children {
139
+ let size = sizes[child.id] ?? .zero
140
+ // Sub-point tolerance: spans are computed to land exactly on `maxWidth`.
141
+ if x > 0, x + size.width > maxWidth + 0.5 {
142
+ x = 0
143
+ y += rowHeight + gutter
144
+ rowHeight = 0
145
+ }
146
+ origins[child.id] = CGPoint(x: x, y: y)
147
+ x += size.width + gutter
148
+ rowHeight = max(rowHeight, size.height)
149
+ }
150
+ return (origins, y + rowHeight)
151
+ }
152
+ }
153
+
154
+ private struct FlowChildSizesKey: PreferenceKey {
155
+ static var defaultValue: [AnyHashable: CGSize] = [:]
156
+
157
+ static func reduce(value: inout [AnyHashable: CGSize], nextValue: () -> [AnyHashable: CGSize]) {
158
+ value.merge(nextValue()) { $1 }
159
+ }
160
+ }
161
+
162
+ /// Wrapping row with equal horizontal/vertical spacing — the Compose `FlowRow` equivalent.
163
+ @available(iOS 16.0, *)
164
+ private struct FlowRowLayout: Layout {
165
+ struct Row {
166
+ var items: [(index: Int, size: CGSize)] = []
167
+ var width: CGFloat = 0
168
+ var height: CGFloat = 0
169
+ }
170
+
171
+ let gutter: CGFloat
172
+
173
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
174
+ let maxWidth = proposal.width ?? .infinity
175
+ let rows = rows(for: subviews, maxWidth: maxWidth)
176
+ let height = rows.reduce(0) { $0 + $1.height } + gutter * CGFloat(max(0, rows.count - 1))
177
+ return CGSize(width: proposal.width ?? rows.map(\.width).max() ?? 0, height: height)
178
+ }
179
+
180
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
181
+ var y = bounds.minY
182
+ for row in rows(for: subviews, maxWidth: bounds.width) {
183
+ var x = bounds.minX
184
+ for item in row.items {
185
+ subviews[item.index].place(
186
+ at: CGPoint(x: x, y: y),
187
+ anchor: .topLeading,
188
+ proposal: ProposedViewSize(item.size)
189
+ )
190
+ x += item.size.width + gutter
191
+ }
192
+ y += row.height + gutter
193
+ }
194
+ }
195
+
196
+ private func rows(for subviews: Subviews, maxWidth: CGFloat) -> [Row] {
197
+ var rows: [Row] = []
198
+ var current = Row()
199
+ for index in subviews.indices {
200
+ let size = subviews[index].sizeThatFits(ProposedViewSize(width: maxWidth, height: nil))
201
+ let widthIfAppended = current.items.isEmpty ? size.width : current.width + gutter + size.width
202
+ // Sub-point tolerance: spans are computed to land exactly on `maxWidth`.
203
+ if !current.items.isEmpty, widthIfAppended > maxWidth + 0.5 {
204
+ rows.append(current)
205
+ current = Row()
206
+ }
207
+ current.width = current.items.isEmpty ? size.width : current.width + gutter + size.width
208
+ current.height = max(current.height, size.height)
209
+ current.items.append((index, size))
210
+ }
211
+ if !current.items.isEmpty { rows.append(current) }
212
+ return rows
213
+ }
214
+ }
@@ -1,12 +1,13 @@
1
1
  import SwiftUI
2
2
 
3
- /// Span-sized cell inside a `SectionView`. Reads the grid published by its container;
3
+ /// Span-sized cell inside a `SectionView` / `CardView`. Reads the grid published by its container;
4
4
  /// without one it just wraps its content. Mirrors `vn.momo.kits.layout.Item`.
5
5
  public struct ItemView<Content: View>: View {
6
6
  // MARK: Lifecycle
7
7
 
8
+ /// `widthSpan` nil spans the full row, whatever the container's column count.
8
9
  public init(
9
- widthSpan: Int = 12,
10
+ widthSpan: Int? = nil,
10
11
  heightSpan: Int = 0,
11
12
  @ViewBuilder content: @escaping () -> Content
12
13
  ) {
@@ -26,14 +27,14 @@ public struct ItemView<Content: View>: View {
26
27
 
27
28
  // MARK: Private
28
29
 
29
- private let widthSpan: Int
30
+ private let widthSpan: Int?
30
31
  private let heightSpan: Int
31
32
  private let content: () -> Content
32
33
 
33
34
  @Environment(\.gridContext) private var grid
34
35
 
35
36
  private var width: CGFloat? {
36
- grid.map { $0.sizeForSpan(widthSpan) }
37
+ grid.map { $0.sizeForSpan(widthSpan ?? $0.numberOfColumns) }
37
38
  }
38
39
 
39
40
  private var height: CGFloat? {
@@ -0,0 +1,385 @@
1
+ //
2
+ // ScreenLayout.swift
3
+ // MoMoUIKits
4
+ //
5
+ // Port of momo-kits RN `useScreenLayout` (Layout/utils.ts) and `Application/device.ts`:
6
+ // window size, safe-area insets, content width, header geometry and the iPhone Duo side bar.
7
+ //
8
+
9
+ import SwiftUI
10
+ import UIKit
11
+
12
+ public enum SideBarEdge: Equatable {
13
+ case left, right
14
+ }
15
+
16
+ public struct SideBarLayout: Equatable {
17
+ public let edge: SideBarEdge
18
+ public let width: CGFloat
19
+ /// Preferred window y of the first control; StackScreen still keeps the column above the bottom bars.
20
+ public let top: CGFloat
21
+ /// Room kept free above the window bottom, clear of a camera at the bottom end of the strip.
22
+ public let bottom: CGFloat
23
+ }
24
+
25
+ /// Room the header row keeps from each side for the status bar clock and battery clusters.
26
+ public struct StatusBarClearance: Equatable {
27
+ public let left: CGFloat
28
+ public let right: CGFloat
29
+ }
30
+
31
+ public struct ScreenLayout: Equatable {
32
+ public let width: CGFloat
33
+ public let height: CGFloat
34
+ public let isIPhoneDuo: Bool
35
+ public let insets: EdgeInsets
36
+ public let insetLeft: CGFloat
37
+ public let insetRight: CGFloat
38
+ public let contentWidth: CGFloat
39
+ public let headerHeight: CGFloat
40
+ /// 0 when the status bar is off the top edge, so the bar row fills the header and keeps the title centered.
41
+ public let headerStatusBarHeight: CGFloat
42
+ /// iPhone Duo moves back and toolkit into a column in the side safe area; nil while they stay in the bar.
43
+ public let sideBar: SideBarLayout?
44
+ /// iPhone Duo inner display in portrait: the header row shares the status bar band, between its clusters.
45
+ public let statusBarClearance: StatusBarClearance?
46
+
47
+ public var headerBarHeight: CGFloat { headerHeight - headerStatusBarHeight }
48
+ }
49
+
50
+ // Measured by momo-kits RN (Layout/utils.ts) on the iOS 27.1 iPhone Duo simulator; keep both in sync.
51
+ enum DuoMetrics {
52
+ // Inner display is ~1.42, outer ~1.45; every slab iPhone is 1.77 or taller.
53
+ static let maxAspectRatio: CGFloat = 1.6
54
+ // Outer display is 466pt tall in landscape, inner 669pt.
55
+ static let innerMinHeight: CGFloat = 600
56
+ static let controlSize: CGFloat = 48
57
+ static let itemPitch: CGFloat = 50
58
+ static let firstItemTop: CGFloat = 170
59
+ static let firstItemTopLandscape: CGFloat = 120
60
+ // Outer display in portrait: the header is 4pt taller than the iPhone floor and its title
61
+ // lines up with the camera punchout (y 24–66 at the top of the strip).
62
+ static let outerHeaderHeight: CGFloat = 76
63
+ static let outerCameraCenterY: CGFloat = 45
64
+ // Outer display in landscape has no status bar in the strip: controls keep this far from
65
+ // both ends unless a reserved region (the camera at either end) asks for more.
66
+ static let edgeInset: CGFloat = 24
67
+ // Inner display in portrait: the clock and connectivity cluster sits at the top right corner
68
+ // (x 550–643 of 669, clock digits centered on y 47); its reserved region is taller than the glyphs.
69
+ static let innerStatusBarCenterY: CGFloat = 47
70
+ static let innerStatusBarWidth: CGFloat = 120
71
+ // Host app's iOS 26 tab bar on the inner display in portrait: a 62pt glass pill sized to its tabs,
72
+ // QR as a 62pt platter beside it, ≥ 20pt from the sides, 13pt into the home indicator inset.
73
+ static let floatingTabBarHeight: CGFloat = 62
74
+ static let floatingTabBarMargin: CGFloat = 20
75
+ static let floatingTabBarInsetOverlap: CGFloat = 13
76
+ // Widest short side of any single-display iPhone.
77
+ static let iPhoneMaxShortSide: CGFloat = 440
78
+ }
79
+
80
+ enum HeaderMetrics {
81
+ static let barHeight: CGFloat = HEADER_HEIGHT
82
+ static let iPhoneMinHeight: CGFloat = 72
83
+ // A smaller top inset means the status bar is not on the top edge.
84
+ static let statusBarMinInset: CGFloat = 20
85
+ }
86
+
87
+ private var isPhoneIdiom: Bool { UIDevice.current.userInterfaceIdiom == .phone }
88
+
89
+ private var keyWindow: UIWindow? {
90
+ UIApplication.shared.connectedScenes
91
+ .compactMap { ($0 as? UIWindowScene)?.keyWindow }
92
+ .first
93
+ }
94
+
95
+ extension ScreenLayout {
96
+ /// Snapshot of the key window; not reactive, so views read `\.screenLayout` instead.
97
+ public static var current: ScreenLayout {
98
+ guard let window = keyWindow else {
99
+ return make(size: UIScreen.main.bounds.size, insets: EdgeInsets())
100
+ }
101
+ let inset = window.safeAreaInsets
102
+ return make(
103
+ size: window.bounds.size,
104
+ insets: EdgeInsets(top: inset.top, leading: inset.left, bottom: inset.bottom, trailing: inset.right)
105
+ )
106
+ }
107
+
108
+ static func make(
109
+ size: CGSize,
110
+ insets: EdgeInsets,
111
+ reservedRegions: [CGRect] = ReservedRegions.current,
112
+ isPhone: Bool = isPhoneIdiom
113
+ ) -> ScreenLayout {
114
+ let width = size.width
115
+ let height = size.height
116
+ var (headerHeight, headerStatusBarHeight) = headerGeometry(topInset: insets.top, isPhone: isPhone)
117
+ let isDuo = isPhone && isIPhoneDuoWindow(width: width, height: height, insets: insets)
118
+ guard isDuo else {
119
+ return ScreenLayout(
120
+ width: width, height: height, isIPhoneDuo: false, insets: insets,
121
+ insetLeft: 0, insetRight: 0, contentWidth: width,
122
+ headerHeight: headerHeight, headerStatusBarHeight: headerStatusBarHeight, sideBar: nil,
123
+ statusBarClearance: nil
124
+ )
125
+ }
126
+
127
+ var statusBarClearance: StatusBarClearance?
128
+ let isInnerPortrait = height > width && width >= DuoMetrics.innerMinHeight
129
+ if isInnerPortrait && insets.top >= HeaderMetrics.statusBarMinInset {
130
+ statusBarClearance = statusBarClusters(reservedRegions, windowWidth: width, topInset: insets.top)
131
+ ?? StatusBarClearance(left: 0, right: DuoMetrics.innerStatusBarWidth + Spacing.S)
132
+ // centered on the clock digits; the cluster's region center sits above them
133
+ headerHeight = DuoMetrics.innerStatusBarCenterY * 2
134
+ headerStatusBarHeight = 0
135
+ }
136
+
137
+ var sideBar: SideBarLayout?
138
+ let sideInset = max(insets.leading, insets.trailing)
139
+ // narrower strips cannot host a control 12pt from the inner edge
140
+ if sideInset >= DuoMetrics.controlSize + Spacing.M {
141
+ let edge: SideBarEdge = insets.trailing >= insets.leading ? .right : .left
142
+ let stripLeft = edge == .right ? width - sideInset : 0
143
+ // clear of the camera and of the status bar cluster, which grows for calls and Live
144
+ // Activities; region frames already include the system's margin
145
+ let occlusion = stripOcclusion(reservedRegions, stripLeft: stripLeft, stripRight: stripLeft + sideInset, windowHeight: height)
146
+
147
+ var top = statusBarClearance != nil
148
+ ? headerHeight + Spacing.M
149
+ : insets.top + HeaderMetrics.barHeight + Spacing.M
150
+ if insets.top < HeaderMetrics.statusBarMinInset {
151
+ // status bar lives in the strip: start under its cluster, as the native bar does
152
+ if width <= height {
153
+ top = DuoMetrics.firstItemTop
154
+ } else if height >= DuoMetrics.innerMinHeight {
155
+ top = DuoMetrics.firstItemTopLandscape
156
+ } else {
157
+ // outer display in landscape: no status bar in the strip, the title row centers
158
+ // on the camera when it sits at the top end, otherwise on the back button
159
+ top = DuoMetrics.edgeInset
160
+ let titleCenterY = occlusion.top > top ? occlusion.topCenter : top + DuoMetrics.controlSize / 2
161
+ (headerHeight, headerStatusBarHeight) = centeredHeaderGeometry(centerY: titleCenterY)
162
+ }
163
+
164
+ if height > width && width < DuoMetrics.innerMinHeight {
165
+ // outer display: the row starts below the top so the title centers on the camera
166
+ headerHeight = DuoMetrics.outerHeaderHeight
167
+ headerStatusBarHeight = DuoMetrics.outerCameraCenterY * 2 - DuoMetrics.outerHeaderHeight
168
+ }
169
+ }
170
+
171
+ sideBar = SideBarLayout(
172
+ edge: edge,
173
+ width: sideInset,
174
+ top: max(top, occlusion.top),
175
+ bottom: max(DuoMetrics.edgeInset, occlusion.bottom)
176
+ )
177
+ }
178
+
179
+ return ScreenLayout(
180
+ width: width, height: height, isIPhoneDuo: true, insets: insets,
181
+ insetLeft: insets.leading, insetRight: insets.trailing,
182
+ contentWidth: width - insets.leading - insets.trailing,
183
+ headerHeight: headerHeight, headerStatusBarHeight: headerStatusBarHeight, sideBar: sideBar,
184
+ statusBarClearance: statusBarClearance
185
+ )
186
+ }
187
+
188
+ private static func headerGeometry(topInset: CGFloat, isPhone: Bool) -> (CGFloat, CGFloat) {
189
+ let height = topInset + HeaderMetrics.barHeight
190
+ guard isPhone else { return (height, topInset) }
191
+ return (
192
+ max(height, HeaderMetrics.iPhoneMinHeight),
193
+ topInset < HeaderMetrics.statusBarMinInset ? 0 : topInset
194
+ )
195
+ }
196
+
197
+ /// Bar row centered on a window y, at least iPhoneMinHeight tall and reaching 8pt below
198
+ /// a side bar control centered there.
199
+ private static func centeredHeaderGeometry(centerY: CGFloat) -> (CGFloat, CGFloat) {
200
+ let height = max(HeaderMetrics.iPhoneMinHeight, centerY + DuoMetrics.controlSize / 2 + Spacing.S)
201
+ return (height, max(0, centerY * 2 - height))
202
+ }
203
+
204
+ /// How far system regions reach into a side strip from its top and from its bottom end
205
+ /// (e.g. the camera, which sits at either end depending on the rotation), and the center y
206
+ /// of the region reaching furthest from the top.
207
+ private static func stripOcclusion(
208
+ _ regions: [CGRect],
209
+ stripLeft: CGFloat,
210
+ stripRight: CGFloat,
211
+ windowHeight: CGFloat
212
+ ) -> (top: CGFloat, topCenter: CGFloat, bottom: CGFloat) {
213
+ regions.reduce(into: (top: CGFloat(0), topCenter: CGFloat(0), bottom: CGFloat(0))) { occlusion, region in
214
+ guard region.minX < stripRight && region.maxX > stripLeft else { return }
215
+ if region.midY < windowHeight / 2 {
216
+ if region.maxY > occlusion.top {
217
+ occlusion.top = region.maxY
218
+ occlusion.topCenter = region.midY
219
+ }
220
+ } else {
221
+ occlusion.bottom = max(occlusion.bottom, windowHeight - region.minY)
222
+ }
223
+ }
224
+ }
225
+
226
+ /// How far the clock and battery clusters of a status bar on the top edge reach in from each
227
+ /// side; nil without reserved regions there.
228
+ private static func statusBarClusters(_ regions: [CGRect], windowWidth: CGFloat, topInset: CGFloat) -> StatusBarClearance? {
229
+ let midX = windowWidth / 2
230
+ let clusters = regions.filter {
231
+ $0.minY < topInset && $0.maxY <= topInset && ($0.maxX <= midX || $0.minX >= midX)
232
+ }
233
+ guard !clusters.isEmpty else { return nil }
234
+ var left: CGFloat = 0
235
+ var right: CGFloat = 0
236
+ for region in clusters {
237
+ if region.minX < midX {
238
+ left = max(left, region.maxX)
239
+ } else {
240
+ right = max(right, windowWidth - region.minX)
241
+ }
242
+ }
243
+ return StatusBarClearance(left: left, right: right)
244
+ }
245
+
246
+ // Slab iPhones report equal side insets in landscape; iPhone Duo moves the status bar to one side.
247
+ private static func isIPhoneDuoWindow(width: CGFloat, height: CGFloat, insets: EdgeInsets) -> Bool {
248
+ let shortSide = min(width, height)
249
+ let isSquare = shortSide > 0 && max(width, height) / shortSide < DuoMetrics.maxAspectRatio
250
+ return isSquare || insets.leading != insets.trailing
251
+ }
252
+
253
+ /// Below the side status bar: in portrait it is about twice as tall as its inset is wide.
254
+ public var duoTopInset: CGFloat {
255
+ max(insets.top, height > width ? max(insets.leading, insets.trailing) * 2 : 0)
256
+ }
257
+ }
258
+
259
+ /// Port of RN `device.ts` `isIPhoneDuo()`: UIScreen stays on the outer display, so this
260
+ /// still matches when Split View narrows the window. Used by popups and overlays.
261
+ public func isIPhoneDuoDevice(windowSize: CGSize? = nil) -> Bool {
262
+ guard isPhoneIdiom else { return false }
263
+ let screen = UIScreen.main.bounds.size
264
+ let window = windowSize ?? keyWindow?.bounds.size ?? screen
265
+ return max(min(screen.width, screen.height), min(window.width, window.height)) > DuoMetrics.iPhoneMaxShortSide
266
+ }
267
+
268
+ // MARK: - Reserved regions
269
+
270
+ /// System occlusion regions (iOS 27.1+) in window coordinates: the iPhone Duo camera, and the
271
+ /// status bar cluster as it grows for a call or a Live Activity. Empty before iOS 27.1.
272
+ enum ReservedRegions {
273
+ // last report of a probe, so `ScreenLayout.current` includes it
274
+ private(set) static var current: [CGRect] = []
275
+ static let didChange = Notification.Name("MoMoUIKitsReservedRegionsDidChange")
276
+
277
+ fileprivate static func publish(_ regions: [CGRect]) {
278
+ guard regions != current else { return }
279
+ current = regions
280
+ NotificationCenter.default.post(name: didChange, object: nil)
281
+ }
282
+ }
283
+
284
+ /// Invisible probe: reading the regions in layoutSubviews makes UIKit run it again when they
285
+ /// change, so nothing polls. Port of RN `@momo-kits/reserved-regions`.
286
+ private final class ReservedRegionsProbe: UIView {
287
+ override init(frame: CGRect) {
288
+ super.init(frame: frame)
289
+ isUserInteractionEnabled = false
290
+ }
291
+
292
+ required init?(coder: NSCoder) { nil }
293
+
294
+ override func layoutSubviews() {
295
+ super.layoutSubviews()
296
+ // read even when not publishing, so UIKit keeps re-running this when they change
297
+ let regions = occlusionRegions()
298
+ // every screen has a probe but the regions are window-wide: one sliding in on a push or
299
+ // a present, or off the window, would overwrite them with a partial read
300
+ guard spansWindow else { return }
301
+ // layout pass: publish on the next turn instead of mutating SwiftUI state mid-update
302
+ DispatchQueue.main.async { ReservedRegions.publish(regions) }
303
+ }
304
+
305
+ private var spansWindow: Bool {
306
+ guard let window else { return false }
307
+ let frame = convert(bounds, to: nil)
308
+ return abs(frame.minX) < 1 && abs(frame.maxX - window.bounds.width) < 1 && abs(frame.minY) < 1
309
+ }
310
+
311
+ override func didMoveToWindow() {
312
+ super.didMoveToWindow()
313
+ setNeedsLayout()
314
+ }
315
+
316
+ // The API is newer than the SDKs hosts may build with, so it is reached through the runtime.
317
+ private func occlusionRegions() -> [CGRect] {
318
+ let regionsOfKind = NSSelectorFromString("reservedRegionsOfKind:")
319
+ guard window != nil, responds(to: regionsOfKind),
320
+ let kindType = NSClassFromString("UIViewReservedRegionKind") as? NSObject.Type,
321
+ let kind = (kindType as AnyObject).perform(NSSelectorFromString("occlusionRegionKind"))?.takeUnretainedValue(),
322
+ let regions = perform(regionsOfKind, with: kind)?.takeUnretainedValue() as? [NSObject]
323
+ else { return [] }
324
+ return regions.compactMap { ($0.value(forKey: "frame") as? NSValue)?.cgRectValue }
325
+ .map { convert($0, to: nil) }
326
+ }
327
+ }
328
+
329
+ private struct ReservedRegionsProbeView: UIViewRepresentable {
330
+ func makeUIView(context: Context) -> ReservedRegionsProbe { ReservedRegionsProbe() }
331
+
332
+ func updateUIView(_ probe: ReservedRegionsProbe, context: Context) {}
333
+ }
334
+
335
+ // MARK: - Environment
336
+
337
+ private struct ScreenLayoutKey: EnvironmentKey {
338
+ // Computed so an un-injected subtree (bare UIHostingController) still reads the live window.
339
+ static var defaultValue: ScreenLayout { .current }
340
+ }
341
+
342
+ public extension EnvironmentValues {
343
+ var screenLayout: ScreenLayout {
344
+ get { self[ScreenLayoutKey.self] }
345
+ set { self[ScreenLayoutKey.self] = newValue }
346
+ }
347
+ }
348
+
349
+ /// Re-reads the window whenever this view's size changes (rotation, fold/unfold, Split View).
350
+ private struct ScreenLayoutReader: ViewModifier {
351
+ @State private var layout: ScreenLayout = .current
352
+
353
+ func body(content: Content) -> some View {
354
+ content
355
+ .environment(\.screenLayout, layout)
356
+ .background(
357
+ GeometryReader { proxy in
358
+ Color.clear
359
+ .onAppear { refresh() }
360
+ .onChange(of: proxy.size) { _ in refresh() }
361
+ .onChange(of: proxy.safeAreaInsets) { _ in refresh() }
362
+ }
363
+ )
364
+ .background(ReservedRegionsProbeView().ignoresSafeArea())
365
+ // any screen's probe may report, so every reader follows the shared value
366
+ .onReceive(NotificationCenter.default.publisher(for: ReservedRegions.didChange)) { _ in
367
+ refresh()
368
+ }
369
+ .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
370
+ refresh()
371
+ }
372
+ }
373
+
374
+ private func refresh() {
375
+ let next = ScreenLayout.current
376
+ if next != layout { layout = next }
377
+ }
378
+ }
379
+
380
+ public extension View {
381
+ /// Provides `\.screenLayout`; NavigationContainer applies it, a bare UIHostingController root should too.
382
+ func momoScreenLayout() -> some View {
383
+ modifier(ScreenLayoutReader())
384
+ }
385
+ }