@momo-kits/native-kits 0.300.3-debug → 0.300.4-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 (218) hide show
  1. package/CLAUDE.md +51 -7
  2. package/README.md +2 -2
  3. package/compose/build.gradle.kts +10 -2
  4. package/compose/build.gradle.kts.backup +8 -0
  5. package/compose/compose.podspec +1 -3
  6. package/compose/src/androidMain/kotlin/vn/momo/kits/const/HostFont.android.kt +4 -3
  7. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +2 -0
  8. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +6 -0
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +0 -14
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/application/DesignSystemKits.kt +62 -0
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +351 -111
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +20 -21
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +2 -1
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +4 -2
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +1 -1
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +1 -1
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +1 -1
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +9 -6
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +11 -12
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +4 -9
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +2 -2
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +10 -9
  23. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +6 -6
  24. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +55 -22
  25. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +12 -1
  26. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +7 -9
  27. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +1 -1
  28. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +13 -6
  29. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +13 -5
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +3 -2
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +14 -6
  32. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +19 -21
  33. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +2 -2
  34. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +5 -2
  35. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +3 -1
  36. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +7 -15
  37. package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +2 -1
  38. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +20 -15
  39. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +1 -2
  40. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +306 -0
  41. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +43 -4
  42. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +8 -0
  43. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +10 -0
  44. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +6 -2
  45. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +6 -0
  46. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +197 -60
  47. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +132 -83
  48. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +54 -62
  49. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +97 -0
  50. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +377 -0
  51. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +122 -24
  52. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +34 -14
  53. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +3 -3
  54. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +1 -2
  55. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +113 -26
  56. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +2 -2
  57. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +1 -2
  58. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HelpCenterRequest.kt +37 -0
  59. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +192 -0
  60. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +2 -1
  61. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/ToolkitRequests.kt +8 -0
  62. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +3 -0
  63. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +20 -0
  64. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +13 -0
  65. package/compose/src/commonTest/kotlin/vn/momo/kits/navigation/component/HelpCenterRequestTest.kt +44 -0
  66. package/compose/src/commonTest/kotlin/vn/momo/kits/navigation/component/ToolkitRequestsTest.kt +15 -0
  67. package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +3 -1
  68. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +4 -0
  69. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +138 -0
  70. package/gradle/libs.versions.toml +3 -14
  71. package/gradle/wrapper/gradle-wrapper.properties +0 -2
  72. package/gradle.properties +3 -5
  73. package/ios/Application/ApplicationEnvironment.swift +24 -6
  74. package/ios/Application/DesignSystemKits.swift +100 -0
  75. package/ios/Application/DesignSystemKitsPayload.swift +68 -0
  76. package/ios/Application/MaxApi.swift +30 -3
  77. package/ios/Avatar/Avatar.swift +4 -3
  78. package/ios/Badge/Badge.swift +6 -4
  79. package/ios/Badge/BadgeDot.swift +1 -1
  80. package/ios/Badge/BadgeRibbon.swift +8 -2
  81. package/ios/Button/Button.swift +15 -12
  82. package/ios/CalculatorKeyboard/CalculatorKeyboard.swift +64 -18
  83. package/ios/Carousel/Carousel.swift +29 -12
  84. package/ios/Checkbox/Checkbox.swift +7 -3
  85. package/ios/Chip/Chip.swift +5 -4
  86. package/ios/Collapse/Collapse.swift +70 -19
  87. package/ios/Colors+Radius+Spacing/Colors.swift +10 -1
  88. package/ios/Colors+Radius+Spacing/Radius.swift +6 -0
  89. package/ios/DateTimePicker/DateTimePicker.swift +54 -20
  90. package/ios/DateTimePicker/WheelPicker.swift +103 -105
  91. package/ios/Divider/Divider.swift +37 -4
  92. package/ios/Extensions/Color++.swift +38 -9
  93. package/ios/Extensions/OptionalEnvironmentObject.swift +12 -0
  94. package/ios/Extensions/SkeletonUICompat.swift +11 -0
  95. package/ios/Icon/Icon.swift +21 -23
  96. package/ios/Icon/Icons.swift +1329 -0
  97. package/ios/IconButton/IconButton.swift +15 -7
  98. package/ios/Image/Image.swift +7 -23
  99. package/ios/Information/Information.swift +27 -9
  100. package/ios/Input/ErrorView.swift +2 -2
  101. package/ios/Input/Input.swift +171 -148
  102. package/ios/Input/InputPhoneNumber.swift +10 -9
  103. package/ios/Input/InputSearch.swift +107 -78
  104. package/ios/Input/InputTextArea.swift +133 -148
  105. package/ios/InputDropDown/InputDropDown.swift +10 -7
  106. package/ios/InputMoney/InputMoney.swift +32 -11
  107. package/ios/InputOTP/InputOTP.swift +22 -12
  108. package/ios/Layout/Card.swift +50 -0
  109. package/ios/Layout/GridContext.swift +187 -3
  110. package/ios/Layout/Item.swift +5 -4
  111. package/ios/Layout/ScreenLayout.swift +415 -0
  112. package/ios/Layout/Section.swift +4 -100
  113. package/ios/Loader/Loader.swift +5 -9
  114. package/ios/Lottie/LottieView.swift +14 -2
  115. package/ios/{Application/Navigation/Overplay → Navigation}/BottomSheet.swift +169 -29
  116. package/ios/Navigation/BottomTab/BottomTab.swift +332 -0
  117. package/ios/{Application/Navigation → Navigation}/BottomTab/BottomTabBar.swift +18 -7
  118. package/ios/Navigation/BottomTab/DuoBarCompression.swift +104 -0
  119. package/ios/Navigation/BottomTab/DuoTabBar.swift +329 -0
  120. package/ios/{Application → Navigation/Component}/FloatingButton.swift +43 -54
  121. package/ios/Navigation/Component/Header.swift +545 -0
  122. package/ios/Navigation/Component/HeaderBackground.swift +154 -0
  123. package/ios/Navigation/Component/HeaderJourney.swift +73 -0
  124. package/ios/Navigation/Component/HeaderLocation.swift +48 -0
  125. package/ios/Navigation/Component/HeaderRight.swift +425 -0
  126. package/ios/Navigation/Component/HeaderTitle.swift +36 -0
  127. package/ios/{Application/Navigation → Navigation/Component}/HeaderUser.swift +21 -24
  128. package/ios/Navigation/Component/SideBar.swift +183 -0
  129. package/ios/{Application/Navigation/Overplay → Navigation/Component}/SnackBar.swift +8 -8
  130. package/ios/Navigation/HostBackGestureBridge.swift +78 -0
  131. package/ios/{Application/Navigation/Overplay → Navigation}/ModalScreen.swift +9 -2
  132. package/ios/Navigation/Navigation.swift +204 -0
  133. package/ios/Navigation/NavigationContainer.swift +195 -0
  134. package/ios/{Application/Navigation → Navigation}/Navigator.swift +207 -107
  135. package/ios/Navigation/StackScreen.swift +930 -0
  136. package/ios/Navigation/StackTransition.swift +400 -0
  137. package/ios/Navigation/SwipeBackHaptic.swift +33 -0
  138. package/ios/{Application/Navigation → Navigation}/Tracking/ScreenTracker.swift +12 -5
  139. package/ios/OTPKeyboard/OTPKeyboard.swift +8 -11
  140. package/ios/Pagination/PaginationScroll.swift +2 -2
  141. package/ios/Popup/PopupDisplay.swift +72 -135
  142. package/ios/Popup/PopupInput.swift +10 -13
  143. package/ios/Popup/PopupLayout.swift +138 -0
  144. package/ios/Popup/PopupNotify.swift +59 -69
  145. package/ios/Popup/PopupPromotion.swift +31 -47
  146. package/ios/PopupView/FullscreenPopup.swift +13 -19
  147. package/ios/PopupView/PopupView.swift +1 -1
  148. package/ios/PopupView/Utils++.swift +0 -8
  149. package/ios/ProgressInfo/ProgressInfo.swift +4 -2
  150. package/ios/Radio/Radio.swift +2 -1
  151. package/ios/Rating/Rating.swift +9 -1
  152. package/ios/Resources/dot_loading.json +558 -0
  153. package/ios/Resources/lottie_circle_loader.json +1 -0
  154. package/ios/ScrollIndicator/ScrollIndicator.swift +11 -10
  155. package/ios/Skeleton/Skeleton.swift +22 -27
  156. package/ios/Slider/Slider.swift +16 -18
  157. package/ios/Stepper/Stepper.swift +7 -3
  158. package/ios/Steps/Steps.swift +8 -6
  159. package/ios/SuggestAction/SuggestAction.swift +5 -0
  160. package/ios/Swipeable/SwipeCell.swift +10 -6
  161. package/ios/Switch/Switch.swift +22 -20
  162. package/ios/TabView/TabView.swift +102 -21
  163. package/ios/Tag/Tag.swift +15 -4
  164. package/ios/Template/Logo/Logo.swift +2 -1
  165. package/ios/Template/TrustBanner/TrustBanner.swift +109 -55
  166. package/ios/Title/Title.swift +6 -17
  167. package/ios/Tooltip/Tooltip.swift +29 -14
  168. package/ios/Typography/FontScale.swift +17 -14
  169. package/ios/Typography/Text.swift +156 -20
  170. package/ios/Typography/Typography.swift +2 -2
  171. package/ios/Uploader/Uploader.swift +3 -1
  172. package/ios/native-kits.podspec +2 -3
  173. package/ios-demo/MoMoUIKitsDemo.podspec +1 -3
  174. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ContentView.swift +1 -1
  175. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +80 -0
  176. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +2 -0
  177. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +173 -0
  178. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +77 -1
  179. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupDisplayDemo.swift +1 -0
  180. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +2 -2
  181. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +2 -0
  182. package/package.json +1 -1
  183. package/publish.sh +19 -2
  184. package/scripts/gen-ios-icons.mjs +36 -0
  185. package/compose/src/commonMain/kotlin/vn/momo/kits/application/AnimationSearchInput.kt +0 -52
  186. package/compose/src/commonMain/kotlin/vn/momo/kits/application/FloatingButton.kt +0 -183
  187. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Header.kt +0 -207
  188. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderAnimated.kt +0 -39
  189. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderBackground.kt +0 -86
  190. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderDefault.kt +0 -76
  191. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderExtended.kt +0 -76
  192. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderRight.kt +0 -293
  193. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderTitle.kt +0 -33
  194. package/compose/src/commonMain/kotlin/vn/momo/kits/application/NavigationContainer.kt +0 -110
  195. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Screen.kt +0 -378
  196. package/compose/src/commonMain/kotlin/vn/momo/kits/application/useHeaderSearchAnimation.kt +0 -69
  197. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitDesignSystem.kt +0 -18
  198. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitFontScale.kt +0 -22
  199. package/ios/Application/Components.swift +0 -270
  200. package/ios/Application/ComposeApi.swift +0 -22
  201. package/ios/Application/HeaderRight.swift +0 -306
  202. package/ios/Application/Navigation/BottomTab/BottomTab.swift +0 -104
  203. package/ios/Application/Navigation/HeaderBackProps.swift +0 -66
  204. package/ios/Application/Navigation/HeaderTitle.swift +0 -57
  205. package/ios/Application/Navigation/NavigationContainer.swift +0 -213
  206. package/ios/Application/Navigation/NavigationOptions.swift +0 -109
  207. package/ios/Application/Navigation/NavigatorManager.swift +0 -22
  208. package/ios/Application/Navigation/component/Header.swift +0 -178
  209. package/ios/Application/Navigation/component/HeaderBackground.swift +0 -83
  210. package/ios/Application/Navigation/component/HeaderColor.swift +0 -65
  211. package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +0 -11
  212. package/ios/Application/Navigation/component/NavigationFooter.swift +0 -90
  213. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +0 -196
  214. package/ios/Application/Screen.swift +0 -250
  215. package/ios/Application/StackScreen.swift +0 -492
  216. package/ios/Typography/FontScaleStore.swift +0 -58
  217. package/ios/Typography/KitDesignSystem.swift +0 -17
  218. /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,20 +94,34 @@ 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 screen it was shown over (Compose's overplay root id): it renders inside that screen,
103
+ /// so a later push slides over it instead of re-mounting it on the new top.
104
+ public internal(set) var screenId: Int? = nil
105
+ }
106
+
107
+ // MARK: - Stack
108
+
109
+ /// One back-stack entry, root first. A presented screen is an entry like any other (Compose's
110
+ /// `DynamicDialogRoute` in the same NavHost): it rises instead of sliding in, and later pushes
111
+ /// stack on top of it.
112
+ struct StackEntry: Hashable, Identifiable {
113
+ let id: Int
114
+ let isDialog: Bool
86
115
  }
87
116
 
88
117
  // MARK: - Navigator
89
118
 
90
- @available(iOS 16.0, *)
91
119
  public final class Navigator: ObservableObject {
92
- @Published public var path: [DynamicScreenRoute] = [] {
93
- // Disposal hangs off the path so every route that leaves the stack — pop,
94
- // replace, reset — is released the same way, after its exit transition.
95
- didSet { disposeScreens(droppedFrom: oldValue) }
120
+ @Published private(set) var entries: [StackEntry] = [] {
121
+ // Disposal hangs off the stack so every entry that leaves it — pop, replace, reset,
122
+ // dismiss — is released the same way, after its exit transition.
123
+ didSet { disposeScreens(droppedFrom: oldValue, keeping: entries) }
96
124
  }
97
- @Published public var presented: DynamicDialogRoute?
98
125
  @Published public var overplay: OverplayItem?
99
126
 
100
127
  /// Owned per-Navigator (mirrors Compose's `remember { DynamicScreenRegistry() }` +
@@ -108,8 +135,20 @@ public final class Navigator: ObservableObject {
108
135
  /// actually removed via `finishOverplayDismiss`.
109
136
  private var overplayDismissHandler: (() -> Void)?
110
137
 
111
- public private(set) var rootRoute: DynamicScreenRoute
138
+ public var rootRoute: DynamicScreenRoute { DynamicScreenRoute(id: entries[0].id) }
112
139
  public var composeApi: KitComposeApi?
140
+ /// Host dismiss fallback when there is no `composeApi` (Compose's `maxApi.dismiss`).
141
+ public var maxApi: MaxApi?
142
+
143
+ /// Set by a bottom tab off its first tab: back on the screen hosting the tabs returns to the
144
+ /// first tab (Compose `tabBackHandler`). Keyed by that screen, so a screen pushed or presented
145
+ /// over the tabs still pops normally.
146
+ var tabBackHandler: (screenId: Int, handler: () -> Void)?
147
+
148
+ /// The container's stack view, which plays (and lets the host gesture scrub) the transitions.
149
+ weak var stackController: StackController?
150
+ private var isSeekingBack = false
151
+ private var swipeHaptic = SwipeBackHaptic()
113
152
 
114
153
  public init(
115
154
  initialScreenName: String = "",
@@ -118,22 +157,18 @@ public final class Navigator: ObservableObject {
118
157
  composeApi: KitComposeApi? = nil
119
158
  ) {
120
159
  self.composeApi = composeApi
121
- self.rootRoute = registry.register(
160
+ let root = registry.register(
122
161
  screenName: initialScreenName,
123
162
  content: initialContent,
124
163
  options: options
125
164
  )
165
+ entries = [StackEntry(id: root.id, isDialog: false)]
126
166
  }
127
167
 
128
168
  // MARK: Stack
129
169
 
130
170
  public func push<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
131
- let route = registry.register(
132
- screenName: options?.screenName ?? "",
133
- content: { AnyView(content()) },
134
- options: options
135
- )
136
- path.append(route)
171
+ push(screenName: options?.screenName ?? "", content, options: options)
137
172
  }
138
173
 
139
174
  public func push<V: View>(
@@ -141,40 +176,26 @@ public final class Navigator: ObservableObject {
141
176
  @ViewBuilder _ content: @escaping () -> V,
142
177
  options: NavigationOptions? = nil
143
178
  ) {
144
- let route = registry.register(
145
- screenName: screenName,
146
- content: { AnyView(content()) },
147
- options: options
148
- )
149
- path.append(route)
179
+ entries.append(register(screenName, content, options, isDialog: false))
150
180
  }
151
181
 
152
182
  public func replace<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
153
- guard !path.isEmpty else { return }
154
- path.removeLast()
155
- push(content, options: options)
183
+ replace(screenName: options?.screenName ?? "", content, options: options)
156
184
  }
157
185
 
186
+ /// Compose pops the top entry (a presented one included) and navigates, so the replacement
187
+ /// always lands as a pushed screen; on the root it becomes the new root.
158
188
  public func replace<V: View>(
159
189
  screenName: String,
160
190
  @ViewBuilder _ content: @escaping () -> V,
161
191
  options: NavigationOptions? = nil
162
192
  ) {
163
- guard !path.isEmpty else { return }
164
- path.removeLast()
165
- push(screenName: screenName, content, options: options)
193
+ let entry = register(screenName, content, options, isDialog: false)
194
+ entries = Array(entries.dropLast()) + [entry]
166
195
  }
167
196
 
168
197
  public func reset<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
169
- let previousRoot = rootRoute.id
170
- path.removeAll()
171
- let route = registry.register(
172
- screenName: options?.screenName ?? "",
173
- content: { AnyView(content()) },
174
- options: options
175
- )
176
- rootRoute = route
177
- disposeScreens([previousRoot])
198
+ reset(screenName: options?.screenName ?? "", content, options: options)
178
199
  }
179
200
 
180
201
  public func reset<V: View>(
@@ -182,15 +203,21 @@ public final class Navigator: ObservableObject {
182
203
  @ViewBuilder _ content: @escaping () -> V,
183
204
  options: NavigationOptions? = nil
184
205
  ) {
185
- let previousRoot = rootRoute.id
186
- path.removeAll()
206
+ entries = [register(screenName, content, options, isDialog: false)]
207
+ }
208
+
209
+ private func register<V: View>(
210
+ _ screenName: String,
211
+ _ content: @escaping () -> V,
212
+ _ options: NavigationOptions?,
213
+ isDialog: Bool
214
+ ) -> StackEntry {
187
215
  let route = registry.register(
188
216
  screenName: screenName,
189
217
  content: { AnyView(content()) },
190
218
  options: options
191
219
  )
192
- rootRoute = route
193
- disposeScreens([previousRoot])
220
+ return StackEntry(id: route.id, isDialog: isDialog)
194
221
  }
195
222
 
196
223
  public func pop(_ count: Int = 1, callback: (() -> Void)? = nil) {
@@ -211,20 +238,19 @@ public final class Navigator: ObservableObject {
211
238
  // Mirrors Kotlin: a snackbar doesn't block back-navigation, so popping
212
239
  // while one is showing pops the screen underneath and clears the
213
240
  // snackbar immediately, without playing its dismiss animation.
214
- overplayDismissHandler = nil
215
- overplay = nil
241
+ replaceOverplay(with: nil)
216
242
  guard popScreen() else {
217
243
  callback?()
218
244
  return
219
245
  }
220
246
  popStep(remaining: remaining - 1, callback: callback)
221
- } else if overplay != nil {
247
+ } else if let closing = overplay?.id {
222
248
  hideOverplay()
223
249
  DispatchQueue.main.asyncAfter(deadline: .now() + Self.overplayDismissDelay) { [weak self] in
224
250
  guard let self else { return }
225
251
  // The component reports back through `finishOverplayDismiss` once its exit
226
252
  // animation ends; clear it here if it never did, so the step is not wasted.
227
- if self.overplay != nil { self.finishOverplayDismiss() }
253
+ self.finishOverplayDismiss(id: closing)
228
254
  self.popStep(remaining: remaining - 1, callback: callback)
229
255
  }
230
256
  } else {
@@ -236,43 +262,62 @@ public final class Navigator: ObservableObject {
236
262
  }
237
263
  }
238
264
 
239
- /// Pops the topmost presented screen or stack entry. Returns `false` (and requests
240
- /// a host dismiss) when there is nothing left to pop.
265
+ /// Pops the top entry. Returns `false` (and requests a host dismiss) on the root.
241
266
  @discardableResult
242
267
  private func popScreen() -> Bool {
243
- if presented != nil {
244
- let dismissedId = presented?.id
245
- presented = nil
246
- if let id = dismissedId { disposeScreens([id]) }
247
- return true
248
- } else if !path.isEmpty {
249
- path.removeLast()
250
- return true
251
- } else {
252
- composeApi?.requestCallback(funcName: "dismiss", params: nil, onResponse: nil)
268
+ guard canPop() else {
269
+ requestHostDismiss()
253
270
  return false
254
271
  }
272
+ entries.removeLast()
273
+ return true
255
274
  }
256
275
 
276
+ private func requestHostDismiss() {
277
+ if let composeApi {
278
+ composeApi.requestCallback(funcName: "dismiss", params: nil, onResponse: nil)
279
+ } else {
280
+ maxApi?.dismiss(completion: nil)
281
+ }
282
+ }
283
+
284
+ /// Whether there is a screen to go back to (Compose's `canPop`); at the root a back
285
+ /// dismisses the mini app instead.
286
+ public func canPop() -> Bool {
287
+ entries.count > 1
288
+ }
289
+
290
+ /// Back to the root of the active flow: the topmost presented screen while one is up,
291
+ /// the stack root otherwise.
257
292
  public func popToRoot() {
258
- path.removeAll()
293
+ let root = entries.lastIndex(where: \.isDialog) ?? 0
294
+ entries = Array(entries[...root])
259
295
  }
260
296
 
261
- /// The entry a back action applies to — mirrors Compose's `currentScreen()`
262
- /// (`navController.currentBackStackEntry`): a presented screen sits on top of
263
- /// the stack, otherwise it is the last pushed screen, otherwise the root.
297
+ /// The entry a back action applies to — Compose's `currentScreen()`
298
+ /// (`navController.currentBackStackEntry`).
264
299
  private func currentScreen() -> DynamicScreen? {
265
- registry.getScreen(id: presented?.id ?? path.last?.id ?? rootRoute.id)
300
+ entries.last.flatMap { registry.getScreen(id: $0.id) }
266
301
  }
267
302
 
268
303
  public func onBackSafe(callback: (() -> Void)? = nil) {
304
+ // An open modal or bottom sheet takes the back first (their BackHandler in Compose).
305
+ if let overplay, overplay.type != .snackBar {
306
+ pop(1, callback: callback)
307
+ return
308
+ }
269
309
  let latest = currentScreen()
310
+ if let tabBackHandler, tabBackHandler.screenId == -1 || tabBackHandler.screenId == latest?.id {
311
+ tabBackHandler.handler()
312
+ return
313
+ }
270
314
  if let onBack = latest?.options?.onBackHandler {
271
315
  onBack()
272
316
  return
273
317
  }
274
318
  if let preventBack = latest?.options?.headerBackProps?.preventBack {
275
- showModal {
319
+ // stored in `overplay`, so a strong self would cycle until the popup closes
320
+ showModal { [weak self] in
276
321
  PopupNotify(
277
322
  props: PopupNotifyProps(
278
323
  image: preventBack.image,
@@ -281,7 +326,7 @@ public final class Navigator: ObservableObject {
281
326
  error: preventBack.error,
282
327
  primary: PopupAction(
283
328
  title: preventBack.primary?.title ?? "",
284
- onPress: { [weak self] in
329
+ onPress: {
285
330
  preventBack.primary?.onPress?()
286
331
  // Close the popup, then leave the screen (matches Compose's pop(2)).
287
332
  self?.pop(2, callback: callback)
@@ -290,14 +335,14 @@ public final class Navigator: ObservableObject {
290
335
  secondary: preventBack.secondary.map { secondary in
291
336
  PopupAction(
292
337
  title: secondary.title,
293
- onPress: { [weak self] in
338
+ onPress: {
294
339
  secondary.onPress?()
295
340
  self?.hideOverplay()
296
341
  }
297
342
  )
298
343
  },
299
344
  buttonDirection: preventBack.buttonDirection,
300
- onIconClose: { [weak self] in
345
+ onIconClose: {
301
346
  preventBack.onIconClose?()
302
347
  self?.hideOverplay()
303
348
  },
@@ -313,12 +358,7 @@ public final class Navigator: ObservableObject {
313
358
  // MARK: Modal-style screen (vertical slide)
314
359
 
315
360
  public func present<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
316
- let route = registry.register(
317
- screenName: options?.screenName ?? "",
318
- content: { AnyView(content()) },
319
- options: options
320
- )
321
- presented = DynamicDialogRoute(id: route.id)
361
+ present(screenName: options?.screenName ?? "", content, options: options)
322
362
  }
323
363
 
324
364
  public func present<V: View>(
@@ -326,19 +366,55 @@ public final class Navigator: ObservableObject {
326
366
  @ViewBuilder _ content: @escaping () -> V,
327
367
  options: NavigationOptions? = nil
328
368
  ) {
329
- let route = registry.register(
330
- screenName: screenName,
331
- content: { AnyView(content()) },
332
- options: options
333
- )
334
- presented = DynamicDialogRoute(id: route.id)
369
+ entries.append(register(screenName, content, options, isDialog: true))
335
370
  }
336
371
 
372
+ /// Closes the topmost presented screen together with everything pushed over it.
337
373
  public func dismiss() {
338
- if let id = presented?.id {
339
- registry.unregister(id: id)
374
+ guard let presented = entries.lastIndex(where: \.isDialog) else { return }
375
+ entries = Array(entries[..<presented])
376
+ }
377
+
378
+ // MARK: Host back gesture (Compose's HostBackTarget)
379
+
380
+ /// Only a plain pop is scrubbed. A back the screen handles itself — tab back, custom
381
+ /// handler, prevent-back popup, an open overplay — or one closing a presented screen
382
+ /// waits for the release and goes through `onBackSafe`, as RN turns the gesture off there.
383
+ private var canSeekBack: Bool {
384
+ guard canPop(), overplay == nil, let top = entries.last, !top.isDialog else { return false }
385
+ if let tabBackHandler, tabBackHandler.screenId == -1 || tabBackHandler.screenId == top.id {
386
+ return false
340
387
  }
341
- presented = nil
388
+ let options = registry.getScreen(id: top.id)?.options
389
+ return options?.onBackHandler == nil && options?.headerBackProps?.preventBack == nil
390
+ }
391
+
392
+ func backStarted() {
393
+ swipeHaptic.reset()
394
+ isSeekingBack = canSeekBack && stackController?.beginBackSeek() == true
395
+ }
396
+
397
+ func backProgressed(_ progress: CGFloat) {
398
+ guard isSeekingBack, let stackController else { return }
399
+ stackController.seekBack(to: progress)
400
+ swipeHaptic.update(travelled: progress * stackController.view.bounds.width, maxApi: maxApi)
401
+ }
402
+
403
+ func backCompleted() {
404
+ let wasSeeking = isSeekingBack
405
+ isSeekingBack = false
406
+ // A seek cut short by a programmatic change falls back to a regular back.
407
+ guard wasSeeking, stackController?.finishBackSeek() == true else {
408
+ onBackSafe()
409
+ return
410
+ }
411
+ entries.removeLast()
412
+ }
413
+
414
+ func backCancelled() {
415
+ guard isSeekingBack else { return }
416
+ isSeekingBack = false
417
+ stackController?.cancelBackSeek()
342
418
  }
343
419
 
344
420
  // MARK: Overplay
@@ -348,13 +424,13 @@ public final class Navigator: ObservableObject {
348
424
  barrierDismissible: Bool = true,
349
425
  onDismiss: (() -> Void)? = nil
350
426
  ) {
351
- overplayDismissHandler = nil
352
- overplay = OverplayItem(
427
+ replaceOverplay(with: OverplayItem(
353
428
  type: .modal,
354
429
  content: { AnyView(content()) },
355
430
  barrierDismissible: barrierDismissible,
356
- onDismiss: onDismiss
357
- )
431
+ onDismiss: onDismiss,
432
+ screenId: entries.last?.id
433
+ ))
358
434
  }
359
435
 
360
436
  public func showBottomSheet<V: View>(
@@ -362,29 +438,35 @@ public final class Navigator: ObservableObject {
362
438
  isSurface: Bool = false,
363
439
  barrierDismissible: Bool = true,
364
440
  header: BottomSheetHeader? = nil,
365
- onDismiss: (() -> Void)? = nil
441
+ onDismiss: (() -> Void)? = nil,
442
+ draggable: Bool = true,
443
+ useBottomInset: Bool = true,
444
+ footer: (() -> AnyView)? = nil
366
445
  ) {
367
- overplayDismissHandler = nil
368
- overplay = OverplayItem(
446
+ replaceOverplay(with: OverplayItem(
369
447
  type: .bottomSheet,
370
448
  content: { AnyView(content()) },
371
449
  barrierDismissible: barrierDismissible,
372
450
  onDismiss: onDismiss,
373
451
  isSurface: isSurface,
374
- header: header
375
- )
452
+ header: header,
453
+ draggable: draggable,
454
+ useBottomInset: useBottomInset,
455
+ footer: footer,
456
+ screenId: entries.last?.id
457
+ ))
376
458
  }
377
459
 
378
460
  /// Mirrors Compose's `showSnackBar(snackBar:)`.
379
461
  public func showSnackBar(_ snackBar: SnackBar, onDismiss: (() -> Void)? = nil) {
380
- overplayDismissHandler = nil
381
- overplay = OverplayItem(
462
+ replaceOverplay(with: OverplayItem(
382
463
  type: .snackBar,
383
464
  content: { AnyView(EmptyView()) },
384
465
  barrierDismissible: false,
385
466
  onDismiss: onDismiss,
386
- snackBar: snackBar
387
- )
467
+ snackBar: snackBar,
468
+ screenId: entries.last?.id
469
+ ))
388
470
  }
389
471
 
390
472
  // MARK: Overplay dismissal
@@ -399,10 +481,23 @@ public final class Navigator: ObservableObject {
399
481
  /// `hardClearAfterDismiss`). Called by a component once its exit animation
400
482
  /// has completed.
401
483
  public func finishOverplayDismiss() {
402
- let dismissCallback = overplay?.onDismiss
484
+ replaceOverplay(with: nil)
485
+ }
486
+
487
+ /// Same, but only while `id` is still the overplay: an exit that ends after a newer overplay
488
+ /// took its place must not take that one down.
489
+ public func finishOverplayDismiss(id: UUID) {
490
+ guard overplay?.id == id else { return }
491
+ replaceOverplay(with: nil)
492
+ }
493
+
494
+ /// A replaced overplay counts as dismissed (Compose disposes it), so its `onDismiss` fires once,
495
+ /// whether it was still closing or not.
496
+ private func replaceOverplay(with item: OverplayItem?) {
497
+ let replaced = overplay
403
498
  overplayDismissHandler = nil
404
- overplay = nil
405
- dismissCallback?()
499
+ overplay = item
500
+ replaced?.onDismiss?()
406
501
  }
407
502
 
408
503
  public func hideOverplay() {
@@ -419,6 +514,14 @@ public final class Navigator: ObservableObject {
419
514
  if overplay?.type == .snackBar { hideOverplay() }
420
515
  }
421
516
 
517
+ /// Room the current screen's footer (or navigation bar) takes from the window bottom.
518
+ func snackBarBottomInset() -> CGFloat {
519
+ guard let id = currentScreen()?.id, let navigation = registry.navigation(id: id) else {
520
+ return NavigationFooter.navigationBarInset
521
+ }
522
+ return navigation.bottomObstruction
523
+ }
524
+
422
525
  // MARK: Screen disposal
423
526
 
424
527
  /// Matches the `delay(300)` Compose waits in `dismissScreen` before unregistering,
@@ -429,22 +532,19 @@ public final class Navigator: ObservableObject {
429
532
  /// after the component has taken itself down.
430
533
  private static let overplayDismissDelay: TimeInterval = 0.3
431
534
 
432
- private func disposeScreens(droppedFrom previous: [DynamicScreenRoute]) {
433
- let live = Set(path.map(\.id))
434
- disposeScreens(previous.map(\.id).filter { !live.contains($0) })
535
+ private func disposeScreens(droppedFrom previous: [StackEntry], keeping current: [StackEntry]) {
536
+ let kept = Set(current.map(\.id))
537
+ disposeScreens(previous.map(\.id).filter { !kept.contains($0) })
435
538
  }
436
539
 
437
540
  /// Screens are released only once the pop transition has played out: the outgoing
438
- /// view is still on screen while it animates and the registry is what renders it —
439
- /// dropping it right away blanks the screen mid-transition. Liveness is re-checked
440
- /// when the timer fires, so a route pushed back before then keeps its screen.
541
+ /// view is still on screen while it animates. Liveness is re-checked when the timer
542
+ /// fires, so an entry back on the stack by then keeps its screen.
441
543
  private func disposeScreens(_ ids: [Int]) {
442
544
  guard !ids.isEmpty else { return }
443
545
  DispatchQueue.main.asyncAfter(deadline: .now() + Self.screenDisposeDelay) { [weak self] in
444
546
  guard let self else { return }
445
- var live = Set(self.path.map(\.id))
446
- live.insert(self.rootRoute.id)
447
- if let presented = self.presented { live.insert(presented.id) }
547
+ let live = Set(self.entries.map(\.id))
448
548
  for id in ids where !live.contains(id) {
449
549
  self.registry.unregister(id: id)
450
550
  }