@momo-kits/native-kits 0.167.1-duo.1-debug → 0.167.1-duo.10-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 (177) hide show
  1. package/CLAUDE.md +32 -0
  2. package/compose/build.gradle.kts +15 -1
  3. package/compose/build.gradle.kts.backup +14 -0
  4. package/compose/compose.podspec +1 -1
  5. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +2 -0
  6. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +6 -0
  7. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +20 -21
  8. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +2 -1
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +4 -2
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +1 -1
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +1 -1
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +1 -1
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +1 -1
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +9 -6
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +11 -12
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +4 -9
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +2 -2
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +10 -9
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +6 -6
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +53 -26
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +12 -2
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +7 -9
  23. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +1 -1
  24. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +13 -6
  25. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +13 -5
  26. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +3 -2
  27. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +14 -6
  28. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +19 -21
  29. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +2 -2
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +5 -2
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +3 -1
  32. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +7 -15
  33. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Card.kt +13 -11
  34. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +21 -9
  35. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +232 -0
  36. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Section.kt +11 -9
  37. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +43 -4
  38. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +8 -0
  39. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +4 -0
  40. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +143 -31
  41. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +100 -66
  42. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +22 -16
  43. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +98 -0
  44. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +312 -0
  45. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +66 -18
  46. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +23 -14
  47. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +3 -3
  48. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +1 -2
  49. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +81 -24
  50. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +1 -2
  51. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +185 -0
  52. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +2 -1
  53. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +3 -0
  54. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +13 -0
  55. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +4 -1
  56. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +4 -0
  57. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +86 -0
  58. package/gradle.properties +1 -1
  59. package/ios/Application/ApplicationEnvironment.swift +17 -0
  60. package/ios/Application/ComposeApi.swift +2 -1
  61. package/ios/Application/{Components.swift → LegacyHeader.swift} +7 -65
  62. package/ios/Application/Screen.swift +3 -2
  63. package/ios/Avatar/Avatar.swift +4 -3
  64. package/ios/Badge/Badge.swift +6 -4
  65. package/ios/Badge/BadgeDot.swift +1 -1
  66. package/ios/Badge/BadgeRibbon.swift +0 -1
  67. package/ios/Button/Button.swift +15 -12
  68. package/ios/CalculatorKeyboard/CalculatorKeyboard.swift +64 -18
  69. package/ios/Carousel/Carousel.swift +29 -12
  70. package/ios/Checkbox/Checkbox.swift +7 -3
  71. package/ios/Chip/Chip.swift +5 -4
  72. package/ios/Collapse/Collapse.swift +70 -19
  73. package/ios/Colors+Radius+Spacing/Colors.swift +10 -1
  74. package/ios/DateTimePicker/DateTimePicker.swift +54 -20
  75. package/ios/DateTimePicker/WheelPicker.swift +103 -105
  76. package/ios/Divider/Divider.swift +37 -4
  77. package/ios/Extensions/Color++.swift +38 -9
  78. package/ios/Extensions/OptionalEnvironmentObject.swift +12 -0
  79. package/ios/Extensions/SkeletonUICompat.swift +11 -0
  80. package/ios/Icon/Icon.swift +21 -23
  81. package/ios/Icon/Icons.swift +1329 -0
  82. package/ios/IconButton/IconButton.swift +15 -7
  83. package/ios/Image/Image.swift +7 -23
  84. package/ios/Information/Information.swift +27 -9
  85. package/ios/Input/ErrorView.swift +2 -2
  86. package/ios/Input/Input.swift +160 -147
  87. package/ios/Input/InputPhoneNumber.swift +12 -9
  88. package/ios/Input/InputSearch.swift +102 -75
  89. package/ios/Input/InputTextArea.swift +128 -145
  90. package/ios/InputDropDown/InputDropDown.swift +10 -7
  91. package/ios/InputMoney/InputMoney.swift +33 -11
  92. package/ios/InputOTP/InputOTP.swift +22 -12
  93. package/ios/Layout/Card.swift +50 -0
  94. package/ios/Layout/GridContext.swift +123 -3
  95. package/ios/Layout/Item.swift +5 -4
  96. package/ios/Layout/ScreenLayout.swift +312 -0
  97. package/ios/Layout/Section.swift +4 -100
  98. package/ios/Loader/Loader.swift +5 -9
  99. package/ios/Lottie/LottieView.swift +14 -2
  100. package/ios/{Application/Navigation/Overplay → Navigation}/BottomSheet.swift +97 -8
  101. package/ios/Navigation/BottomTab/BottomTab.swift +187 -0
  102. package/ios/{Application/Navigation → Navigation}/BottomTab/BottomTabBar.swift +15 -7
  103. package/ios/Navigation/BottomTab/DuoBarCompression.swift +105 -0
  104. package/ios/Navigation/BottomTab/DuoTabBar.swift +304 -0
  105. package/ios/{Application → Navigation/Component}/FloatingButton.swift +14 -3
  106. package/ios/Navigation/Component/Header.swift +493 -0
  107. package/ios/Navigation/Component/HeaderBackground.swift +134 -0
  108. package/ios/Navigation/Component/HeaderJourney.swift +73 -0
  109. package/ios/Navigation/Component/HeaderLocation.swift +48 -0
  110. package/ios/Navigation/Component/HeaderRight.swift +386 -0
  111. package/ios/Navigation/Component/HeaderTitle.swift +36 -0
  112. package/ios/{Application/Navigation → Navigation/Component}/HeaderUser.swift +21 -24
  113. package/ios/Navigation/Component/SideBar.swift +176 -0
  114. package/ios/{Application/Navigation/Overplay → Navigation/Component}/SnackBar.swift +5 -5
  115. package/ios/Navigation/HostBackGestureBridge.swift +57 -0
  116. package/ios/{Application/Navigation/Overplay → Navigation}/ModalScreen.swift +5 -1
  117. package/ios/Navigation/Navigation.swift +190 -0
  118. package/ios/{Application/Navigation → Navigation}/NavigationContainer.swift +92 -81
  119. package/ios/{Application/Navigation → Navigation}/Navigator.swift +185 -36
  120. package/ios/Navigation/StackScreen.swift +782 -0
  121. package/ios/{Application/Navigation → Navigation}/Tracking/ScreenTracker.swift +12 -5
  122. package/ios/OTPKeyboard/OTPKeyboard.swift +8 -11
  123. package/ios/Pagination/PaginationScroll.swift +2 -2
  124. package/ios/Popup/PopupDisplay.swift +72 -136
  125. package/ios/Popup/PopupInput.swift +10 -13
  126. package/ios/Popup/PopupLayout.swift +140 -0
  127. package/ios/Popup/PopupNotify.swift +56 -73
  128. package/ios/Popup/PopupPromotion.swift +31 -47
  129. package/ios/PopupView/FullscreenPopup.swift +13 -19
  130. package/ios/PopupView/PopupView.swift +1 -1
  131. package/ios/PopupView/Utils++.swift +0 -8
  132. package/ios/ProgressInfo/ProgressInfo.swift +4 -2
  133. package/ios/Radio/Radio.swift +2 -1
  134. package/ios/Rating/Rating.swift +9 -1
  135. package/ios/Resources/dot_loading.json +558 -0
  136. package/ios/Resources/lottie_circle_loader.json +1 -0
  137. package/ios/ScrollIndicator/ScrollIndicator.swift +11 -10
  138. package/ios/Skeleton/Skeleton.swift +22 -27
  139. package/ios/Slider/Slider.swift +16 -18
  140. package/ios/Stepper/Stepper.swift +6 -1
  141. package/ios/Steps/Steps.swift +8 -6
  142. package/ios/SuggestAction/SuggestAction.swift +5 -0
  143. package/ios/Swipeable/SwipeCell.swift +10 -6
  144. package/ios/Switch/Switch.swift +22 -20
  145. package/ios/TabView/TabView.swift +102 -21
  146. package/ios/Tag/Tag.swift +15 -4
  147. package/ios/Template/Logo/Logo.swift +2 -1
  148. package/ios/Template/TrustBanner/TrustBanner.swift +106 -54
  149. package/ios/Title/Title.swift +6 -17
  150. package/ios/Tooltip/Tooltip.swift +29 -14
  151. package/ios/Typography/Text.swift +61 -19
  152. package/ios/Uploader/Uploader.swift +3 -1
  153. package/ios/native-kits.podspec +2 -1
  154. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  155. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ContentView.swift +1 -1
  156. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +80 -0
  157. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +1 -0
  158. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +173 -0
  159. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +77 -1
  160. package/package.json +1 -1
  161. package/scripts/gen-ios-icons.mjs +36 -0
  162. package/.claude/settings.local.json +0 -185
  163. package/ios/Application/HeaderRight.swift +0 -306
  164. package/ios/Application/Navigation/BottomTab/BottomTab.swift +0 -104
  165. package/ios/Application/Navigation/HeaderBackProps.swift +0 -66
  166. package/ios/Application/Navigation/HeaderTitle.swift +0 -57
  167. package/ios/Application/Navigation/NavigationOptions.swift +0 -109
  168. package/ios/Application/Navigation/NavigatorManager.swift +0 -22
  169. package/ios/Application/Navigation/component/Header.swift +0 -178
  170. package/ios/Application/Navigation/component/HeaderBackground.swift +0 -83
  171. package/ios/Application/Navigation/component/HeaderColor.swift +0 -65
  172. package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +0 -11
  173. package/ios/Application/Navigation/component/NavigationFooter.swift +0 -90
  174. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +0 -196
  175. package/ios/Application/StackScreen.swift +0 -492
  176. package/local.properties +0 -8
  177. /package/ios/{Application/Navigation → Navigation}/BottomTab/CurvedContainer.swift +0 -0
@@ -0,0 +1,312 @@
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
+ public struct ScreenLayout: Equatable {
26
+ public let width: CGFloat
27
+ public let height: CGFloat
28
+ public let isIPhoneDuo: Bool
29
+ public let insets: EdgeInsets
30
+ public let insetLeft: CGFloat
31
+ public let insetRight: CGFloat
32
+ public let contentWidth: CGFloat
33
+ public let headerHeight: CGFloat
34
+ /// 0 when the status bar is off the top edge, so the bar row fills the header and keeps the title centered.
35
+ public let headerStatusBarHeight: CGFloat
36
+ /// iPhone Duo moves back and toolkit into a column in the side safe area; nil while they stay in the bar.
37
+ public let sideBar: SideBarLayout?
38
+
39
+ public var headerBarHeight: CGFloat { headerHeight - headerStatusBarHeight }
40
+ }
41
+
42
+ // Measured by momo-kits RN (Layout/utils.ts) on the iOS 27.1 iPhone Duo simulator; keep both in sync.
43
+ enum DuoMetrics {
44
+ // Inner display is ~1.42, outer ~1.45; every slab iPhone is 1.77 or taller.
45
+ static let maxAspectRatio: CGFloat = 1.6
46
+ // Outer display is 466pt tall in landscape, inner 669pt.
47
+ static let innerMinHeight: CGFloat = 600
48
+ static let controlSize: CGFloat = 48
49
+ static let itemPitch: CGFloat = 50
50
+ static let firstItemTop: CGFloat = 170
51
+ static let firstItemTopLandscape: CGFloat = 120
52
+ // Outer display in portrait: the header is 4pt taller than the iPhone floor and its title
53
+ // lines up with the camera punchout (y 24–66 at the top of the strip).
54
+ static let outerHeaderHeight: CGFloat = 76
55
+ static let outerCameraCenterY: CGFloat = 45
56
+ // Outer display in landscape has no status bar in the strip: controls keep this far from
57
+ // both ends unless a reserved region (the camera at either end) asks for more.
58
+ static let edgeInset: CGFloat = 24
59
+ // Widest short side of any single-display iPhone.
60
+ static let iPhoneMaxShortSide: CGFloat = 440
61
+ }
62
+
63
+ enum HeaderMetrics {
64
+ static let barHeight: CGFloat = HEADER_HEIGHT
65
+ static let iPhoneMinHeight: CGFloat = 72
66
+ // A smaller top inset means the status bar is not on the top edge.
67
+ static let statusBarMinInset: CGFloat = 20
68
+ }
69
+
70
+ private var isPhoneIdiom: Bool { UIDevice.current.userInterfaceIdiom == .phone }
71
+
72
+ private var keyWindow: UIWindow? {
73
+ UIApplication.shared.connectedScenes
74
+ .compactMap { ($0 as? UIWindowScene)?.keyWindow }
75
+ .first
76
+ }
77
+
78
+ extension ScreenLayout {
79
+ /// Snapshot of the key window; not reactive, so views read `\.screenLayout` instead.
80
+ public static var current: ScreenLayout {
81
+ guard let window = keyWindow else {
82
+ return make(size: UIScreen.main.bounds.size, insets: EdgeInsets())
83
+ }
84
+ let inset = window.safeAreaInsets
85
+ return make(
86
+ size: window.bounds.size,
87
+ insets: EdgeInsets(top: inset.top, leading: inset.left, bottom: inset.bottom, trailing: inset.right)
88
+ )
89
+ }
90
+
91
+ static func make(
92
+ size: CGSize,
93
+ insets: EdgeInsets,
94
+ reservedRegions: [CGRect] = ReservedRegions.current,
95
+ isPhone: Bool = isPhoneIdiom
96
+ ) -> ScreenLayout {
97
+ let width = size.width
98
+ let height = size.height
99
+ var (headerHeight, headerStatusBarHeight) = headerGeometry(topInset: insets.top, isPhone: isPhone)
100
+ let isDuo = isPhone && isIPhoneDuoWindow(width: width, height: height, insets: insets)
101
+ guard isDuo else {
102
+ return ScreenLayout(
103
+ width: width, height: height, isIPhoneDuo: false, insets: insets,
104
+ insetLeft: 0, insetRight: 0, contentWidth: width,
105
+ headerHeight: headerHeight, headerStatusBarHeight: headerStatusBarHeight, sideBar: nil
106
+ )
107
+ }
108
+
109
+ var sideBar: SideBarLayout?
110
+ let sideInset = max(insets.leading, insets.trailing)
111
+ // narrower strips cannot host a control 12pt from the inner edge
112
+ if sideInset >= DuoMetrics.controlSize + Spacing.M {
113
+ var top = insets.top + HeaderMetrics.barHeight + Spacing.M
114
+ if insets.top < HeaderMetrics.statusBarMinInset {
115
+ // status bar lives in the strip: start under its cluster, as the native bar does
116
+ if width <= height {
117
+ top = DuoMetrics.firstItemTop
118
+ } else if height >= DuoMetrics.innerMinHeight {
119
+ top = DuoMetrics.firstItemTopLandscape
120
+ } else {
121
+ // outer display in landscape: no status bar in the strip
122
+ top = DuoMetrics.edgeInset
123
+ }
124
+
125
+ if height > width && width < DuoMetrics.innerMinHeight {
126
+ // outer display: the row starts below the top so the title centers on the camera
127
+ headerHeight = DuoMetrics.outerHeaderHeight
128
+ headerStatusBarHeight = DuoMetrics.outerCameraCenterY * 2 - DuoMetrics.outerHeaderHeight
129
+ }
130
+ }
131
+
132
+ let edge: SideBarEdge = insets.trailing >= insets.leading ? .right : .left
133
+ let stripLeft = edge == .right ? width - sideInset : 0
134
+ // clear of the camera and of the status bar cluster, which grows for calls and Live
135
+ // Activities; region frames already include the system's margin
136
+ let occlusion = stripOcclusion(reservedRegions, stripLeft: stripLeft, stripRight: stripLeft + sideInset, windowHeight: height)
137
+ sideBar = SideBarLayout(
138
+ edge: edge,
139
+ width: sideInset,
140
+ top: max(top, occlusion.top),
141
+ bottom: max(DuoMetrics.edgeInset, occlusion.bottom)
142
+ )
143
+ }
144
+
145
+ return ScreenLayout(
146
+ width: width, height: height, isIPhoneDuo: true, insets: insets,
147
+ insetLeft: insets.leading, insetRight: insets.trailing,
148
+ contentWidth: width - insets.leading - insets.trailing,
149
+ headerHeight: headerHeight, headerStatusBarHeight: headerStatusBarHeight, sideBar: sideBar
150
+ )
151
+ }
152
+
153
+ private static func headerGeometry(topInset: CGFloat, isPhone: Bool) -> (CGFloat, CGFloat) {
154
+ let height = topInset + HeaderMetrics.barHeight
155
+ guard isPhone else { return (height, topInset) }
156
+ return (
157
+ max(height, HeaderMetrics.iPhoneMinHeight),
158
+ topInset < HeaderMetrics.statusBarMinInset ? 0 : topInset
159
+ )
160
+ }
161
+
162
+ /// How far system regions reach into a side strip from its top and from its bottom end
163
+ /// (e.g. the camera, which sits at either end depending on the rotation).
164
+ private static func stripOcclusion(
165
+ _ regions: [CGRect],
166
+ stripLeft: CGFloat,
167
+ stripRight: CGFloat,
168
+ windowHeight: CGFloat
169
+ ) -> (top: CGFloat, bottom: CGFloat) {
170
+ regions.reduce(into: (top: CGFloat(0), bottom: CGFloat(0))) { occlusion, region in
171
+ guard region.minX < stripRight && region.maxX > stripLeft else { return }
172
+ if region.midY < windowHeight / 2 {
173
+ occlusion.top = max(occlusion.top, region.maxY)
174
+ } else {
175
+ occlusion.bottom = max(occlusion.bottom, windowHeight - region.minY)
176
+ }
177
+ }
178
+ }
179
+
180
+ // Slab iPhones report equal side insets in landscape; iPhone Duo moves the status bar to one side.
181
+ private static func isIPhoneDuoWindow(width: CGFloat, height: CGFloat, insets: EdgeInsets) -> Bool {
182
+ let shortSide = min(width, height)
183
+ let isSquare = shortSide > 0 && max(width, height) / shortSide < DuoMetrics.maxAspectRatio
184
+ return isSquare || insets.leading != insets.trailing
185
+ }
186
+
187
+ /// Below the side status bar: in portrait it is about twice as tall as its inset is wide.
188
+ public var duoTopInset: CGFloat {
189
+ max(insets.top, height > width ? max(insets.leading, insets.trailing) * 2 : 0)
190
+ }
191
+ }
192
+
193
+ /// Port of RN `device.ts` `isIPhoneDuo()`: UIScreen stays on the outer display, so this
194
+ /// still matches when Split View narrows the window. Used by popups and overlays.
195
+ public func isIPhoneDuoDevice(windowSize: CGSize? = nil) -> Bool {
196
+ guard isPhoneIdiom else { return false }
197
+ let screen = UIScreen.main.bounds.size
198
+ let window = windowSize ?? keyWindow?.bounds.size ?? screen
199
+ return max(min(screen.width, screen.height), min(window.width, window.height)) > DuoMetrics.iPhoneMaxShortSide
200
+ }
201
+
202
+ // MARK: - Reserved regions
203
+
204
+ /// System occlusion regions (iOS 27.1+) in window coordinates: the iPhone Duo camera, and the
205
+ /// status bar cluster as it grows for a call or a Live Activity. Empty before iOS 27.1.
206
+ enum ReservedRegions {
207
+ // last report of the probe, so `ScreenLayout.current` includes it
208
+ static var current: [CGRect] = []
209
+ }
210
+
211
+ /// Invisible probe: reading the regions in layoutSubviews makes UIKit run it again when they
212
+ /// change, so nothing polls. Port of RN `@momo-kits/reserved-regions`.
213
+ private final class ReservedRegionsProbe: UIView {
214
+ var onChange: (() -> Void)?
215
+ private var last: [CGRect]?
216
+
217
+ override init(frame: CGRect) {
218
+ super.init(frame: frame)
219
+ isUserInteractionEnabled = false
220
+ }
221
+
222
+ required init?(coder: NSCoder) { nil }
223
+
224
+ override func layoutSubviews() {
225
+ super.layoutSubviews()
226
+ let regions = occlusionRegions()
227
+ guard regions != last else { return }
228
+ last = regions
229
+ ReservedRegions.current = regions
230
+ // layout pass: publish on the next turn instead of mutating SwiftUI state mid-update
231
+ DispatchQueue.main.async { [weak self] in self?.onChange?() }
232
+ }
233
+
234
+ override func didMoveToWindow() {
235
+ super.didMoveToWindow()
236
+ setNeedsLayout()
237
+ }
238
+
239
+ // The API is newer than the SDKs hosts may build with, so it is reached through the runtime.
240
+ private func occlusionRegions() -> [CGRect] {
241
+ let regionsOfKind = NSSelectorFromString("reservedRegionsOfKind:")
242
+ guard window != nil, responds(to: regionsOfKind),
243
+ let kindType = NSClassFromString("UIViewReservedRegionKind") as? NSObject.Type,
244
+ let kind = (kindType as AnyObject).perform(NSSelectorFromString("occlusionRegionKind"))?.takeUnretainedValue(),
245
+ let regions = perform(regionsOfKind, with: kind)?.takeUnretainedValue() as? [NSObject]
246
+ else { return [] }
247
+ return regions.compactMap { ($0.value(forKey: "frame") as? NSValue)?.cgRectValue }
248
+ .map { convert($0, to: nil) }
249
+ }
250
+ }
251
+
252
+ private struct ReservedRegionsProbeView: UIViewRepresentable {
253
+ let onChange: () -> Void
254
+
255
+ func makeUIView(context: Context) -> ReservedRegionsProbe {
256
+ let probe = ReservedRegionsProbe()
257
+ probe.onChange = onChange
258
+ return probe
259
+ }
260
+
261
+ func updateUIView(_ probe: ReservedRegionsProbe, context: Context) {
262
+ probe.onChange = onChange
263
+ }
264
+ }
265
+
266
+ // MARK: - Environment
267
+
268
+ private struct ScreenLayoutKey: EnvironmentKey {
269
+ // Computed so an un-injected subtree (bare UIHostingController) still reads the live window.
270
+ static var defaultValue: ScreenLayout { .current }
271
+ }
272
+
273
+ public extension EnvironmentValues {
274
+ var screenLayout: ScreenLayout {
275
+ get { self[ScreenLayoutKey.self] }
276
+ set { self[ScreenLayoutKey.self] = newValue }
277
+ }
278
+ }
279
+
280
+ /// Re-reads the window whenever this view's size changes (rotation, fold/unfold, Split View).
281
+ private struct ScreenLayoutReader: ViewModifier {
282
+ @State private var layout: ScreenLayout = .current
283
+
284
+ func body(content: Content) -> some View {
285
+ content
286
+ .environment(\.screenLayout, layout)
287
+ .background(
288
+ GeometryReader { proxy in
289
+ Color.clear
290
+ .onAppear { refresh() }
291
+ .onChange(of: proxy.size) { _ in refresh() }
292
+ .onChange(of: proxy.safeAreaInsets) { _ in refresh() }
293
+ }
294
+ )
295
+ .background(ReservedRegionsProbeView { refresh() }.ignoresSafeArea())
296
+ .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
297
+ refresh()
298
+ }
299
+ }
300
+
301
+ private func refresh() {
302
+ let next = ScreenLayout.current
303
+ if next != layout { layout = next }
304
+ }
305
+ }
306
+
307
+ public extension View {
308
+ /// Provides `\.screenLayout`; NavigationContainer applies it, a bare UIHostingController root should too.
309
+ func momoScreenLayout() -> some View {
310
+ modifier(ScreenLayoutReader())
311
+ }
312
+ }
@@ -1,8 +1,8 @@
1
1
  import SwiftUI
2
2
 
3
- /// 12-column section container: horizontal margin, optional background image, and a
4
- /// wrapping row of children that read their span size from `\.gridContext`.
5
- /// Mirrors `vn.momo.kits.layout.Section`.
3
+ /// Grid section container (12 columns, 24 when the content is wider than 600pt): horizontal
4
+ /// margin, optional background image, and a wrapping row of children that read their span
5
+ /// size from `\.gridContext`. Mirrors `vn.momo.kits.layout.Section`.
6
6
  public struct SectionView<Content: View>: View {
7
7
  // MARK: Lifecycle
8
8
 
@@ -19,116 +19,20 @@ public struct SectionView<Content: View>: View {
19
19
  // MARK: Public
20
20
 
21
21
  public var body: some View {
22
- flow
23
- .environment(\.gridContext, grid)
24
- .frame(maxWidth: .infinity, alignment: .topLeading)
25
- .background(widthReader)
22
+ GridFlow(gutter: Spacing.M, content: content)
26
23
  .background(background)
27
24
  .padding(.horizontal, useMargin ? Spacing.M : 0)
28
25
  }
29
26
 
30
27
  // MARK: Private
31
28
 
32
- private static var columns: Int { 12 }
33
- private static var gutter: CGFloat { Spacing.M }
34
-
35
29
  private let useMargin: Bool
36
30
  private let backgroundImage: String?
37
31
  private let content: () -> Content
38
32
 
39
- /// Width available *inside* the horizontal margin — the Compose `BoxWithConstraints` equivalent.
40
- @State private var availableWidth: CGFloat = 0
41
-
42
- private var grid: GridContext {
43
- let columns = CGFloat(Self.columns)
44
- // The trailing `- 1 / columns` mirrors Compose: it absorbs rounding so 12 spans
45
- // plus their gutters never overflow the row by a sub-point.
46
- let sizePerSpan = (availableWidth - Self.gutter * (columns - 1)) / columns - 1 / columns
47
- return GridContext(numberOfColumns: Self.columns, gutter: Self.gutter, sizePerSpan: max(0, sizePerSpan))
48
- }
49
-
50
- @ViewBuilder private var flow: some View {
51
- if #available(iOS 16.0, *) {
52
- FlowRowLayout(gutter: Self.gutter) { content() }
53
- } else {
54
- // No `Layout` protocol before iOS 16: children stack instead of wrapping.
55
- // Full-width (span 12) items — the common case — look identical.
56
- VStack(alignment: .leading, spacing: Self.gutter) { content() }
57
- }
58
- }
59
-
60
33
  @ViewBuilder private var background: some View {
61
34
  if let backgroundImage {
62
35
  ImageView(backgroundImage, contentMode: .fill).clipped()
63
36
  }
64
37
  }
65
-
66
- private var widthReader: some View {
67
- GeometryReader { proxy in
68
- Color.clear.preference(key: SectionWidthKey.self, value: proxy.size.width)
69
- }
70
- .onPreferenceChange(SectionWidthKey.self) { availableWidth = $0 }
71
- }
72
- }
73
-
74
- private struct SectionWidthKey: PreferenceKey {
75
- static var defaultValue: CGFloat = 0
76
-
77
- static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
78
- value = max(value, nextValue())
79
- }
80
- }
81
-
82
- /// Wrapping row with equal horizontal/vertical spacing — the Compose `FlowRow` equivalent.
83
- @available(iOS 16.0, *)
84
- private struct FlowRowLayout: Layout {
85
- struct Row {
86
- var items: [(index: Int, size: CGSize)] = []
87
- var width: CGFloat = 0
88
- var height: CGFloat = 0
89
- }
90
-
91
- let gutter: CGFloat
92
-
93
- func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
94
- let maxWidth = proposal.width ?? .infinity
95
- let rows = rows(for: subviews, maxWidth: maxWidth)
96
- let height = rows.reduce(0) { $0 + $1.height } + gutter * CGFloat(max(0, rows.count - 1))
97
- return CGSize(width: proposal.width ?? rows.map(\.width).max() ?? 0, height: height)
98
- }
99
-
100
- func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
101
- var y = bounds.minY
102
- for row in rows(for: subviews, maxWidth: bounds.width) {
103
- var x = bounds.minX
104
- for item in row.items {
105
- subviews[item.index].place(
106
- at: CGPoint(x: x, y: y),
107
- anchor: .topLeading,
108
- proposal: ProposedViewSize(item.size)
109
- )
110
- x += item.size.width + gutter
111
- }
112
- y += row.height + gutter
113
- }
114
- }
115
-
116
- private func rows(for subviews: Subviews, maxWidth: CGFloat) -> [Row] {
117
- var rows: [Row] = []
118
- var current = Row()
119
- for index in subviews.indices {
120
- let size = subviews[index].sizeThatFits(ProposedViewSize(width: maxWidth, height: nil))
121
- let widthIfAppended = current.items.isEmpty ? size.width : current.width + gutter + size.width
122
- // Sub-point tolerance: spans are computed to land exactly on `maxWidth`.
123
- if !current.items.isEmpty, widthIfAppended > maxWidth + 0.5 {
124
- rows.append(current)
125
- current = Row()
126
- }
127
- current.width = current.items.isEmpty ? size.width : current.width + gutter + size.width
128
- current.height = max(current.height, size.height)
129
- current.items.append((index, size))
130
- }
131
- if !current.items.isEmpty { rows.append(current) }
132
- return rows
133
- }
134
38
  }
@@ -12,8 +12,7 @@ public enum LoaderType {
12
12
  /// Mirrors Compose `Loader(type:, tintColor:, modifier:)` which delegates to
13
13
  /// `DotLoader` / `SpinnerLoader`. Both variants render Lottie animations
14
14
  /// (`files/dot_loading` and `files/lottie_circle_loader`) and optionally tint
15
- /// the fill/stroke. The tint is reproduced here by masking a solid color rect
16
- /// with the (monochrome) Lottie animation.
15
+ /// the fill/stroke through a Lottie color value provider.
17
16
  public struct Loader: View {
18
17
  private let type: LoaderType
19
18
  private let tintColor: Color?
@@ -97,17 +96,14 @@ public struct ProgressBar: View {
97
96
  }
98
97
  }
99
98
 
100
- /// Renders a looping Lottie animation, optionally recolored via a mask.
99
+ // Tinted inside Lottie, as Compose does: SwiftUI masks/color effects over a UIKit-backed view aren't reliable.
101
100
  private struct TintedLottie: View {
102
101
  let name: String
103
102
  let tintColor: Color?
104
103
 
105
104
  var body: some View {
106
- if let tintColor = tintColor {
107
- tintColor
108
- .mask(LottieView(name: name, loopMode: .loop))
109
- } else {
110
- LottieView(name: name, loopMode: .loop)
111
- }
105
+ LottieView(name: name, loopMode: .loop, tintColor: tintColor.map { UIColor($0) })
106
+ // The tint is applied once in makeUIView; a new tint needs a new view.
107
+ .id(tintColor)
112
108
  }
113
109
  }
@@ -15,6 +15,8 @@ struct LottieView: UIViewRepresentable {
15
15
  var loopMode: LottieLoopMode = .playOnce
16
16
  var contentMode : UIView.ContentMode = .scaleAspectFit
17
17
  var onDone: (() -> Void)?
18
+ // Recolors every fill and stroke, as Compose's iOS LottieAnimation does; nil keeps the file's colors.
19
+ var tintColor: UIColor?
18
20
 
19
21
  // Add static cache for animations
20
22
  private static var animationCache = NSCache<NSString, LottieAnimation>()
@@ -36,7 +38,8 @@ struct LottieView: UIViewRepresentable {
36
38
  animationView.animation = cachedAnimation
37
39
  setupAnimationView(animationView, view)
38
40
  } else {
39
- LottieAnimation.loadedFrom(url: URL(string: url)!, closure: { animation in
41
+ guard let animationURL = URL(string: url) else { return view }
42
+ LottieAnimation.loadedFrom(url: animationURL, closure: { animation in
40
43
  if let animation = animation {
41
44
  Self.animationCache.setObject(animation, forKey: url as NSString)
42
45
  animationView.animation = animation
@@ -49,7 +52,8 @@ struct LottieView: UIViewRepresentable {
49
52
  if let cachedAnimation = Self.animationCache.object(forKey: name as NSString) {
50
53
  animationView.animation = cachedAnimation
51
54
  setupAnimationView(animationView, view)
52
- } else if let animation = LottieAnimation.named(name) {
55
+ } else if let animation = LottieAnimation.named(name, bundle: MoMoUIKitsResources.bundle)
56
+ ?? LottieAnimation.named(name) {
53
57
  Self.animationCache.setObject(animation, forKey: name as NSString)
54
58
  animationView.animation = animation
55
59
  setupAnimationView(animationView, view)
@@ -62,6 +66,12 @@ struct LottieView: UIViewRepresentable {
62
66
  private func setupAnimationView(_ animationView: LottieAnimationView, _ containerView: UIView) {
63
67
  animationView.contentMode = contentMode
64
68
  animationView.loopMode = loopMode
69
+ if let tintColor {
70
+ animationView.setValueProvider(
71
+ ColorValueProvider(tintColor.lottieColorValue),
72
+ keypath: AnimationKeypath(keypath: "**.Color")
73
+ )
74
+ }
65
75
  animationView.backgroundBehavior = loopMode == .loop ? .pauseAndRestore : .pause
66
76
 
67
77
  // Optimize rendering
@@ -71,6 +81,8 @@ struct LottieView: UIViewRepresentable {
71
81
  containerView.addSubview(animationView)
72
82
 
73
83
  NSLayoutConstraint.activate([
84
+ animationView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
85
+ animationView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
74
86
  animationView.widthAnchor.constraint(equalTo: containerView.widthAnchor),
75
87
  animationView.heightAnchor.constraint(equalTo: containerView.heightAnchor)
76
88
  ])
@@ -2,7 +2,7 @@
2
2
  // BottomSheet.swift
3
3
  // MoMoUIKits
4
4
  //
5
- // SwiftUI mirror of Compose's `BottomSheet` (navigation/BottomSheet.kt).
5
+ // SwiftUI mirror of Compose's `navigation/BottomSheet.kt`.
6
6
  // A sheet that slides up from the bottom over a dimmed scrim, with a drag
7
7
  // handle, a header (title or custom), a divider and the content body.
8
8
  // Dragging down past a threshold (or tapping the scrim) dismisses it.
@@ -40,11 +40,16 @@ struct BottomSheetView<Content: View>: View {
40
40
  let header: BottomSheetHeader
41
41
  let isSurface: Bool
42
42
  let barrierDismissible: Bool
43
+ var draggable: Bool = true
44
+ /// Keeps the content above the home indicator (Compose `AppNavigationBar` padding).
45
+ var useBottomInset: Bool = true
46
+ var footer: (() -> AnyView)? = nil
43
47
  let content: () -> Content
44
48
 
45
49
  @EnvironmentObject private var navigator: Navigator
46
50
  @Environment(\.appTheme) private var theme
47
51
  @Environment(\.applicationEnvironment) private var appEnv
52
+ @Environment(\.screenLayout) private var layout
48
53
 
49
54
  /// Vertical translation of the sheet; starts off-screen and animates to 0.
50
55
  @State private var sheetOffset: CGFloat = UIScreen.main.bounds.height
@@ -66,8 +71,36 @@ struct BottomSheetView<Content: View>: View {
66
71
  /// steady-state (fully open) scrim peaks at 0.6, matching ModalScreen's peak.
67
72
  private let maxDynamicAlpha: CGFloat = 0.3
68
73
 
69
- private var screenHeight: CGFloat { UIScreen.main.bounds.height }
70
- private var bottomInset: CGFloat { safeAreaBottomInset() }
74
+ // UIScreen stays on the outer display on iPhone Duo, even when unfolded
75
+ private var screenHeight: CGFloat { isDuo ? layout.height : UIScreen.main.bounds.height }
76
+
77
+ private var isDuo: Bool { layout.isDuoDevice }
78
+ // iPhone Duo inner display: center the sheet instead of spanning the width
79
+ private var isDuoCentered: Bool { isDuo && layout.width >= 600 }
80
+
81
+ /// Keeps the portrait width in landscape, like system sheets.
82
+ private var duoCenteredWidth: CGFloat {
83
+ min(
84
+ min(layout.width, layout.height) - Spacing.L * 2,
85
+ layout.width - max(layout.insets.leading, layout.insets.trailing, Spacing.L) * 2
86
+ )
87
+ }
88
+
89
+ /// Landscape side buttons span the full height, so the content is inset; in portrait the
90
+ /// side status bar only covers the top corner.
91
+ private var sideInsetLeading: CGFloat {
92
+ isDuo && !isDuoCentered && layout.width > layout.height ? layout.insets.leading : 0
93
+ }
94
+
95
+ private var sideInsetTrailing: CGFloat {
96
+ isDuo && !isDuoCentered && layout.width > layout.height ? layout.insets.trailing : 0
97
+ }
98
+
99
+ /// The keyboard filler is part of the sheet, so capping the sheet shrinks the content by it.
100
+ private var maxHeight: CGFloat {
101
+ isDuo ? layout.height - max(layout.duoTopInset, Spacing.XL) : screenHeight - 90
102
+ }
103
+ private var bottomInset: CGFloat { useBottomInset ? safeAreaBottomInset() : 0 }
71
104
  /// While the keyboard is up, it already covers the home-indicator area, so it
72
105
  /// replaces (rather than adds to) the usual bottom-inset filler.
73
106
  private var effectiveBottomInset: CGFloat { keyboardHeight > 0 ? keyboardHeight : bottomInset }
@@ -110,7 +143,7 @@ struct BottomSheetView<Content: View>: View {
110
143
  .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in
111
144
  guard let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
112
145
  withAnimation(.easeOut(duration: 0.25)) {
113
- keyboardHeight = UIScreen.main.bounds.height - endFrame.origin.y
146
+ keyboardHeight = screenHeight - endFrame.origin.y
114
147
  }
115
148
  }
116
149
  .onAppear {
@@ -138,23 +171,34 @@ struct BottomSheetView<Content: View>: View {
138
171
  headerView
139
172
  .frame(height: 56)
140
173
  .padding(.horizontal, Spacing.M)
174
+ .padding(.leading, sideInsetLeading)
175
+ .padding(.trailing, sideInsetTrailing)
141
176
  }
142
177
  .frame(maxWidth: .infinity)
143
178
  .frame(height: 72)
144
179
  .contentShape(Rectangle())
145
- .gesture(dragGesture)
180
+ .gesture(dragGesture, including: draggable ? .all : .subviews)
146
181
 
147
182
  Rectangle()
148
183
  .fill(theme.colors.border.default)
149
184
  .frame(height: 1)
150
185
 
151
186
  content()
187
+ .padding(.leading, sideInsetLeading)
188
+ .padding(.trailing, sideInsetTrailing)
189
+ // a tall sheet keeps its header (and footer) and gives the content what is left
190
+ .layoutPriority(-1)
191
+
192
+ if let footer {
193
+ BottomSheetFooter(content: footer)
194
+ }
152
195
 
153
- backgroundColor
196
+ (footer != nil ? theme.colors.background.surface : backgroundColor)
154
197
  .frame(height: effectiveBottomInset)
155
198
  }
156
- .frame(maxWidth: .infinity)
157
- .frame(alignment: .bottom)
199
+ .frame(maxWidth: isDuoCentered ? duoCenteredWidth : .infinity)
200
+ .modifier(CapHeight(maxHeight: maxHeight))
201
+ .clipped()
158
202
  .background(
159
203
  GeometryReader { geo in
160
204
  Color.clear.onAppear { sheetHeight = geo.size.height }
@@ -247,6 +291,51 @@ struct BottomSheetView<Content: View>: View {
247
291
  }
248
292
  }
249
293
 
294
+ /// Compose's `heightIn(max:)`: caps the sheet but keeps it hugging its content, which
295
+ /// `.frame(maxHeight:)` would not — a flexible frame grows to the cap.
296
+ @available(iOS 16.0, *)
297
+ private struct CapHeight: ViewModifier {
298
+ let maxHeight: CGFloat
299
+
300
+ func body(content: Content) -> some View {
301
+ CapHeightLayout(maxHeight: maxHeight) { content }
302
+ }
303
+ }
304
+
305
+ @available(iOS 16.0, *)
306
+ private struct CapHeightLayout: Layout {
307
+ let maxHeight: CGFloat
308
+
309
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
310
+ let height = min(proposal.height ?? .infinity, maxHeight)
311
+ return subviews.first?.sizeThatFits(ProposedViewSize(width: proposal.width, height: height)) ?? .zero
312
+ }
313
+
314
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
315
+ subviews.first?.place(at: bounds.origin, proposal: ProposedViewSize(bounds.size))
316
+ }
317
+ }
318
+
319
+ /// Mirrors Compose's `BottomSheetFooter`: a surface band with a soft shadow cast on the content.
320
+ private struct BottomSheetFooter: View {
321
+ let content: () -> AnyView
322
+
323
+ @Environment(\.appTheme) private var theme
324
+
325
+ var body: some View {
326
+ content()
327
+ .frame(maxWidth: .infinity)
328
+ .padding(.vertical, Spacing.S)
329
+ .background(theme.colors.background.surface)
330
+ .overlay(alignment: .top) {
331
+ LinearGradient(colors: [.clear, Color.black.opacity(0.05)], startPoint: .top, endPoint: .bottom)
332
+ .frame(height: 6)
333
+ .offset(y: -6)
334
+ .allowsHitTesting(false)
335
+ }
336
+ }
337
+ }
338
+
250
339
  @available(iOS 16.0, *)
251
340
  private func safeAreaBottomInset() -> CGFloat {
252
341
  let bottomInset = UIApplication.shared.connectedScenes