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