@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
@@ -2,9 +2,9 @@
2
2
  // Navigator.swift
3
3
  // MoMoUIKits
4
4
  //
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.
5
+ // SwiftUI mirror of Compose's `navigation/Navigator.kt`: Navigator, DynamicScreenRegistry and
6
+ // the overplay model. Screens are registered by Int id; the stack paths store routes by id and
7
+ // content is looked up from the registry on render.
8
8
  //
9
9
 
10
10
  import SwiftUI
@@ -31,6 +31,7 @@ public struct DynamicScreen {
31
31
 
32
32
  public final class DynamicScreenRegistry {
33
33
  private var screens: [Int: DynamicScreen] = [:]
34
+ private var navigations: [Int: WeakNavigation] = [:]
34
35
  private var idCounter: Int = 1
35
36
 
36
37
  public init() {}
@@ -49,12 +50,20 @@ public final class DynamicScreenRegistry {
49
50
 
50
51
  public func unregister(id: Int) {
51
52
  screens.removeValue(forKey: id)
53
+ navigations.removeValue(forKey: id)
52
54
  }
53
55
 
54
56
  public func unregisterAll() {
55
57
  screens.removeAll()
58
+ navigations.removeAll()
56
59
  }
57
60
 
61
+ func bind(_ navigation: Navigation) {
62
+ navigations[navigation.id] = WeakNavigation(value: navigation)
63
+ }
64
+
65
+ func navigation(id: Int) -> Navigation? { navigations[id]?.value }
66
+
58
67
  public func getScreen(id: Int) -> DynamicScreen? { screens[id] }
59
68
 
60
69
  public func latest() -> DynamicScreen? {
@@ -66,6 +75,10 @@ public final class DynamicScreenRegistry {
66
75
  }
67
76
  }
68
77
 
78
+ private struct WeakNavigation {
79
+ weak var value: Navigation?
80
+ }
81
+
69
82
  // MARK: - Overplay
70
83
 
71
84
  public enum OverplayType {
@@ -81,8 +94,14 @@ public struct OverplayItem: Identifiable {
81
94
  // Bottom-sheet params (mirrors Compose's OverplayComponentParams.BottomSheet).
82
95
  public var isSurface: Bool = false
83
96
  public var header: BottomSheetHeader? = nil
97
+ public var draggable: Bool = true
98
+ public var useBottomInset: Bool = true
99
+ public var footer: (() -> AnyView)? = nil
84
100
  // Snack-bar payload (mirrors Compose's SnackBar value).
85
101
  public var snackBar: SnackBar? = nil
102
+ /// The presented screen it was shown over, nil for the main stack. Like Compose's overplay
103
+ /// root id, it pins the item to that context instead of following later presents/dismisses.
104
+ public internal(set) var presentedId: Int? = nil
86
105
  }
87
106
 
88
107
  // MARK: - Navigator
@@ -92,11 +111,24 @@ public final class Navigator: ObservableObject {
92
111
  @Published public var path: [DynamicScreenRoute] = [] {
93
112
  // Disposal hangs off the path so every route that leaves the stack — pop,
94
113
  // replace, reset — is released the same way, after its exit transition.
95
- didSet { disposeScreens(droppedFrom: oldValue) }
114
+ didSet { disposeScreens(droppedFrom: oldValue, keeping: path) }
115
+ }
116
+ @Published public var presented: DynamicDialogRoute? {
117
+ didSet { syncCover() }
118
+ }
119
+ /// Screens pushed while a screen is presented. Compose's dialog route is a plain back-stack
120
+ /// entry, so later pushes stack on top of it; the cover hosts its own stack for the same result.
121
+ @Published public var presentedPath: [DynamicScreenRoute] = [] {
122
+ didSet { disposeScreens(droppedFrom: oldValue, keeping: presentedPath) }
96
123
  }
97
- @Published public var presented: DynamicDialogRoute?
98
124
  @Published public var overplay: OverplayItem?
99
125
 
126
+ /// What the cover shows. It trails `presented` while the previous cover is still sliding out:
127
+ /// SwiftUI can drop a presentation issued during another one's dismissal.
128
+ @Published private(set) var coverRoute: DynamicDialogRoute?
129
+ private var coverOnScreen = false
130
+ private var presentationGeneration = 0
131
+
100
132
  /// Owned per-Navigator (mirrors Compose's `remember { DynamicScreenRegistry() }` +
101
133
  /// disposal): screens registered by this navigator are released via ARC when the
102
134
  /// navigator itself deallocates, instead of accumulating in a process-wide singleton.
@@ -108,8 +140,15 @@ public final class Navigator: ObservableObject {
108
140
  /// actually removed via `finishOverplayDismiss`.
109
141
  private var overplayDismissHandler: (() -> Void)?
110
142
 
111
- public private(set) var rootRoute: DynamicScreenRoute
143
+ @Published public private(set) var rootRoute: DynamicScreenRoute
112
144
  public var composeApi: KitComposeApi?
145
+ /// Host dismiss fallback when there is no `composeApi` (Compose's `maxApi.dismiss`).
146
+ public var maxApi: MaxApi?
147
+
148
+ /// Set by a bottom tab off its first tab: back on the screen hosting the tabs returns to the
149
+ /// first tab (Compose `tabBackHandler`). Keyed by that screen, so a screen pushed or presented
150
+ /// over the tabs still pops normally.
151
+ var tabBackHandler: (screenId: Int, handler: () -> Void)?
113
152
 
114
153
  public init(
115
154
  initialScreenName: String = "",
@@ -133,7 +172,7 @@ public final class Navigator: ObservableObject {
133
172
  content: { AnyView(content()) },
134
173
  options: options
135
174
  )
136
- path.append(route)
175
+ appendToActiveStack(route)
137
176
  }
138
177
 
139
178
  public func push<V: View>(
@@ -146,12 +185,11 @@ public final class Navigator: ObservableObject {
146
185
  content: { AnyView(content()) },
147
186
  options: options
148
187
  )
149
- path.append(route)
188
+ appendToActiveStack(route)
150
189
  }
151
190
 
152
191
  public func replace<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
153
- guard !path.isEmpty else { return }
154
- path.removeLast()
192
+ guard removeLastFromActiveStack() else { return }
155
193
  push(content, options: options)
156
194
  }
157
195
 
@@ -160,13 +198,37 @@ public final class Navigator: ObservableObject {
160
198
  @ViewBuilder _ content: @escaping () -> V,
161
199
  options: NavigationOptions? = nil
162
200
  ) {
163
- guard !path.isEmpty else { return }
164
- path.removeLast()
201
+ guard removeLastFromActiveStack() else { return }
165
202
  push(screenName: screenName, content, options: options)
166
203
  }
167
204
 
205
+ /// The presented flow while one is up, the main stack otherwise.
206
+ private func appendToActiveStack(_ route: DynamicScreenRoute) {
207
+ if presented != nil {
208
+ presentedPath.append(route)
209
+ } else {
210
+ path.append(route)
211
+ }
212
+ }
213
+
214
+ private func removeLastFromActiveStack() -> Bool {
215
+ if presented != nil {
216
+ // Compose pops the dialog route itself, so the replacement lands on the main stack.
217
+ if presentedPath.isEmpty {
218
+ clearPresented()
219
+ } else {
220
+ presentedPath.removeLast()
221
+ }
222
+ } else {
223
+ guard !path.isEmpty else { return false }
224
+ path.removeLast()
225
+ }
226
+ return true
227
+ }
228
+
168
229
  public func reset<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
169
230
  let previousRoot = rootRoute.id
231
+ clearPresented()
170
232
  path.removeAll()
171
233
  let route = registry.register(
172
234
  screenName: options?.screenName ?? "",
@@ -183,6 +245,7 @@ public final class Navigator: ObservableObject {
183
245
  options: NavigationOptions? = nil
184
246
  ) {
185
247
  let previousRoot = rootRoute.id
248
+ clearPresented()
186
249
  path.removeAll()
187
250
  let route = registry.register(
188
251
  screenName: screenName,
@@ -241,38 +304,64 @@ public final class Navigator: ObservableObject {
241
304
  @discardableResult
242
305
  private func popScreen() -> Bool {
243
306
  if presented != nil {
244
- let dismissedId = presented?.id
245
- presented = nil
246
- if let id = dismissedId { disposeScreens([id]) }
307
+ if presentedPath.isEmpty {
308
+ clearPresented()
309
+ } else {
310
+ presentedPath.removeLast()
311
+ }
247
312
  return true
248
313
  } else if !path.isEmpty {
249
314
  path.removeLast()
250
315
  return true
251
316
  } else {
252
- composeApi?.requestCallback(funcName: "dismiss", params: nil, onResponse: nil)
317
+ requestHostDismiss()
253
318
  return false
254
319
  }
255
320
  }
256
321
 
322
+ private func requestHostDismiss() {
323
+ if let composeApi {
324
+ composeApi.requestCallback(funcName: "dismiss", params: nil, onResponse: nil)
325
+ } else {
326
+ maxApi?.dismiss(completion: nil)
327
+ }
328
+ }
329
+
330
+ /// Whether the active stack has a screen to go back to (Compose's `canPop`); at the root a
331
+ /// back dismisses the mini app instead.
332
+ public func canPop() -> Bool {
333
+ presented != nil || !path.isEmpty
334
+ }
335
+
336
+ /// Empties both stacks; a presented screen stays up on its own root.
257
337
  public func popToRoot() {
338
+ presentedPath.removeAll()
258
339
  path.removeAll()
259
340
  }
260
341
 
261
342
  /// The entry a back action applies to — mirrors Compose's `currentScreen()`
262
- /// (`navController.currentBackStackEntry`): a presented screen sits on top of
343
+ /// (`navController.currentBackStackEntry`): the presented flow sits on top of
263
344
  /// the stack, otherwise it is the last pushed screen, otherwise the root.
264
345
  private func currentScreen() -> DynamicScreen? {
265
- registry.getScreen(id: presented?.id ?? path.last?.id ?? rootRoute.id)
346
+ if let presented {
347
+ return registry.getScreen(id: presentedPath.last?.id ?? presented.id)
348
+ }
349
+ return registry.getScreen(id: path.last?.id ?? rootRoute.id)
266
350
  }
267
351
 
268
352
  public func onBackSafe(callback: (() -> Void)? = nil) {
269
353
  let latest = currentScreen()
354
+ if let tabBackHandler, tabBackHandler.screenId == -1 || tabBackHandler.screenId == latest?.id {
355
+ tabBackHandler.handler()
356
+ return
357
+ }
270
358
  if let onBack = latest?.options?.onBackHandler {
271
359
  onBack()
272
360
  return
273
361
  }
274
362
  if let preventBack = latest?.options?.headerBackProps?.preventBack {
275
- showModal {
363
+ // stored in `overplay`, so a strong self would cycle until the popup closes
364
+ showModal { [weak self] in
276
365
  PopupNotify(
277
366
  props: PopupNotifyProps(
278
367
  image: preventBack.image,
@@ -281,7 +370,7 @@ public final class Navigator: ObservableObject {
281
370
  error: preventBack.error,
282
371
  primary: PopupAction(
283
372
  title: preventBack.primary?.title ?? "",
284
- onPress: { [weak self] in
373
+ onPress: {
285
374
  preventBack.primary?.onPress?()
286
375
  // Close the popup, then leave the screen (matches Compose's pop(2)).
287
376
  self?.pop(2, callback: callback)
@@ -290,14 +379,14 @@ public final class Navigator: ObservableObject {
290
379
  secondary: preventBack.secondary.map { secondary in
291
380
  PopupAction(
292
381
  title: secondary.title,
293
- onPress: { [weak self] in
382
+ onPress: {
294
383
  secondary.onPress?()
295
384
  self?.hideOverplay()
296
385
  }
297
386
  )
298
387
  },
299
388
  buttonDirection: preventBack.buttonDirection,
300
- onIconClose: { [weak self] in
389
+ onIconClose: {
301
390
  preventBack.onIconClose?()
302
391
  self?.hideOverplay()
303
392
  },
@@ -318,7 +407,7 @@ public final class Navigator: ObservableObject {
318
407
  content: { AnyView(content()) },
319
408
  options: options
320
409
  )
321
- presented = DynamicDialogRoute(id: route.id)
410
+ presentScreen(route)
322
411
  }
323
412
 
324
413
  public func present<V: View>(
@@ -331,14 +420,54 @@ public final class Navigator: ObservableObject {
331
420
  content: { AnyView(content()) },
332
421
  options: options
333
422
  )
334
- presented = DynamicDialogRoute(id: route.id)
423
+ presentScreen(route)
335
424
  }
336
425
 
337
426
  public func dismiss() {
338
- if let id = presented?.id {
339
- registry.unregister(id: id)
427
+ clearPresented()
428
+ }
429
+
430
+ /// One cover per container: presenting from a presented flow stacks onto that flow, the way
431
+ /// Compose stacks a second dialog route.
432
+ private func presentScreen(_ route: DynamicScreenRoute) {
433
+ if presented != nil {
434
+ presentedPath.append(route)
435
+ } else {
436
+ presentationGeneration += 1
437
+ presentedPath = []
438
+ presented = DynamicDialogRoute(id: route.id)
340
439
  }
440
+ }
441
+
442
+ /// The cover keeps rendering its stack while it slides out, so the flow is torn down only
443
+ /// once the exit animation is over.
444
+ private func clearPresented() {
445
+ guard let id = presented?.id else { return }
341
446
  presented = nil
447
+ let generation = presentationGeneration
448
+ DispatchQueue.main.asyncAfter(deadline: .now() + Self.presentedDisposeDelay) { [weak self] in
449
+ guard let self, self.presentationGeneration == generation else { return }
450
+ self.presentedPath.removeAll()
451
+ }
452
+ disposeScreens([id], after: Self.presentedDisposeDelay)
453
+ }
454
+
455
+ private func syncCover() {
456
+ if presented == nil {
457
+ coverRoute = nil
458
+ } else if !coverOnScreen || coverRoute != nil {
459
+ coverRoute = presented
460
+ }
461
+ }
462
+
463
+ /// Reported by the container from the cover's appear / disappear.
464
+ func coverDidAppear() {
465
+ coverOnScreen = true
466
+ }
467
+
468
+ func coverDidDisappear() {
469
+ coverOnScreen = false
470
+ syncCover()
342
471
  }
343
472
 
344
473
  // MARK: Overplay
@@ -353,7 +482,8 @@ public final class Navigator: ObservableObject {
353
482
  type: .modal,
354
483
  content: { AnyView(content()) },
355
484
  barrierDismissible: barrierDismissible,
356
- onDismiss: onDismiss
485
+ onDismiss: onDismiss,
486
+ presentedId: presented?.id
357
487
  )
358
488
  }
359
489
 
@@ -362,7 +492,10 @@ public final class Navigator: ObservableObject {
362
492
  isSurface: Bool = false,
363
493
  barrierDismissible: Bool = true,
364
494
  header: BottomSheetHeader? = nil,
365
- onDismiss: (() -> Void)? = nil
495
+ onDismiss: (() -> Void)? = nil,
496
+ draggable: Bool = true,
497
+ useBottomInset: Bool = true,
498
+ footer: (() -> AnyView)? = nil
366
499
  ) {
367
500
  overplayDismissHandler = nil
368
501
  overplay = OverplayItem(
@@ -371,7 +504,11 @@ public final class Navigator: ObservableObject {
371
504
  barrierDismissible: barrierDismissible,
372
505
  onDismiss: onDismiss,
373
506
  isSurface: isSurface,
374
- header: header
507
+ header: header,
508
+ draggable: draggable,
509
+ useBottomInset: useBottomInset,
510
+ footer: footer,
511
+ presentedId: presented?.id
375
512
  )
376
513
  }
377
514
 
@@ -383,7 +520,8 @@ public final class Navigator: ObservableObject {
383
520
  content: { AnyView(EmptyView()) },
384
521
  barrierDismissible: false,
385
522
  onDismiss: onDismiss,
386
- snackBar: snackBar
523
+ snackBar: snackBar,
524
+ presentedId: presented?.id
387
525
  )
388
526
  }
389
527
 
@@ -419,6 +557,14 @@ public final class Navigator: ObservableObject {
419
557
  if overplay?.type == .snackBar { hideOverplay() }
420
558
  }
421
559
 
560
+ /// Room the current screen's footer (or navigation bar) takes from the window bottom.
561
+ func snackBarBottomInset() -> CGFloat {
562
+ guard let id = currentScreen()?.id, let navigation = registry.navigation(id: id) else {
563
+ return NavigationFooter.navigationBarInset
564
+ }
565
+ return navigation.bottomObstruction
566
+ }
567
+
422
568
  // MARK: Screen disposal
423
569
 
424
570
  /// Matches the `delay(300)` Compose waits in `dismissScreen` before unregistering,
@@ -429,20 +575,23 @@ public final class Navigator: ObservableObject {
429
575
  /// after the component has taken itself down.
430
576
  private static let overplayDismissDelay: TimeInterval = 0.3
431
577
 
432
- private func disposeScreens(droppedFrom previous: [DynamicScreenRoute]) {
433
- let live = Set(path.map(\.id))
434
- disposeScreens(previous.map(\.id).filter { !live.contains($0) })
578
+ /// A full-screen cover slides out slower than a stack pop.
579
+ private static let presentedDisposeDelay: TimeInterval = 0.6
580
+
581
+ private func disposeScreens(droppedFrom previous: [DynamicScreenRoute], keeping current: [DynamicScreenRoute]) {
582
+ let kept = Set(current.map(\.id))
583
+ disposeScreens(previous.map(\.id).filter { !kept.contains($0) })
435
584
  }
436
585
 
437
586
  /// Screens are released only once the pop transition has played out: the outgoing
438
587
  /// view is still on screen while it animates and the registry is what renders it —
439
588
  /// dropping it right away blanks the screen mid-transition. Liveness is re-checked
440
589
  /// when the timer fires, so a route pushed back before then keeps its screen.
441
- private func disposeScreens(_ ids: [Int]) {
590
+ private func disposeScreens(_ ids: [Int], after delay: TimeInterval = Navigator.screenDisposeDelay) {
442
591
  guard !ids.isEmpty else { return }
443
- DispatchQueue.main.asyncAfter(deadline: .now() + Self.screenDisposeDelay) { [weak self] in
592
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
444
593
  guard let self else { return }
445
- var live = Set(self.path.map(\.id))
594
+ var live = Set(self.path.map(\.id)).union(self.presentedPath.map(\.id))
446
595
  live.insert(self.rootRoute.id)
447
596
  if let presented = self.presented { live.insert(presented.id) }
448
597
  for id in ids where !live.contains(id) {