@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
package/CLAUDE.md CHANGED
@@ -35,6 +35,13 @@ No unit-test suite is configured. Validate changes by compiling the relevant tar
35
35
  ./gradlew :sample:androidApp:installDebug # run demo on device/emulator
36
36
  ```
37
37
 
38
+ The SwiftUI kit (`ios/`, pod `MoMoUIKits`) compiles on its own through the sample's Pods project (`pod install` in `sample/iosApp` first when files are added):
39
+
40
+ ```bash
41
+ xcodebuild -project sample/iosApp/Pods/Pods.xcodeproj -scheme MoMoUIKits -sdk iphonesimulator \
42
+ -destination 'generic/platform=iOS Simulator' build
43
+ ```
44
+
38
45
  Publishing is automated (`publish.sh` + GitLab CI on `[ci build]` / `main`). Do not publish manually unless asked: `sh publish.sh beta` / `sh publish.sh release` (Maven→GitLab + NPM).
39
46
 
40
47
  ## Architecture essentials
@@ -47,6 +54,28 @@ Publishing is automated (`publish.sh` + GitLab CI on `[ci build]` / `main`). Do
47
54
  - Navigation uses a runtime `DynamicScreenRegistry`; overlays (modal/bottom-sheet/snackbar) go through `Navigator` + `OverplayComponentRegistry`.
48
55
  - **iOS swipe-back:** the host owns the only edge-pan recogniser and replays it through `HostBackGestureBridge` (`navigation/HostBackGestureBridge.kt`; the kit framework is never exported, so the host reaches it through its own Koin-provided facade): `checkCanPop` / `canPopInner` / `pop` route the back into the kit stack, `started` / `progressed` / `completed` / `cancelled` seek the NavHost transition, `shouldUseFloatingArrowBack` is set by bottom tabs. `NavigationContainer` attaches itself automatically (`AttachHostBackGesture`, iOS only, detached by identity on dispose) — hosts need no wrapper and no `setNavigator`. `maxApi` is a required parameter of `NavigationContainer` — every container, nested or root, is handed its host bridge explicitly.
49
56
 
57
+ ## SwiftUI kit (`ios/`)
58
+
59
+ - The pod's minimum is **iOS 15**, but the navigation shell (`NavigationContainer`, `Navigator`, `NavigatorManager`, `BottomTab`, overplay views) is **iOS 16+** (`@available(iOS 16.0, *)`) because it is built on `NavigationStack`. Keep components outside that shell iOS 15-compatible.
60
+ - `present` opens one `fullScreenCover` that hosts its own `NavigationStack` (`Navigator.presentedPath`): pushes and pops act on the presented flow while it is up, and each overplay (modal / bottom sheet / snackbar) renders in the context it was shown over (`OverplayItem.presentedId`).
61
+ - Icons are compiled in: `ios/Icon/Icons.swift` is generated from Compose `utils/Icons.kt` by `node scripts/gen-ios-icons.mjs` — don't hand-edit it. A host `icon.json` in the main bundle only adds keys the kit lacks.
62
+ - Bundled assets (colors, Lottie JSON) live in `ios/Resources` and ship in the `MoMoUIKitsResources` bundle; load them through `MoMoUIKitsResources.bundle`, never `Bundle.main`.
63
+
64
+ ## Screen layout / iPhone Duo (port of RN `useScreenLayout`, momo-kits !664)
65
+
66
+ - `layout/ScreenLayout.kt`: `getScreenLayout()` returns window size, raw top/bottom + side insets, `contentWidth`, header geometry, the iPhone Duo `sideBar` and `duoTopInset`. `NavigationContainer` computes it once and provides `LocalScreenLayout`; reactive to fold/unfold and rotation (window size + `WindowInsets`). Metrics (`DuoMetrics`, 72pt iPhone header floor, 20pt status-bar threshold) mirror RN `Layout/utils.ts` — change both together.
67
+ - iPhone-only: everything gates on `isPhoneIdiom()` (`platform/`), never on "iOS", so iPad windows keep the regular layout. Two detectors, as in RN: `isIPhoneDuo` (window aspect < 1.6 or asymmetric side insets) drives layout; `isIPhoneDuoDevice` (short side of screen or window > 440, RN `device.ts`) drives popups, modal and bottom sheet.
68
+ - Header height is `headerHeight` (not `AppStatusBar + HEADER_HEIGHT`); the bar row is `headerBarHeight` and starts `headerStatusBarHeight` below the window top. When the status bar is off the top edge (iPhone landscape, iPhone Duo) `headerStatusBarHeight` is 0 so the title stays centered — except on the Duo outer display in portrait, where the header is 76pt and the row starts at 14pt so the title centers on the camera punchout.
69
+ - iPhone Duo with a side strip ≥ 60pt: back + toolkit leave the bar for `HeaderSideBar` (`navigation/component/SideBar.kt`, 48pt glass controls); custom header-right stays in the bar. `sideBar.top` is 170pt in portrait, 120pt on the inner display in landscape (height ≥ 600), 24pt on the outer display in landscape, or below the header when the status bar is on top; UIKit's `statusBarFrame` is useless on Duo. Unlike RN the column is clamped above footer (24pt gap) / rail (12pt gap).
70
+ - Reserved regions (iOS 27.1 `reservedRegionsOfKind:`, reached through the ObjC runtime so older SDKs still build): a probe `UIView` (`platform/ReservedRegions.ios.kt` on the Compose root view; `ReservedRegionsProbe` inside SwiftUI `momoScreenLayout()`) re-reads them in `layoutSubviews`, which UIKit re-runs when they change. They push `sideBar.top` below the status cluster as it grows for calls / Live Activities, and `sideBar.bottom` (≥ 24pt) above a camera at the bottom end of the strip.
71
+ - Tab bar on Duo is `bottomtab/DuoTabBar.kt`: a rail in the side strip (`sideBar.bottom` above the window bottom, QR below it) or, without a side bar, a floating bar spanning 6/8 grid columns. Both overlay the content; `duoFloatingTabBarInset` pads tab content, footer and FAB.
72
+ - Bar compression (`bottomtab/DuoBarCompression.kt`, HIG "Vertical controls"): when toolbar + rail don't fit in the strip, one is compressed — the toolkit into a `navigation_menu` button, the rail into one button showing the current tab — and tapping it swaps them; `BottomTab(compressionBehavior)` picks which starts compressed. If neither fits beside the other compressed, both rest compressed and a tap opens one for a moment. Side bar columns report their full height so the decision follows each screen's toolkit.
73
+ - Glass header: on Duo with a side bar, Default/Extended headers without search and with a `ScrollState` let content scroll under the bar (`usesDuoGlassHeader`); the bar fades in a translucent surface + hairline over 16pt (Compose has no backdrop blur — RN's fallback look), the gradient leaves with the content.
74
+ - Quick return header: glass headers and Animated headers (with a `ScrollState`) on the Duo side bar layout slide out with the content and back in on a reverse scroll; a half-shown header settles in or out once the scroll stops. The side bar is drawn apart from the header, so it stays put.
75
+ - Overlays on Duo: popups cap at 440pt wide and the room between `duoTopInset` and the bottom inset (body scrolls, actions stay); modal centers inside that room; bottom sheet uses the window height, centers at width ≥ 600 and pads by side insets in landscape.
76
+ - Font scale deliberately uses the short side (`min(w, h)`), unlike RN which reads the window width — landscape must not inflate every font.
77
+ - `StackScreen` pads content/footer/search/FAB by the side insets and provides an inset-consumed layout to its content; a bottom-tab root stays full width (tab bar + each tab's StackScreen handle insets). Grid columns use `contentWidth`.
78
+
50
79
  ## i18n / Localize (port of RN `Localize`)
51
80
 
52
81
  - `application/Localize.kt`: everything i18n. `LocalizationObject` is a data class with required `vi` / `en` maps (`LocalizationObject(vi = mapOf(…), en = mapOf(…))`). `Localize` constructors take a `LocalizationObject` or a `(vi, en)` map pair; the constructor **merges** the kit defaults with the host data (host wins on key clash). Methods: `translate(key)` (missing/empty → returns the key), `translateData(map)` / `translateData(vi, en)`, `addTranslations` (existing keys win), `changeLanguage(lang)`. Default language `"vi"`, `"en"` only on exact match; language is snapshot state so a switch recomposes consumers.
@@ -97,3 +126,10 @@ Prefer one tight line at the surprising spot over a paragraph. Match the (low) c
97
126
  - **Resource qualifier drift:** the generated Compose `Res` namespace is `vn.momo.uikits.resources`, but `utils/Resources.kt` and `platform/Platform.ios.kt` reference `vn.momo.compose.resources`. Keep bundled strings/dictionaries as plain Kotlin (the `defaultLanguage` map in `Localize.kt`), not `composeResources/values`, to avoid iOS framework resource-loading issues.
98
127
  - Prefer `NavigationContainer` over the deprecated `ApplicationContainer`; prefer `IMaxApi`/`LocalMaxApi` over the deprecated `PlatformApi`.
99
128
  - iOS strings/maps must survive Kotlin/Native framework packaging — keep them compiled-in, not loaded from resource bundles.
129
+
130
+ ## Code comments
131
+ - Only comment when necessary: non-obvious logic, workarounds, or the "why" behind a decision.
132
+ - Don't comment on what the code already says clearly (no "// increment i", no restating function names).
133
+ - Keep comments short: one line when possible, plain and easy to understand.
134
+ - No long comment blocks, no line-by-line narration, no lines that overflow the editor width.
135
+ - Don't leave comments about the change itself (e.g. "// updated this", "// new logic").
@@ -41,6 +41,12 @@ public class KitConfig {
41
41
  public var trustBanner: TrustBannerData? = nil
42
42
  public var headerBar: String? = nil
43
43
  public var headerGradient: String? = nil
44
+
45
+ public init(trustBanner: TrustBannerData? = nil, headerBar: String? = nil, headerGradient: String? = nil) {
46
+ self.trustBanner = trustBanner
47
+ self.headerBar = headerBar
48
+ self.headerGradient = headerGradient
49
+ }
44
50
  }
45
51
 
46
52
  public class ApplicationEnvironment: ObservableObject {
@@ -248,9 +248,7 @@ public struct Header: View {
248
248
  if let inputProps = inputSearchProps {
249
249
  InputSearch(props: inputProps)
250
250
  } else if case let .user(userData)? = headerTitle {
251
- if #available(iOS 16.0, *) {
252
- HeaderUserView(userData)
253
- }
251
+ HeaderUserView(userData)
254
252
  } else {
255
253
  MomoText(headerTitle?.defaultTitle ?? title, typography: .headerDefaultBold, color: tintColor ?? Colors.black17, maxLines: 1)
256
254
  .opacity(titleOpacity)
@@ -7,20 +7,31 @@ public struct HeaderRightData {
7
7
  var tools: [ToolGroup]
8
8
  var toolCallback: ((String) -> Void)?
9
9
  var useSystemTools: Bool
10
+ var useCloseIcon: Bool
10
11
 
11
12
  public init(
12
13
  useShortcut: Bool = false,
13
14
  useMore: Bool = false,
14
15
  tools: [ToolGroup] = [],
15
16
  toolCallback: ((String) -> Void)? = nil,
16
- useSystemTools: Bool = true
17
+ useSystemTools: Bool = true,
18
+ useCloseIcon: Bool = false
17
19
  ) {
18
20
  self.useShortcut = useShortcut
19
21
  self.useMore = useMore
20
22
  self.tools = tools
21
23
  self.toolCallback = toolCallback
22
24
  self.useSystemTools = useSystemTools
25
+ self.useCloseIcon = useCloseIcon
23
26
  }
27
+
28
+ /// Close-circle instead of home, as RN `headerRight.useCloseIcon`.
29
+ var closeIcon: String { useCloseIcon ? "16_navigation_close_circle" : "16_basic_home" }
30
+ }
31
+
32
+ /// The Help Center mini-app hides its own help-center button.
33
+ func showsHelpCenter(_ context: MiniAppContext?) -> Bool {
34
+ context?.appId != "vn.momo.helpcenter"
24
35
  }
25
36
 
26
37
  public struct Tool {
@@ -101,8 +112,13 @@ struct ToolkitHeaderRight: View {
101
112
  var tintColor: Color?
102
113
  @State private var isFavorite: Bool = false
103
114
  @State private var isLoading: Bool = false
104
- @EnvironmentObject private var environment: ApplicationEnvironment
105
-
115
+ // Legacy hosts inject with `.environmentObject`, containers with the keyed value; outside
116
+ // both this used to trap, so read the object optionally and fall back to the keyed one.
117
+ @OptionalEnvironmentObject private var injectedEnvironment: ApplicationEnvironment?
118
+ @Environment(\.applicationEnvironment) private var keyedEnvironment
119
+
120
+ private var environment: ApplicationEnvironment { injectedEnvironment ?? keyedEnvironment }
121
+
106
122
  // MARK: - Actions
107
123
  private func onPressShortcut() {
108
124
  // onToolAction needs the app context (appId/code) so the host knows which app to (un)favorite;
@@ -238,17 +254,19 @@ struct ToolkitHeaderRight: View {
238
254
  }
239
255
 
240
256
  HStack(alignment: .center, spacing: 0) {
241
- Icon(source: "help_center", size: 20, color: tintColor ?? Colors.black17)
242
- .padding(4)
243
- .onTapGesture {
244
- onPressHelpCenter()
245
- }
246
-
247
- Rectangle()
248
- .fill(tintColor ?? Colors.black20)
249
- .frame(width: 0.5, height: 12)
250
-
251
- Icon(source: "16_basic_home", size: 20, color: tintColor ?? Colors.black17)
257
+ if showsHelpCenter(environment.applicationContext) {
258
+ Icon(source: "help_center", size: 20, color: tintColor ?? Colors.black17)
259
+ .padding(4)
260
+ .onTapGesture {
261
+ onPressHelpCenter()
262
+ }
263
+
264
+ Rectangle()
265
+ .fill(tintColor ?? Colors.black20)
266
+ .frame(width: 0.5, height: 12)
267
+ }
268
+
269
+ Icon(source: headerRight?.closeIcon ?? "16_basic_home", size: 20, color: tintColor ?? Colors.black17)
252
270
  .padding(4)
253
271
  .onTapGesture {
254
272
  onPressClose()
@@ -0,0 +1,312 @@
1
+ //
2
+ // ScreenLayout.swift
3
+ // MoMoUIKits
4
+ //
5
+ // Port of momo-kits RN `useScreenLayout` (Layout/utils.ts) and `Application/device.ts`:
6
+ // window size, safe-area insets, content width, header geometry and the iPhone Duo side bar.
7
+ //
8
+
9
+ import SwiftUI
10
+ import UIKit
11
+
12
+ public enum SideBarEdge: Equatable {
13
+ case left, right
14
+ }
15
+
16
+ public struct SideBarLayout: Equatable {
17
+ public let edge: SideBarEdge
18
+ public let width: CGFloat
19
+ /// Preferred window y of the first control; StackScreen still keeps the column above the bottom bars.
20
+ public let top: CGFloat
21
+ /// Room kept free above the window bottom, clear of a camera at the bottom end of the strip.
22
+ public let bottom: CGFloat
23
+ }
24
+
25
+ public struct ScreenLayout: Equatable {
26
+ public let width: CGFloat
27
+ public let height: CGFloat
28
+ public let isIPhoneDuo: Bool
29
+ public let insets: EdgeInsets
30
+ public let insetLeft: CGFloat
31
+ public let insetRight: CGFloat
32
+ public let contentWidth: CGFloat
33
+ public let headerHeight: CGFloat
34
+ /// 0 when the status bar is off the top edge, so the bar row fills the header and keeps the title centered.
35
+ public let headerStatusBarHeight: CGFloat
36
+ /// iPhone Duo moves back and toolkit into a column in the side safe area; nil while they stay in the bar.
37
+ public let sideBar: SideBarLayout?
38
+
39
+ public var headerBarHeight: CGFloat { headerHeight - headerStatusBarHeight }
40
+ }
41
+
42
+ // Measured by momo-kits RN (Layout/utils.ts) on the iOS 27.1 iPhone Duo simulator; keep both in sync.
43
+ enum DuoMetrics {
44
+ // Inner display is ~1.42, outer ~1.45; every slab iPhone is 1.77 or taller.
45
+ static let maxAspectRatio: CGFloat = 1.6
46
+ // Outer display is 466pt tall in landscape, inner 669pt.
47
+ static let innerMinHeight: CGFloat = 600
48
+ static let controlSize: CGFloat = 48
49
+ static let itemPitch: CGFloat = 50
50
+ static let firstItemTop: CGFloat = 170
51
+ static let firstItemTopLandscape: CGFloat = 120
52
+ // Outer display in portrait: the header is 4pt taller than the iPhone floor and its title
53
+ // lines up with the camera punchout (y 24–66 at the top of the strip).
54
+ static let outerHeaderHeight: CGFloat = 76
55
+ static let outerCameraCenterY: CGFloat = 45
56
+ // Outer display in landscape has no status bar in the strip: controls keep this far from
57
+ // both ends unless a reserved region (the camera at either end) asks for more.
58
+ static let edgeInset: CGFloat = 24
59
+ // Widest short side of any single-display iPhone.
60
+ static let iPhoneMaxShortSide: CGFloat = 440
61
+ }
62
+
63
+ enum HeaderMetrics {
64
+ static let barHeight: CGFloat = HEADER_HEIGHT
65
+ static let iPhoneMinHeight: CGFloat = 72
66
+ // A smaller top inset means the status bar is not on the top edge.
67
+ static let statusBarMinInset: CGFloat = 20
68
+ }
69
+
70
+ private var isPhoneIdiom: Bool { UIDevice.current.userInterfaceIdiom == .phone }
71
+
72
+ private var keyWindow: UIWindow? {
73
+ UIApplication.shared.connectedScenes
74
+ .compactMap { ($0 as? UIWindowScene)?.keyWindow }
75
+ .first
76
+ }
77
+
78
+ extension ScreenLayout {
79
+ /// Snapshot of the key window; not reactive, so views read `\.screenLayout` instead.
80
+ public static var current: ScreenLayout {
81
+ guard let window = keyWindow else {
82
+ return make(size: UIScreen.main.bounds.size, insets: EdgeInsets())
83
+ }
84
+ let inset = window.safeAreaInsets
85
+ return make(
86
+ size: window.bounds.size,
87
+ insets: EdgeInsets(top: inset.top, leading: inset.left, bottom: inset.bottom, trailing: inset.right)
88
+ )
89
+ }
90
+
91
+ static func make(
92
+ size: CGSize,
93
+ insets: EdgeInsets,
94
+ reservedRegions: [CGRect] = ReservedRegions.current,
95
+ isPhone: Bool = isPhoneIdiom
96
+ ) -> ScreenLayout {
97
+ let width = size.width
98
+ let height = size.height
99
+ var (headerHeight, headerStatusBarHeight) = headerGeometry(topInset: insets.top, isPhone: isPhone)
100
+ let isDuo = isPhone && isIPhoneDuoWindow(width: width, height: height, insets: insets)
101
+ guard isDuo else {
102
+ return ScreenLayout(
103
+ width: width, height: height, isIPhoneDuo: false, insets: insets,
104
+ insetLeft: 0, insetRight: 0, contentWidth: width,
105
+ headerHeight: headerHeight, headerStatusBarHeight: headerStatusBarHeight, sideBar: nil
106
+ )
107
+ }
108
+
109
+ var sideBar: SideBarLayout?
110
+ let sideInset = max(insets.leading, insets.trailing)
111
+ // narrower strips cannot host a control 12pt from the inner edge
112
+ if sideInset >= DuoMetrics.controlSize + Spacing.M {
113
+ var top = insets.top + HeaderMetrics.barHeight + Spacing.M
114
+ if insets.top < HeaderMetrics.statusBarMinInset {
115
+ // status bar lives in the strip: start under its cluster, as the native bar does
116
+ if width <= height {
117
+ top = DuoMetrics.firstItemTop
118
+ } else if height >= DuoMetrics.innerMinHeight {
119
+ top = DuoMetrics.firstItemTopLandscape
120
+ } else {
121
+ // outer display in landscape: no status bar in the strip
122
+ top = DuoMetrics.edgeInset
123
+ }
124
+
125
+ if height > width && width < DuoMetrics.innerMinHeight {
126
+ // outer display: the row starts below the top so the title centers on the camera
127
+ headerHeight = DuoMetrics.outerHeaderHeight
128
+ headerStatusBarHeight = DuoMetrics.outerCameraCenterY * 2 - DuoMetrics.outerHeaderHeight
129
+ }
130
+ }
131
+
132
+ let edge: SideBarEdge = insets.trailing >= insets.leading ? .right : .left
133
+ let stripLeft = edge == .right ? width - sideInset : 0
134
+ // clear of the camera and of the status bar cluster, which grows for calls and Live
135
+ // Activities; region frames already include the system's margin
136
+ let occlusion = stripOcclusion(reservedRegions, stripLeft: stripLeft, stripRight: stripLeft + sideInset, windowHeight: height)
137
+ sideBar = SideBarLayout(
138
+ edge: edge,
139
+ width: sideInset,
140
+ top: max(top, occlusion.top),
141
+ bottom: max(DuoMetrics.edgeInset, occlusion.bottom)
142
+ )
143
+ }
144
+
145
+ return ScreenLayout(
146
+ width: width, height: height, isIPhoneDuo: true, insets: insets,
147
+ insetLeft: insets.leading, insetRight: insets.trailing,
148
+ contentWidth: width - insets.leading - insets.trailing,
149
+ headerHeight: headerHeight, headerStatusBarHeight: headerStatusBarHeight, sideBar: sideBar
150
+ )
151
+ }
152
+
153
+ private static func headerGeometry(topInset: CGFloat, isPhone: Bool) -> (CGFloat, CGFloat) {
154
+ let height = topInset + HeaderMetrics.barHeight
155
+ guard isPhone else { return (height, topInset) }
156
+ return (
157
+ max(height, HeaderMetrics.iPhoneMinHeight),
158
+ topInset < HeaderMetrics.statusBarMinInset ? 0 : topInset
159
+ )
160
+ }
161
+
162
+ /// How far system regions reach into a side strip from its top and from its bottom end
163
+ /// (e.g. the camera, which sits at either end depending on the rotation).
164
+ private static func stripOcclusion(
165
+ _ regions: [CGRect],
166
+ stripLeft: CGFloat,
167
+ stripRight: CGFloat,
168
+ windowHeight: CGFloat
169
+ ) -> (top: CGFloat, bottom: CGFloat) {
170
+ regions.reduce(into: (top: CGFloat(0), bottom: CGFloat(0))) { occlusion, region in
171
+ guard region.minX < stripRight && region.maxX > stripLeft else { return }
172
+ if region.midY < windowHeight / 2 {
173
+ occlusion.top = max(occlusion.top, region.maxY)
174
+ } else {
175
+ occlusion.bottom = max(occlusion.bottom, windowHeight - region.minY)
176
+ }
177
+ }
178
+ }
179
+
180
+ // Slab iPhones report equal side insets in landscape; iPhone Duo moves the status bar to one side.
181
+ private static func isIPhoneDuoWindow(width: CGFloat, height: CGFloat, insets: EdgeInsets) -> Bool {
182
+ let shortSide = min(width, height)
183
+ let isSquare = shortSide > 0 && max(width, height) / shortSide < DuoMetrics.maxAspectRatio
184
+ return isSquare || insets.leading != insets.trailing
185
+ }
186
+
187
+ /// Below the side status bar: in portrait it is about twice as tall as its inset is wide.
188
+ public var duoTopInset: CGFloat {
189
+ max(insets.top, height > width ? max(insets.leading, insets.trailing) * 2 : 0)
190
+ }
191
+ }
192
+
193
+ /// Port of RN `device.ts` `isIPhoneDuo()`: UIScreen stays on the outer display, so this
194
+ /// still matches when Split View narrows the window. Used by popups and overlays.
195
+ public func isIPhoneDuoDevice(windowSize: CGSize? = nil) -> Bool {
196
+ guard isPhoneIdiom else { return false }
197
+ let screen = UIScreen.main.bounds.size
198
+ let window = windowSize ?? keyWindow?.bounds.size ?? screen
199
+ return max(min(screen.width, screen.height), min(window.width, window.height)) > DuoMetrics.iPhoneMaxShortSide
200
+ }
201
+
202
+ // MARK: - Reserved regions
203
+
204
+ /// System occlusion regions (iOS 27.1+) in window coordinates: the iPhone Duo camera, and the
205
+ /// status bar cluster as it grows for a call or a Live Activity. Empty before iOS 27.1.
206
+ enum ReservedRegions {
207
+ // last report of the probe, so `ScreenLayout.current` includes it
208
+ static var current: [CGRect] = []
209
+ }
210
+
211
+ /// Invisible probe: reading the regions in layoutSubviews makes UIKit run it again when they
212
+ /// change, so nothing polls. Port of RN `@momo-kits/reserved-regions`.
213
+ private final class ReservedRegionsProbe: UIView {
214
+ var onChange: (() -> Void)?
215
+ private var last: [CGRect]?
216
+
217
+ override init(frame: CGRect) {
218
+ super.init(frame: frame)
219
+ isUserInteractionEnabled = false
220
+ }
221
+
222
+ required init?(coder: NSCoder) { nil }
223
+
224
+ override func layoutSubviews() {
225
+ super.layoutSubviews()
226
+ let regions = occlusionRegions()
227
+ guard regions != last else { return }
228
+ last = regions
229
+ ReservedRegions.current = regions
230
+ // layout pass: publish on the next turn instead of mutating SwiftUI state mid-update
231
+ DispatchQueue.main.async { [weak self] in self?.onChange?() }
232
+ }
233
+
234
+ override func didMoveToWindow() {
235
+ super.didMoveToWindow()
236
+ setNeedsLayout()
237
+ }
238
+
239
+ // The API is newer than the SDKs hosts may build with, so it is reached through the runtime.
240
+ private func occlusionRegions() -> [CGRect] {
241
+ let regionsOfKind = NSSelectorFromString("reservedRegionsOfKind:")
242
+ guard window != nil, responds(to: regionsOfKind),
243
+ let kindType = NSClassFromString("UIViewReservedRegionKind") as? NSObject.Type,
244
+ let kind = (kindType as AnyObject).perform(NSSelectorFromString("occlusionRegionKind"))?.takeUnretainedValue(),
245
+ let regions = perform(regionsOfKind, with: kind)?.takeUnretainedValue() as? [NSObject]
246
+ else { return [] }
247
+ return regions.compactMap { ($0.value(forKey: "frame") as? NSValue)?.cgRectValue }
248
+ .map { convert($0, to: nil) }
249
+ }
250
+ }
251
+
252
+ private struct ReservedRegionsProbeView: UIViewRepresentable {
253
+ let onChange: () -> Void
254
+
255
+ func makeUIView(context: Context) -> ReservedRegionsProbe {
256
+ let probe = ReservedRegionsProbe()
257
+ probe.onChange = onChange
258
+ return probe
259
+ }
260
+
261
+ func updateUIView(_ probe: ReservedRegionsProbe, context: Context) {
262
+ probe.onChange = onChange
263
+ }
264
+ }
265
+
266
+ // MARK: - Environment
267
+
268
+ private struct ScreenLayoutKey: EnvironmentKey {
269
+ // Computed so an un-injected subtree (bare UIHostingController) still reads the live window.
270
+ static var defaultValue: ScreenLayout { .current }
271
+ }
272
+
273
+ public extension EnvironmentValues {
274
+ var screenLayout: ScreenLayout {
275
+ get { self[ScreenLayoutKey.self] }
276
+ set { self[ScreenLayoutKey.self] = newValue }
277
+ }
278
+ }
279
+
280
+ /// Re-reads the window whenever this view's size changes (rotation, fold/unfold, Split View).
281
+ private struct ScreenLayoutReader: ViewModifier {
282
+ @State private var layout: ScreenLayout = .current
283
+
284
+ func body(content: Content) -> some View {
285
+ content
286
+ .environment(\.screenLayout, layout)
287
+ .background(
288
+ GeometryReader { proxy in
289
+ Color.clear
290
+ .onAppear { refresh() }
291
+ .onChange(of: proxy.size) { _ in refresh() }
292
+ .onChange(of: proxy.safeAreaInsets) { _ in refresh() }
293
+ }
294
+ )
295
+ .background(ReservedRegionsProbeView { refresh() }.ignoresSafeArea())
296
+ .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
297
+ refresh()
298
+ }
299
+ }
300
+
301
+ private func refresh() {
302
+ let next = ScreenLayout.current
303
+ if next != layout { layout = next }
304
+ }
305
+ }
306
+
307
+ public extension View {
308
+ /// Provides `\.screenLayout`; NavigationContainer applies it, a bare UIHostingController root should too.
309
+ func momoScreenLayout() -> some View {
310
+ modifier(ScreenLayoutReader())
311
+ }
312
+ }
@@ -14,19 +14,61 @@ import SwiftUI
14
14
  public struct BottomTab: View {
15
15
  private let items: [BottomTabItem]
16
16
  private let floatingButton: BottomTabFloatingButton?
17
+ private let compressionBehavior: DuoBarCompressionBehavior
17
18
 
18
19
  @EnvironmentObject private var navigator: Navigator
20
+ @Environment(\.screenLayout) private var layout
19
21
  @State private var selectedIndex: Int = 0
22
+ @State private var toolbarHeight = DuoBarCompression.defaultToolbarHeight
23
+ @State private var expandedBar: DuoBar?
24
+ @State private var openedBar: DuoBar?
20
25
 
21
- public init(items: [BottomTabItem], floatingButton: BottomTabFloatingButton? = nil) {
26
+ /// `compressionBehavior`: on iPhone Duo, when the side bar can't hold both bars, which one
27
+ /// starts compressed; tapping the compressed one swaps them.
28
+ public init(
29
+ items: [BottomTabItem],
30
+ floatingButton: BottomTabFloatingButton? = nil,
31
+ compressionBehavior: DuoBarCompressionBehavior = .toolbar
32
+ ) {
22
33
  self.items = items
23
34
  self.floatingButton = floatingButton
35
+ self.compressionBehavior = compressionBehavior
24
36
  }
25
37
 
26
38
  private var bottomInset: CGFloat { safeAreaBottomInset() }
27
- private var reservedBottom: CGFloat { BOTTOM_TAB_BAR_HEIGHT + bottomInset + Spacing.S }
39
+ // the Duo bars overlay the content or sit in the side strip, so only the home indicator is kept clear
40
+ private var reservedBottom: CGFloat {
41
+ layout.isIPhoneDuo ? layout.insets.bottom : BOTTOM_TAB_BAR_HEIGHT + bottomInset + Spacing.S
42
+ }
43
+
44
+ private var compression: DuoBarCompression {
45
+ DuoBarCompression.resolve(
46
+ layout: layout,
47
+ tabCount: items.count,
48
+ hasQr: floatingButton != nil,
49
+ toolbarHeight: toolbarHeight,
50
+ // 'toolbar' compression keeps the tab bar whole until the toolbar is tapped
51
+ expanded: Binding(
52
+ get: { expandedBar ?? (compressionBehavior == .toolbar ? .tabBar : .toolbar) },
53
+ set: { expandedBar = $0 }
54
+ ),
55
+ opened: $openedBar,
56
+ setToolbarHeight: { height in
57
+ if toolbarHeight != height { toolbarHeight = height }
58
+ }
59
+ )
60
+ }
61
+
62
+ private func duoInsets(_ compression: DuoBarCompression) -> DuoTabBarInsets {
63
+ layout.duoTabBarInsets(
64
+ itemCount: items.count,
65
+ hasFloatingButton: floatingButton != nil,
66
+ tabBarCompressed: compression.tabBarCompressed
67
+ )
68
+ }
28
69
 
29
70
  public var body: some View {
71
+ let compression = compression
30
72
  ZStack(alignment: .bottom) {
31
73
  // Tab content area, inset above the bar.
32
74
  ZStack {
@@ -38,20 +80,31 @@ public struct BottomTab: View {
38
80
  }
39
81
  }
40
82
  .padding(.bottom, reservedBottom)
83
+ .environment(\.duoTabBarInsets, duoInsets(compression))
84
+ .environment(\.duoBarCompression, compression)
41
85
  .animation(.easeInOut(duration: 0.2), value: selectedIndex)
42
86
 
43
- // Tab bar + home-indicator filler.
44
- VStack(spacing: 0) {
45
- BottomTabBar(
87
+ if layout.isIPhoneDuo {
88
+ DuoTabBar(
46
89
  items: items,
47
90
  floatingButton: floatingButton,
48
91
  selectedIndex: selectedIndex,
49
- onTabSelected: { index in
50
- if index != selectedIndex { selectedIndex = index }
51
- }
92
+ onTabSelected: selectTab,
93
+ compression: compression
52
94
  )
53
- Colors.black01
54
- .frame(height: bottomInset + Spacing.S)
95
+ } else {
96
+ // Tab bar + home-indicator filler.
97
+ VStack(spacing: 0) {
98
+ BottomTabBar(
99
+ items: items,
100
+ floatingButton: floatingButton,
101
+ selectedIndex: selectedIndex,
102
+ onTabSelected: selectTab
103
+ )
104
+ Colors.black01
105
+ .frame(height: bottomInset + Spacing.S)
106
+ .ignoresSafeArea(edges: .horizontal)
107
+ }
55
108
  }
56
109
  }
57
110
  .frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -59,6 +112,10 @@ public struct BottomTab: View {
59
112
  .preference(key: IsBottomTabRootKey.self, value: true)
60
113
  }
61
114
 
115
+ private func selectTab(_ index: Int) {
116
+ if index != selectedIndex { selectedIndex = index }
117
+ }
118
+
62
119
  @ViewBuilder
63
120
  private func tabScreen(for index: Int) -> some View {
64
121
  let item = items[index]
@@ -65,7 +65,10 @@ struct BottomTabBar: View {
65
65
  let selectedIndex: Int
66
66
  let onTabSelected: (Int) -> Void
67
67
 
68
- private var screenWidth: CGFloat { UIScreen.main.bounds.width }
68
+ @Environment(\.screenLayout) private var layout
69
+
70
+ // the bar sits between the side safe areas
71
+ private var screenWidth: CGFloat { layout.width - layout.insets.leading - layout.insets.trailing }
69
72
  private var mid: Int { items.count / 2 }
70
73
 
71
74
  private var tabWidth: CGFloat {
@@ -95,7 +98,7 @@ struct BottomTabBar: View {
95
98
  }
96
99
  .frame(maxWidth: .infinity)
97
100
  .frame(height: BOTTOM_TAB_BAR_HEIGHT)
98
- .background(Colors.black01)
101
+ .background(Colors.black01.ignoresSafeArea(edges: .horizontal))
99
102
 
100
103
  // Animated selection indicator.
101
104
  RoundedRectangle(cornerRadius: 2)