@momo-kits/native-kits 0.167.1-beta.9-debug → 0.167.1-duo-search.1

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 (189) hide show
  1. package/CLAUDE.md +36 -0
  2. package/ios/Application/ApplicationEnvironment.swift +6 -0
  3. package/ios/Application/Components.swift +1 -3
  4. package/ios/Application/HeaderRight.swift +32 -14
  5. package/ios/Application/Layout/ScreenLayout.swift +312 -0
  6. package/ios/Application/Navigation/BottomTab/BottomTab.swift +67 -10
  7. package/ios/Application/Navigation/BottomTab/BottomTabBar.swift +5 -2
  8. package/ios/Application/Navigation/BottomTab/DuoBarCompression.swift +105 -0
  9. package/ios/Application/Navigation/BottomTab/DuoTabBar.swift +304 -0
  10. package/ios/Application/Navigation/HeaderUser.swift +4 -4
  11. package/ios/Application/Navigation/NavigationContainer.swift +48 -42
  12. package/ios/Application/Navigation/Navigator.swift +122 -29
  13. package/ios/Application/Navigation/NavigatorManager.swift +13 -6
  14. package/ios/Application/Navigation/Overplay/BottomSheet.swift +41 -4
  15. package/ios/Application/Navigation/Overplay/ModalScreen.swift +7 -0
  16. package/ios/Application/Navigation/component/DuoScrollGradient.swift +54 -0
  17. package/ios/Application/Navigation/component/Header.swift +11 -6
  18. package/ios/Application/Navigation/component/HeaderBackground.swift +3 -2
  19. package/ios/Application/Navigation/component/NavigationFooter.swift +1 -1
  20. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +93 -9
  21. package/ios/Application/Navigation/component/SideBar.swift +155 -0
  22. package/ios/Application/StackScreen.swift +175 -18
  23. package/ios/CalculatorKeyboard/CalculatorKeyboard.swift +59 -5
  24. package/ios/Colors+Radius+Spacing/Colors.swift +1 -1
  25. package/ios/Extensions/Color++.swift +20 -9
  26. package/ios/Extensions/OptionalEnvironmentObject.swift +12 -0
  27. package/ios/Icon/Icon.swift +14 -22
  28. package/ios/Icon/Icons.swift +1329 -0
  29. package/ios/Image/Image.swift +16 -5
  30. package/ios/Lottie/LottieView.swift +4 -2
  31. package/ios/Popup/PopupDisplay.swift +84 -67
  32. package/ios/Popup/PopupLayout.swift +45 -0
  33. package/ios/Popup/PopupNotify.swift +44 -26
  34. package/ios/Popup/PopupPromotion.swift +18 -6
  35. package/ios/Resources/dot_loading.json +558 -0
  36. package/ios/Resources/lottie_circle_loader.json +1 -0
  37. package/ios/Template/TrustBanner/TrustBanner.swift +69 -31
  38. package/ios/Typography/FontScale.swift +3 -3
  39. package/ios/native-kits.podspec +1 -1
  40. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  41. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +80 -0
  42. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +2 -0
  43. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +173 -0
  44. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupDisplayDemo.swift +1 -0
  45. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +2 -2
  46. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +2 -0
  47. package/package.json +1 -1
  48. package/scripts/gen-ios-icons.mjs +36 -0
  49. package/build.gradle.kts +0 -11
  50. package/compose/build.gradle.kts +0 -190
  51. package/compose/build.gradle.kts.backup +0 -190
  52. package/compose/compose.podspec +0 -47
  53. package/compose/src/androidMain/kotlin/vn/momo/kits/const/HostFont.android.kt +0 -19
  54. package/compose/src/androidMain/kotlin/vn/momo/kits/navigation/ScrollToTop.android.kt +0 -6
  55. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/BackGesture.android.kt +0 -12
  56. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/OsFontScale.android.kt +0 -7
  57. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +0 -151
  58. package/compose/src/commonMain/kotlin/vn/momo/kits/application/AnimationSearchInput.kt +0 -52
  59. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +0 -124
  60. package/compose/src/commonMain/kotlin/vn/momo/kits/application/FloatingButton.kt +0 -183
  61. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Header.kt +0 -207
  62. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderAnimated.kt +0 -39
  63. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderBackground.kt +0 -86
  64. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderDefault.kt +0 -76
  65. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderExtended.kt +0 -76
  66. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderRight.kt +0 -293
  67. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderTitle.kt +0 -33
  68. package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +0 -831
  69. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Localize.kt +0 -269
  70. package/compose/src/commonMain/kotlin/vn/momo/kits/application/NavigationContainer.kt +0 -110
  71. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Screen.kt +0 -378
  72. package/compose/src/commonMain/kotlin/vn/momo/kits/application/WidgetContainer.kt +0 -56
  73. package/compose/src/commonMain/kotlin/vn/momo/kits/application/useHeaderSearchAnimation.kt +0 -69
  74. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Avatar.kt +0 -157
  75. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +0 -82
  76. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeDot.kt +0 -32
  77. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeRibbon.kt +0 -338
  78. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BaselineView.kt +0 -175
  79. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +0 -356
  80. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +0 -113
  81. package/compose/src/commonMain/kotlin/vn/momo/kits/components/CheckBox.kt +0 -89
  82. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +0 -128
  83. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Collapse.kt +0 -209
  84. package/compose/src/commonMain/kotlin/vn/momo/kits/components/CupertinoOverscroll.kt +0 -542
  85. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +0 -49
  86. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +0 -76
  87. package/compose/src/commonMain/kotlin/vn/momo/kits/components/IconButton.kt +0 -125
  88. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Image.kt +0 -231
  89. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +0 -189
  90. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +0 -475
  91. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputDropDown.kt +0 -114
  92. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +0 -266
  93. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +0 -240
  94. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +0 -223
  95. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +0 -264
  96. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +0 -185
  97. package/compose/src/commonMain/kotlin/vn/momo/kits/components/LazyColumnWithBouncing.kt +0 -343
  98. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Loader.kt +0 -108
  99. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationDot.kt +0 -56
  100. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationNumber.kt +0 -41
  101. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationScroll.kt +0 -84
  102. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationWhiteDot.kt +0 -40
  103. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +0 -328
  104. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +0 -97
  105. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +0 -310
  106. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +0 -59
  107. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Rating.kt +0 -87
  108. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ScaleSizeScope.kt +0 -21
  109. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Skeleton.kt +0 -90
  110. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +0 -323
  111. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Stepper.kt +0 -220
  112. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Steps.kt +0 -473
  113. package/compose/src/commonMain/kotlin/vn/momo/kits/components/SuggestAction.kt +0 -122
  114. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +0 -199
  115. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +0 -91
  116. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +0 -552
  117. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +0 -88
  118. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +0 -125
  119. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Title.kt +0 -207
  120. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +0 -498
  121. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TrustBanner.kt +0 -176
  122. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Uploader.kt +0 -182
  123. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePicker.kt +0 -193
  124. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerTypes.kt +0 -29
  125. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +0 -234
  126. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +0 -188
  127. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Colors.kt +0 -325
  128. package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +0 -16
  129. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitDesignSystem.kt +0 -14
  130. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitFontScale.kt +0 -22
  131. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Radius.kt +0 -12
  132. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Spacing.kt +0 -16
  133. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Theme.kt +0 -221
  134. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +0 -270
  135. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Card.kt +0 -52
  136. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +0 -38
  137. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Section.kt +0 -39
  138. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/AutomationId.kt +0 -52
  139. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Clickable.kt +0 -66
  140. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Conditional.kt +0 -11
  141. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/DeprecatedModifier.kt +0 -104
  142. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Shadow.kt +0 -48
  143. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Size.kt +0 -51
  144. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +0 -299
  145. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/HostBackGestureBridge.kt +0 -58
  146. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +0 -129
  147. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +0 -106
  148. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +0 -201
  149. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +0 -393
  150. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ScrollToTop.kt +0 -8
  151. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +0 -595
  152. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackTransition.kt +0 -148
  153. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/SwipeBackHaptic.kt +0 -35
  154. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +0 -175
  155. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +0 -225
  156. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/CurvedContainer.kt +0 -86
  157. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/FloatingButton.kt +0 -169
  158. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +0 -358
  159. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +0 -83
  160. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +0 -106
  161. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +0 -75
  162. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +0 -309
  163. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +0 -40
  164. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +0 -364
  165. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +0 -132
  166. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/tracking/ScreenTracker.kt +0 -127
  167. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/BackGesture.kt +0 -12
  168. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ComposeLottieAnimation.kt +0 -91
  169. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/OsFontScale.kt +0 -9
  170. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +0 -71
  171. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Icons.kt +0 -1329
  172. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Resources.kt +0 -55
  173. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Tracking.kt +0 -15
  174. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +0 -105
  175. package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +0 -40
  176. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/HostBackGesture.ios.kt +0 -45
  177. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/ScrollToTop.ios.kt +0 -32
  178. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/BackGesture.ios.kt +0 -38
  179. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/OsFontScale.ios.kt +0 -66
  180. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +0 -213
  181. package/gradle/libs.versions.toml +0 -69
  182. package/gradle/wrapper/gradle-wrapper.jar +0 -0
  183. package/gradle/wrapper/gradle-wrapper.properties +0 -8
  184. package/gradle.properties +0 -26
  185. package/gradlew +0 -252
  186. package/gradlew.bat +0 -94
  187. package/ios-demo/README.md +0 -120
  188. package/local.properties +0 -8
  189. package/settings.gradle.kts +0 -64
@@ -3,8 +3,8 @@
3
3
  // MoMoUIKits
4
4
  //
5
5
  // SwiftUI mirror of Compose's Navigator + DynamicScreenRegistry.
6
- // Screens are registered by Int id; the NavigationStack path stores routes
7
- // by id and looks up content from the registry on render.
6
+ // Screens are registered by Int id; the stack paths store routes
7
+ // by id and content is looked up from the registry on render.
8
8
  //
9
9
 
10
10
  import SwiftUI
@@ -83,6 +83,9 @@ public struct OverplayItem: Identifiable {
83
83
  public var header: BottomSheetHeader? = nil
84
84
  // Snack-bar payload (mirrors Compose's SnackBar value).
85
85
  public var snackBar: SnackBar? = nil
86
+ /// The presented screen it was shown over, nil for the main stack. Like Compose's overplay
87
+ /// root id, it pins the item to that context instead of following later presents/dismisses.
88
+ public internal(set) var presentedId: Int? = nil
86
89
  }
87
90
 
88
91
  // MARK: - Navigator
@@ -92,11 +95,24 @@ public final class Navigator: ObservableObject {
92
95
  @Published public var path: [DynamicScreenRoute] = [] {
93
96
  // Disposal hangs off the path so every route that leaves the stack — pop,
94
97
  // replace, reset — is released the same way, after its exit transition.
95
- didSet { disposeScreens(droppedFrom: oldValue) }
98
+ didSet { disposeScreens(droppedFrom: oldValue, keeping: path) }
99
+ }
100
+ @Published public var presented: DynamicDialogRoute? {
101
+ didSet { syncCover() }
102
+ }
103
+ /// Screens pushed while a screen is presented. Compose's dialog route is a plain back-stack
104
+ /// entry, so later pushes stack on top of it; the cover hosts its own stack for the same result.
105
+ @Published public var presentedPath: [DynamicScreenRoute] = [] {
106
+ didSet { disposeScreens(droppedFrom: oldValue, keeping: presentedPath) }
96
107
  }
97
- @Published public var presented: DynamicDialogRoute?
98
108
  @Published public var overplay: OverplayItem?
99
109
 
110
+ /// What the cover shows. It trails `presented` while the previous cover is still sliding out:
111
+ /// SwiftUI can drop a presentation issued during another one's dismissal.
112
+ @Published private(set) var coverRoute: DynamicDialogRoute?
113
+ private var coverOnScreen = false
114
+ private var presentationGeneration = 0
115
+
100
116
  /// Owned per-Navigator (mirrors Compose's `remember { DynamicScreenRegistry() }` +
101
117
  /// disposal): screens registered by this navigator are released via ARC when the
102
118
  /// navigator itself deallocates, instead of accumulating in a process-wide singleton.
@@ -108,7 +124,7 @@ public final class Navigator: ObservableObject {
108
124
  /// actually removed via `finishOverplayDismiss`.
109
125
  private var overplayDismissHandler: (() -> Void)?
110
126
 
111
- public private(set) var rootRoute: DynamicScreenRoute
127
+ @Published public private(set) var rootRoute: DynamicScreenRoute
112
128
  public var composeApi: KitComposeApi?
113
129
 
114
130
  public init(
@@ -133,7 +149,7 @@ public final class Navigator: ObservableObject {
133
149
  content: { AnyView(content()) },
134
150
  options: options
135
151
  )
136
- path.append(route)
152
+ appendToActiveStack(route)
137
153
  }
138
154
 
139
155
  public func push<V: View>(
@@ -146,12 +162,11 @@ public final class Navigator: ObservableObject {
146
162
  content: { AnyView(content()) },
147
163
  options: options
148
164
  )
149
- path.append(route)
165
+ appendToActiveStack(route)
150
166
  }
151
167
 
152
168
  public func replace<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
153
- guard !path.isEmpty else { return }
154
- path.removeLast()
169
+ guard removeLastFromActiveStack() else { return }
155
170
  push(content, options: options)
156
171
  }
157
172
 
@@ -160,13 +175,37 @@ public final class Navigator: ObservableObject {
160
175
  @ViewBuilder _ content: @escaping () -> V,
161
176
  options: NavigationOptions? = nil
162
177
  ) {
163
- guard !path.isEmpty else { return }
164
- path.removeLast()
178
+ guard removeLastFromActiveStack() else { return }
165
179
  push(screenName: screenName, content, options: options)
166
180
  }
167
181
 
182
+ /// The presented flow while one is up, the main stack otherwise.
183
+ private func appendToActiveStack(_ route: DynamicScreenRoute) {
184
+ if presented != nil {
185
+ presentedPath.append(route)
186
+ } else {
187
+ path.append(route)
188
+ }
189
+ }
190
+
191
+ private func removeLastFromActiveStack() -> Bool {
192
+ if presented != nil {
193
+ // Compose pops the dialog route itself, so the replacement lands on the main stack.
194
+ if presentedPath.isEmpty {
195
+ clearPresented()
196
+ } else {
197
+ presentedPath.removeLast()
198
+ }
199
+ } else {
200
+ guard !path.isEmpty else { return false }
201
+ path.removeLast()
202
+ }
203
+ return true
204
+ }
205
+
168
206
  public func reset<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
169
207
  let previousRoot = rootRoute.id
208
+ clearPresented()
170
209
  path.removeAll()
171
210
  let route = registry.register(
172
211
  screenName: options?.screenName ?? "",
@@ -183,6 +222,7 @@ public final class Navigator: ObservableObject {
183
222
  options: NavigationOptions? = nil
184
223
  ) {
185
224
  let previousRoot = rootRoute.id
225
+ clearPresented()
186
226
  path.removeAll()
187
227
  let route = registry.register(
188
228
  screenName: screenName,
@@ -241,9 +281,11 @@ public final class Navigator: ObservableObject {
241
281
  @discardableResult
242
282
  private func popScreen() -> Bool {
243
283
  if presented != nil {
244
- let dismissedId = presented?.id
245
- presented = nil
246
- if let id = dismissedId { disposeScreens([id]) }
284
+ if presentedPath.isEmpty {
285
+ clearPresented()
286
+ } else {
287
+ presentedPath.removeLast()
288
+ }
247
289
  return true
248
290
  } else if !path.isEmpty {
249
291
  path.removeLast()
@@ -254,15 +296,20 @@ public final class Navigator: ObservableObject {
254
296
  }
255
297
  }
256
298
 
299
+ /// Empties both stacks; a presented screen stays up on its own root.
257
300
  public func popToRoot() {
301
+ presentedPath.removeAll()
258
302
  path.removeAll()
259
303
  }
260
304
 
261
305
  /// The entry a back action applies to — mirrors Compose's `currentScreen()`
262
- /// (`navController.currentBackStackEntry`): a presented screen sits on top of
306
+ /// (`navController.currentBackStackEntry`): the presented flow sits on top of
263
307
  /// the stack, otherwise it is the last pushed screen, otherwise the root.
264
308
  private func currentScreen() -> DynamicScreen? {
265
- registry.getScreen(id: presented?.id ?? path.last?.id ?? rootRoute.id)
309
+ if let presented {
310
+ return registry.getScreen(id: presentedPath.last?.id ?? presented.id)
311
+ }
312
+ return registry.getScreen(id: path.last?.id ?? rootRoute.id)
266
313
  }
267
314
 
268
315
  public func onBackSafe(callback: (() -> Void)? = nil) {
@@ -318,7 +365,7 @@ public final class Navigator: ObservableObject {
318
365
  content: { AnyView(content()) },
319
366
  options: options
320
367
  )
321
- presented = DynamicDialogRoute(id: route.id)
368
+ presentScreen(route)
322
369
  }
323
370
 
324
371
  public func present<V: View>(
@@ -331,14 +378,54 @@ public final class Navigator: ObservableObject {
331
378
  content: { AnyView(content()) },
332
379
  options: options
333
380
  )
334
- presented = DynamicDialogRoute(id: route.id)
381
+ presentScreen(route)
335
382
  }
336
383
 
337
384
  public func dismiss() {
338
- if let id = presented?.id {
339
- registry.unregister(id: id)
385
+ clearPresented()
386
+ }
387
+
388
+ /// One cover per container: presenting from a presented flow stacks onto that flow, the way
389
+ /// Compose stacks a second dialog route.
390
+ private func presentScreen(_ route: DynamicScreenRoute) {
391
+ if presented != nil {
392
+ presentedPath.append(route)
393
+ } else {
394
+ presentationGeneration += 1
395
+ presentedPath = []
396
+ presented = DynamicDialogRoute(id: route.id)
340
397
  }
398
+ }
399
+
400
+ /// The cover keeps rendering its stack while it slides out, so the flow is torn down only
401
+ /// once the exit animation is over.
402
+ private func clearPresented() {
403
+ guard let id = presented?.id else { return }
341
404
  presented = nil
405
+ let generation = presentationGeneration
406
+ DispatchQueue.main.asyncAfter(deadline: .now() + Self.presentedDisposeDelay) { [weak self] in
407
+ guard let self, self.presentationGeneration == generation else { return }
408
+ self.presentedPath.removeAll()
409
+ }
410
+ disposeScreens([id], after: Self.presentedDisposeDelay)
411
+ }
412
+
413
+ private func syncCover() {
414
+ if presented == nil {
415
+ coverRoute = nil
416
+ } else if !coverOnScreen || coverRoute != nil {
417
+ coverRoute = presented
418
+ }
419
+ }
420
+
421
+ /// Reported by the container from the cover's appear / disappear.
422
+ func coverDidAppear() {
423
+ coverOnScreen = true
424
+ }
425
+
426
+ func coverDidDisappear() {
427
+ coverOnScreen = false
428
+ syncCover()
342
429
  }
343
430
 
344
431
  // MARK: Overplay
@@ -353,7 +440,8 @@ public final class Navigator: ObservableObject {
353
440
  type: .modal,
354
441
  content: { AnyView(content()) },
355
442
  barrierDismissible: barrierDismissible,
356
- onDismiss: onDismiss
443
+ onDismiss: onDismiss,
444
+ presentedId: presented?.id
357
445
  )
358
446
  }
359
447
 
@@ -371,7 +459,8 @@ public final class Navigator: ObservableObject {
371
459
  barrierDismissible: barrierDismissible,
372
460
  onDismiss: onDismiss,
373
461
  isSurface: isSurface,
374
- header: header
462
+ header: header,
463
+ presentedId: presented?.id
375
464
  )
376
465
  }
377
466
 
@@ -383,7 +472,8 @@ public final class Navigator: ObservableObject {
383
472
  content: { AnyView(EmptyView()) },
384
473
  barrierDismissible: false,
385
474
  onDismiss: onDismiss,
386
- snackBar: snackBar
475
+ snackBar: snackBar,
476
+ presentedId: presented?.id
387
477
  )
388
478
  }
389
479
 
@@ -429,20 +519,23 @@ public final class Navigator: ObservableObject {
429
519
  /// after the component has taken itself down.
430
520
  private static let overplayDismissDelay: TimeInterval = 0.3
431
521
 
432
- private func disposeScreens(droppedFrom previous: [DynamicScreenRoute]) {
433
- let live = Set(path.map(\.id))
434
- disposeScreens(previous.map(\.id).filter { !live.contains($0) })
522
+ /// A full-screen cover slides out slower than a stack pop.
523
+ private static let presentedDisposeDelay: TimeInterval = 0.6
524
+
525
+ private func disposeScreens(droppedFrom previous: [DynamicScreenRoute], keeping current: [DynamicScreenRoute]) {
526
+ let kept = Set(current.map(\.id))
527
+ disposeScreens(previous.map(\.id).filter { !kept.contains($0) })
435
528
  }
436
529
 
437
530
  /// Screens are released only once the pop transition has played out: the outgoing
438
531
  /// view is still on screen while it animates and the registry is what renders it —
439
532
  /// dropping it right away blanks the screen mid-transition. Liveness is re-checked
440
533
  /// when the timer fires, so a route pushed back before then keeps its screen.
441
- private func disposeScreens(_ ids: [Int]) {
534
+ private func disposeScreens(_ ids: [Int], after delay: TimeInterval = Navigator.screenDisposeDelay) {
442
535
  guard !ids.isEmpty else { return }
443
- DispatchQueue.main.asyncAfter(deadline: .now() + Self.screenDisposeDelay) { [weak self] in
536
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
444
537
  guard let self else { return }
445
- var live = Set(self.path.map(\.id))
538
+ var live = Set(self.path.map(\.id)).union(self.presentedPath.map(\.id))
446
539
  live.insert(self.rootRoute.id)
447
540
  if let presented = self.presented { live.insert(presented.id) }
448
541
  for id in ids where !live.contains(id) {
@@ -5,18 +5,25 @@ public final class NavigatorManager {
5
5
  public static let shared = NavigatorManager()
6
6
  private init() {}
7
7
 
8
- private var navigators: [Navigator] = []
8
+ // Weak: a container torn down while its cover is up never reports its disappearance.
9
+ private var navigators: [WeakNavigator] = []
9
10
 
10
11
  public func push(_ navigator: Navigator) {
11
- guard !navigators.contains(where: { $0 === navigator }) else { return }
12
- navigators.append(navigator)
12
+ navigators.removeAll { $0.value == nil }
13
+ guard !navigators.contains(where: { $0.value === navigator }) else { return }
14
+ navigators.append(WeakNavigator(value: navigator))
13
15
  }
14
16
 
15
17
  public func remove(_ navigator: Navigator) {
16
- navigators.removeAll { $0 === navigator }
18
+ navigators.removeAll { $0.value == nil || $0.value === navigator }
17
19
  }
18
20
 
19
- public func checkCanPop() -> Bool { !navigators.isEmpty }
21
+ public func checkCanPop() -> Bool { navigators.contains { $0.value != nil } }
20
22
 
21
- public func pop() { navigators.last?.onBackSafe() }
23
+ public func pop() { navigators.last { $0.value != nil }?.value?.onBackSafe() }
24
+ }
25
+
26
+ @available(iOS 16.0, *)
27
+ private struct WeakNavigator {
28
+ weak var value: Navigator?
22
29
  }
@@ -45,6 +45,7 @@ struct BottomSheetView<Content: View>: View {
45
45
  @EnvironmentObject private var navigator: Navigator
46
46
  @Environment(\.appTheme) private var theme
47
47
  @Environment(\.applicationEnvironment) private var appEnv
48
+ @Environment(\.screenLayout) private var layout
48
49
 
49
50
  /// Vertical translation of the sheet; starts off-screen and animates to 0.
50
51
  @State private var sheetOffset: CGFloat = UIScreen.main.bounds.height
@@ -66,7 +67,36 @@ struct BottomSheetView<Content: View>: View {
66
67
  /// steady-state (fully open) scrim peaks at 0.6, matching ModalScreen's peak.
67
68
  private let maxDynamicAlpha: CGFloat = 0.3
68
69
 
69
- private var screenHeight: CGFloat { UIScreen.main.bounds.height }
70
+ // UIScreen stays on the outer display on iPhone Duo, even when unfolded
71
+ private var screenHeight: CGFloat { isDuo ? layout.height : UIScreen.main.bounds.height }
72
+
73
+ private var isDuo: Bool { layout.isDuoDevice }
74
+ // iPhone Duo inner display: center the sheet instead of spanning the width
75
+ private var isDuoCentered: Bool { isDuo && layout.width >= 600 }
76
+
77
+ /// Keeps the portrait width in landscape, like system sheets.
78
+ private var duoCenteredWidth: CGFloat {
79
+ min(
80
+ min(layout.width, layout.height) - Spacing.L * 2,
81
+ layout.width - max(layout.insets.leading, layout.insets.trailing, Spacing.L) * 2
82
+ )
83
+ }
84
+
85
+ /// Landscape side buttons span the full height, so the content is inset; in portrait the
86
+ /// side status bar only covers the top corner.
87
+ private var sideInsetLeading: CGFloat {
88
+ isDuo && !isDuoCentered && layout.width > layout.height ? layout.insets.leading : 0
89
+ }
90
+
91
+ private var sideInsetTrailing: CGFloat {
92
+ isDuo && !isDuoCentered && layout.width > layout.height ? layout.insets.trailing : 0
93
+ }
94
+
95
+ /// The keyboard filler is part of the sheet, so capping the sheet shrinks the content by it.
96
+ private var duoMaxHeight: CGFloat? {
97
+ guard isDuo else { return nil }
98
+ return layout.height - max(layout.duoTopInset, Spacing.XL)
99
+ }
70
100
  private var bottomInset: CGFloat { safeAreaBottomInset() }
71
101
  /// While the keyboard is up, it already covers the home-indicator area, so it
72
102
  /// replaces (rather than adds to) the usual bottom-inset filler.
@@ -110,7 +140,7 @@ struct BottomSheetView<Content: View>: View {
110
140
  .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in
111
141
  guard let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
112
142
  withAnimation(.easeOut(duration: 0.25)) {
113
- keyboardHeight = UIScreen.main.bounds.height - endFrame.origin.y
143
+ keyboardHeight = screenHeight - endFrame.origin.y
114
144
  }
115
145
  }
116
146
  .onAppear {
@@ -138,6 +168,8 @@ struct BottomSheetView<Content: View>: View {
138
168
  headerView
139
169
  .frame(height: 56)
140
170
  .padding(.horizontal, Spacing.M)
171
+ .padding(.leading, sideInsetLeading)
172
+ .padding(.trailing, sideInsetTrailing)
141
173
  }
142
174
  .frame(maxWidth: .infinity)
143
175
  .frame(height: 72)
@@ -149,12 +181,17 @@ struct BottomSheetView<Content: View>: View {
149
181
  .frame(height: 1)
150
182
 
151
183
  content()
184
+ .padding(.leading, sideInsetLeading)
185
+ .padding(.trailing, sideInsetTrailing)
186
+ // a tall sheet on iPhone Duo keeps its header and gives the content what is left
187
+ .layoutPriority(-1)
152
188
 
153
189
  backgroundColor
154
190
  .frame(height: effectiveBottomInset)
155
191
  }
156
- .frame(maxWidth: .infinity)
157
- .frame(alignment: .bottom)
192
+ .frame(maxWidth: isDuoCentered ? duoCenteredWidth : .infinity)
193
+ .frame(maxHeight: duoMaxHeight, alignment: .bottom)
194
+ .clipped()
158
195
  .background(
159
196
  GeometryReader { geo in
160
197
  Color.clear.onAppear { sheetHeight = geo.size.height }
@@ -16,6 +16,7 @@ struct ModalScreen<Content: View>: View {
16
16
 
17
17
  @EnvironmentObject private var navigator: Navigator
18
18
  @Environment(\.applicationEnvironment) private var appEnv
19
+ @Environment(\.screenLayout) private var layout
19
20
 
20
21
  @State private var scrimOpacity: CGFloat = 0
21
22
  @State private var contentScale: CGFloat = 0.8
@@ -35,7 +36,13 @@ struct ModalScreen<Content: View>: View {
35
36
  content()
36
37
  .scaleEffect(contentScale)
37
38
  .opacity(contentOpacity)
39
+ // iPhone Duo: center below the side status bar and above the home indicator
40
+ .padding(.top, layout.isDuoDevice ? layout.duoTopInset : 0)
41
+ .padding(.bottom, layout.isDuoDevice ? layout.insets.bottom : 0)
38
42
  }
43
+ // Full screen like RN's Modal, so asymmetric landscape insets don't shift the content sideways.
44
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
45
+ .ignoresSafeArea(.container)
39
46
  .onAppear {
40
47
  guard !didAppear else { return }
41
48
  didAppear = true
@@ -0,0 +1,54 @@
1
+ //
2
+ // DuoScrollGradient.swift
3
+ // MoMoUIKits
4
+ //
5
+ // Port of RN `DuoScrollGradient` (Components/DuoGlassBackground.tsx): with the iPhone Duo
6
+ // glass header the content scrolls under the bar, so the header gradient is drawn under it.
7
+ //
8
+
9
+ import SwiftUI
10
+
11
+ // extended headers run their gradient this far below the header, as off Duo
12
+ private let extendedGradientExtra: CGFloat = 52
13
+ // scroll distance over which the extended gradient collapses to the header
14
+ private let extendedCollapseDistance: CGFloat = 100
15
+
16
+ struct DuoScrollGradient: View {
17
+ let scrollOffset: CGFloat
18
+ let headerHeight: CGFloat
19
+ let extended: Bool
20
+ /// Quick return header: the gradient leaves with the content instead of fading.
21
+ var followScroll = false
22
+
23
+ @Environment(\.appTheme) private var theme
24
+
25
+ private var scroll: CGFloat { max(scrollOffset, 0) }
26
+
27
+ var body: some View {
28
+ Group {
29
+ if followScroll {
30
+ gradient
31
+ .frame(height: headerHeight + (extended ? extendedGradientExtra : 0))
32
+ .offset(y: -scroll)
33
+ } else {
34
+ // fades out on scroll as the extended header does off Duo; extended also collapses
35
+ let progress = min(scroll / extendedCollapseDistance, 1)
36
+ gradient
37
+ .frame(height: headerHeight + (extended ? extendedGradientExtra * (1 - progress) : 0))
38
+ .opacity(1 - min(scroll / extendedGradientExtra, 1))
39
+ }
40
+ }
41
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
42
+ .ignoresSafeArea()
43
+ .allowsHitTesting(false)
44
+ }
45
+
46
+ /// The fade spans the gradient box.
47
+ private var gradient: LinearGradient {
48
+ LinearGradient(
49
+ colors: [theme.colors.gradient, theme.colors.gradient.opacity(0)],
50
+ startPoint: .top,
51
+ endPoint: .bottom
52
+ )
53
+ }
54
+ }
@@ -60,7 +60,11 @@ public struct NavigationHeader: View {
60
60
  self.titleOpacity = titleOpacity
61
61
  }
62
62
 
63
+ @Environment(\.screenLayout) private var layout
64
+
63
65
  private var isAnimated: Bool { animatedHeader != nil }
66
+ /// iPhone Duo side bar holds back and the toolkit; a custom header right stays in the bar.
67
+ private var useSideBar: Bool { layout.sideBar != nil }
64
68
  private var isSurface: Bool { animatedHeader?.isSurface ?? true }
65
69
 
66
70
  private var headerColor: HeaderColor {
@@ -79,7 +83,7 @@ public struct NavigationHeader: View {
79
83
  if headerType != .none {
80
84
  ZStack {
81
85
  HStack(spacing: 0) {
82
- if let goBack {
86
+ if let goBack, !useSideBar {
83
87
  BackButton(color: headerColor, onBack: goBack)
84
88
  Spacer().frame(width: Spacing.S)
85
89
  }
@@ -91,7 +95,7 @@ public struct NavigationHeader: View {
91
95
  .frame(maxWidth: .infinity, alignment: .leading)
92
96
  }
93
97
 
94
- if let headerRight {
98
+ if let headerRight, !(useSideBar && toolkitHeaderRight(headerRight) != nil) {
95
99
  AnyView(headerRight())
96
100
  .background(
97
101
  GeometryReader { geo in
@@ -107,8 +111,11 @@ public struct NavigationHeader: View {
107
111
  .padding(.horizontal, Spacing.M)
108
112
  }
109
113
  }
110
- .frame(height: HEADER_HEIGHT)
114
+ .frame(height: layout.headerBarHeight)
111
115
  .overlay(alignment: .bottom) { VerticalShadow(opacity: opacity) }
116
+ // the row starts headerStatusBarHeight below the window top: 0 with the status bar off
117
+ // the top edge, lower on the iPhone Duo outer display to center on the camera
118
+ .padding(.top, layout.headerStatusBarHeight - layout.insets.top)
112
119
  }
113
120
  }
114
121
 
@@ -119,9 +126,7 @@ public struct NavigationHeader: View {
119
126
  if let inputSearchProps {
120
127
  InputSearch(props: inputSearchProps)
121
128
  } else if case let .user(userData)? = headerTitle {
122
- if #available(iOS 16.0, *) {
123
- HeaderUserView(userData)
124
- }
129
+ HeaderUserView(userData)
125
130
  } else {
126
131
  MomoText(
127
132
  headerTitle?.defaultTitle ?? title,
@@ -43,10 +43,11 @@ public struct NavigationHeaderBackground: View {
43
43
  )
44
44
  }
45
45
 
46
+ @Environment(\.screenLayout) private var layout
47
+
46
48
  public var body: some View {
47
- let statusBarHeight = safeAreaTopInset()
48
49
  let opacity = max(0, 1 - scrollState / CGFloat(HEADER_HEIGHT))
49
- let minHeight = fullScreenContent ? 0 : statusBarHeight + HEADER_HEIGHT
50
+ let minHeight = fullScreenContent ? 0 : layout.headerHeight
50
51
  let maxHeight: CGFloat = 154
51
52
  let backgroundColor = headerTransparent ? Color.clear : Colors.black01
52
53
  let topColor = opacity == 0 ? backgroundColor : Color(hex: 0xFDCADE)
@@ -69,7 +69,7 @@ public struct NavigationFooter: View {
69
69
  .padding(.bottom, Self.bottomPadding)
70
70
  // Compose paints the surface all the way to the bottom of the screen; the
71
71
  // footer's own frame stops at the safe area, so only the fill bleeds down.
72
- .background(theme.colors.background.surface.ignoresSafeArea(.container, edges: .bottom))
72
+ .background(theme.colors.background.surface.ignoresSafeArea(.container, edges: [.bottom, .horizontal]))
73
73
  .overlay(alignment: .top) { shadow }
74
74
  .offset(y: Self.verticalOffset(keyboardSize: keyboardSize))
75
75
  }