@momo-kits/native-kits 0.167.1-duo.2-debug → 0.167.1-duo.3

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 (171) hide show
  1. package/.claude/scheduled_tasks.lock +1 -0
  2. package/.claude/settings.local.json +185 -0
  3. package/CLAUDE.md +8 -3
  4. package/ios/Application/HeaderRight.swift +25 -12
  5. package/ios/Application/Layout/ScreenLayout.swift +196 -0
  6. package/ios/Application/Navigation/BottomTab/BottomTab.swift +30 -9
  7. package/ios/Application/Navigation/BottomTab/BottomTabBar.swift +5 -2
  8. package/ios/Application/Navigation/BottomTab/DuoTabBar.swift +275 -0
  9. package/ios/Application/Navigation/HeaderUser.swift +4 -1
  10. package/ios/Application/Navigation/NavigationContainer.swift +3 -0
  11. package/ios/Application/Navigation/Overplay/BottomSheet.swift +41 -4
  12. package/ios/Application/Navigation/Overplay/ModalScreen.swift +5 -1
  13. package/ios/Application/Navigation/component/DuoScrollGradient.swift +55 -0
  14. package/ios/Application/Navigation/component/Header.swift +9 -3
  15. package/ios/Application/Navigation/component/HeaderBackground.swift +3 -2
  16. package/ios/Application/Navigation/component/NavigationFooter.swift +1 -1
  17. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +61 -9
  18. package/ios/Application/Navigation/component/SideBar.swift +155 -0
  19. package/ios/Application/StackScreen.swift +135 -18
  20. package/ios/Popup/PopupDisplay.swift +84 -68
  21. package/ios/Popup/PopupLayout.swift +45 -0
  22. package/ios/Popup/PopupNotify.swift +43 -32
  23. package/ios/Popup/PopupPromotion.swift +17 -5
  24. package/ios/native-kits.podspec +1 -1
  25. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  26. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +76 -0
  27. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +1 -0
  28. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +170 -0
  29. package/package.json +1 -1
  30. package/build.gradle.kts +0 -11
  31. package/compose/build.gradle.kts +0 -190
  32. package/compose/build.gradle.kts.backup +0 -190
  33. package/compose/compose.podspec +0 -47
  34. package/compose/src/androidMain/kotlin/vn/momo/kits/const/HostFont.android.kt +0 -19
  35. package/compose/src/androidMain/kotlin/vn/momo/kits/navigation/ScrollToTop.android.kt +0 -6
  36. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/BackGesture.android.kt +0 -12
  37. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/OsFontScale.android.kt +0 -7
  38. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +0 -151
  39. package/compose/src/commonMain/kotlin/vn/momo/kits/application/AnimationSearchInput.kt +0 -52
  40. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +0 -124
  41. package/compose/src/commonMain/kotlin/vn/momo/kits/application/FloatingButton.kt +0 -183
  42. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Header.kt +0 -207
  43. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderAnimated.kt +0 -39
  44. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderBackground.kt +0 -86
  45. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderDefault.kt +0 -76
  46. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderExtended.kt +0 -76
  47. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderRight.kt +0 -293
  48. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderTitle.kt +0 -33
  49. package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +0 -831
  50. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Localize.kt +0 -269
  51. package/compose/src/commonMain/kotlin/vn/momo/kits/application/NavigationContainer.kt +0 -110
  52. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Screen.kt +0 -378
  53. package/compose/src/commonMain/kotlin/vn/momo/kits/application/WidgetContainer.kt +0 -56
  54. package/compose/src/commonMain/kotlin/vn/momo/kits/application/useHeaderSearchAnimation.kt +0 -69
  55. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Avatar.kt +0 -157
  56. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +0 -82
  57. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeDot.kt +0 -32
  58. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeRibbon.kt +0 -338
  59. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BaselineView.kt +0 -175
  60. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +0 -356
  61. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +0 -113
  62. package/compose/src/commonMain/kotlin/vn/momo/kits/components/CheckBox.kt +0 -89
  63. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +0 -128
  64. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Collapse.kt +0 -209
  65. package/compose/src/commonMain/kotlin/vn/momo/kits/components/CupertinoOverscroll.kt +0 -542
  66. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +0 -49
  67. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +0 -76
  68. package/compose/src/commonMain/kotlin/vn/momo/kits/components/IconButton.kt +0 -125
  69. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Image.kt +0 -231
  70. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +0 -189
  71. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +0 -475
  72. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputDropDown.kt +0 -114
  73. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +0 -266
  74. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +0 -240
  75. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +0 -223
  76. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +0 -264
  77. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +0 -185
  78. package/compose/src/commonMain/kotlin/vn/momo/kits/components/LazyColumnWithBouncing.kt +0 -343
  79. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Loader.kt +0 -108
  80. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationDot.kt +0 -56
  81. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationNumber.kt +0 -41
  82. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationScroll.kt +0 -84
  83. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationWhiteDot.kt +0 -40
  84. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +0 -334
  85. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +0 -98
  86. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +0 -310
  87. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +0 -59
  88. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Rating.kt +0 -87
  89. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ScaleSizeScope.kt +0 -21
  90. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Skeleton.kt +0 -90
  91. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +0 -323
  92. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Stepper.kt +0 -220
  93. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Steps.kt +0 -473
  94. package/compose/src/commonMain/kotlin/vn/momo/kits/components/SuggestAction.kt +0 -122
  95. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +0 -199
  96. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +0 -91
  97. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +0 -552
  98. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +0 -88
  99. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +0 -125
  100. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Title.kt +0 -207
  101. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +0 -498
  102. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TrustBanner.kt +0 -176
  103. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Uploader.kt +0 -182
  104. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePicker.kt +0 -193
  105. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerTypes.kt +0 -29
  106. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +0 -234
  107. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +0 -188
  108. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Colors.kt +0 -325
  109. package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +0 -16
  110. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitDesignSystem.kt +0 -18
  111. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitFontScale.kt +0 -22
  112. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Radius.kt +0 -12
  113. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Spacing.kt +0 -16
  114. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Theme.kt +0 -188
  115. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +0 -297
  116. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Card.kt +0 -54
  117. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +0 -50
  118. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +0 -157
  119. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Section.kt +0 -41
  120. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/AutomationId.kt +0 -52
  121. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Clickable.kt +0 -66
  122. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Conditional.kt +0 -11
  123. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/DeprecatedModifier.kt +0 -104
  124. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Shadow.kt +0 -48
  125. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Size.kt +0 -51
  126. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +0 -299
  127. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/HostBackGestureBridge.kt +0 -58
  128. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +0 -129
  129. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +0 -106
  130. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +0 -205
  131. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +0 -393
  132. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ScrollToTop.kt +0 -8
  133. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +0 -622
  134. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackTransition.kt +0 -148
  135. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/SwipeBackHaptic.kt +0 -35
  136. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +0 -175
  137. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +0 -232
  138. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/CurvedContainer.kt +0 -86
  139. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/FloatingButton.kt +0 -169
  140. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +0 -381
  141. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +0 -83
  142. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +0 -105
  143. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +0 -74
  144. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +0 -347
  145. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +0 -37
  146. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +0 -363
  147. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +0 -169
  148. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +0 -132
  149. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/tracking/ScreenTracker.kt +0 -127
  150. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/BackGesture.kt +0 -12
  151. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ComposeLottieAnimation.kt +0 -91
  152. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/OsFontScale.kt +0 -9
  153. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +0 -71
  154. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Icons.kt +0 -1329
  155. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Resources.kt +0 -55
  156. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Tracking.kt +0 -15
  157. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +0 -118
  158. package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +0 -40
  159. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/HostBackGesture.ios.kt +0 -45
  160. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/ScrollToTop.ios.kt +0 -32
  161. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/BackGesture.ios.kt +0 -38
  162. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/OsFontScale.ios.kt +0 -66
  163. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +0 -213
  164. package/gradle/libs.versions.toml +0 -69
  165. package/gradle/wrapper/gradle-wrapper.jar +0 -0
  166. package/gradle/wrapper/gradle-wrapper.properties +0 -8
  167. package/gradle.properties +0 -26
  168. package/gradlew +0 -252
  169. package/gradlew.bat +0 -94
  170. package/ios-demo/README.md +0 -120
  171. package/settings.gradle.kts +0 -64
@@ -0,0 +1,275 @@
1
+ //
2
+ // DuoTabBar.swift
3
+ // MoMoUIKits
4
+ //
5
+ // iPhone Duo tab bar, port of RN `BottomTab/DuoTabBar.tsx` (Figma Fold-Design-System MT1/MT2):
6
+ // a glass rail in the side bar, or a floating glass bar when unfolded portrait.
7
+ //
8
+
9
+ import SwiftUI
10
+
11
+ private let itemSize = DuoMetrics.controlSize
12
+ private let iconSize: CGFloat = 28
13
+ private let qrIconSize: CGFloat = 24
14
+ private let railPadding: CGFloat = 6
15
+ private let railGap = Spacing.XXS
16
+ // selection bubble overflows the 48 rail cell by 5 above and below (Figma MT2)
17
+ private let railBubbleWidth: CGFloat = 44
18
+ private let railBubbleHeight: CGFloat = 58
19
+ let duoFloatingTabBarHeight: CGFloat = 62
20
+ private let floatingPadding = Spacing.XS
21
+ private let floatingQrSize: CGFloat = 44
22
+ // floating bar spans 6 of the 8 portrait grid columns, like the content
23
+ private let gridColumns: CGFloat = 8
24
+ private let gridSpan: CGFloat = 6
25
+ private let tabSpring = Animation.interpolatingSpring(mass: 0.8, stiffness: 220, damping: 18)
26
+
27
+ /// Room tab screens leave for the Duo tab bar, provided by BottomTab to its tab StackScreens.
28
+ struct DuoTabBarInsets: Equatable {
29
+ /// Floating bar overlays the content: scroll content, footer and FAB keep clear of it.
30
+ var overlay: CGFloat = 0
31
+ /// Rail sits at the bottom of the side strip: the header side bar stays above it.
32
+ var sideBarReserve: CGFloat = 0
33
+ }
34
+
35
+ private struct DuoTabBarInsetsKey: EnvironmentKey {
36
+ static let defaultValue = DuoTabBarInsets()
37
+ }
38
+
39
+ extension EnvironmentValues {
40
+ var duoTabBarInsets: DuoTabBarInsets {
41
+ get { self[DuoTabBarInsetsKey.self] }
42
+ set { self[DuoTabBarInsetsKey.self] = newValue }
43
+ }
44
+ }
45
+
46
+ extension ScreenLayout {
47
+ func duoTabBarInsets(itemCount: Int, hasFloatingButton: Bool) -> DuoTabBarInsets {
48
+ guard isIPhoneDuo else { return DuoTabBarInsets() }
49
+ guard sideBar != nil else {
50
+ return DuoTabBarInsets(overlay: duoFloatingTabBarHeight + Spacing.M)
51
+ }
52
+ let count = CGFloat(itemCount)
53
+ let rail = railPadding * 2 + count * itemSize + max(count - 1, 0) * railGap
54
+ let qr = hasFloatingButton ? Spacing.M + itemSize : 0
55
+ return DuoTabBarInsets(sideBarReserve: rail + qr + Spacing.XL)
56
+ }
57
+ }
58
+
59
+ /// Glass on iOS 26, a system blur before it (RN GlassView's fallback).
60
+ struct DuoBarMaterial<S: Shape>: ViewModifier {
61
+ let shape: S
62
+ var visible: Bool = true
63
+
64
+ func body(content: Content) -> some View {
65
+ content.background(material.allowsHitTesting(false))
66
+ }
67
+
68
+ @ViewBuilder
69
+ private var material: some View {
70
+ #if compiler(>=6.2)
71
+ if #available(iOS 26.0, *) {
72
+ // glass materializes by toggling its effect, it does not fade with opacity
73
+ Color.clear
74
+ .glassEffect(visible ? .regular : .identity, in: shape)
75
+ .shadow(color: Color.black.opacity(visible ? 0.16 : 0), radius: 16, x: 0, y: 6)
76
+ .animation(.easeInOut(duration: 0.25), value: visible)
77
+ } else {
78
+ blur
79
+ }
80
+ #else
81
+ blur
82
+ #endif
83
+ }
84
+
85
+ private var blur: some View {
86
+ shape.fill(.regularMaterial)
87
+ .shadow(color: Color.black.opacity(0.16), radius: 16, x: 0, y: 6)
88
+ .opacity(visible ? 1 : 0)
89
+ .animation(.easeInOut(duration: 0.25), value: visible)
90
+ }
91
+ }
92
+
93
+ struct DuoTabBar: View {
94
+ let items: [BottomTabItem]
95
+ let floatingButton: BottomTabFloatingButton?
96
+ let selectedIndex: Int
97
+ let onTabSelected: (Int) -> Void
98
+
99
+ @Environment(\.screenLayout) private var layout
100
+
101
+ var body: some View {
102
+ if let sideBar = layout.sideBar {
103
+ DuoRailTabBar(sideBar: sideBar, items: items, floatingButton: floatingButton,
104
+ selectedIndex: selectedIndex, onTabSelected: onTabSelected)
105
+ } else {
106
+ DuoFloatingTabBar(items: items, floatingButton: floatingButton,
107
+ selectedIndex: selectedIndex, onTabSelected: onTabSelected)
108
+ }
109
+ }
110
+ }
111
+
112
+ /// Vertical bar anchored to the bottom of the side bar, QR button below it (Figma MT1-C/E).
113
+ private struct DuoRailTabBar: View {
114
+ let sideBar: SideBarLayout
115
+ let items: [BottomTabItem]
116
+ let floatingButton: BottomTabFloatingButton?
117
+ let selectedIndex: Int
118
+ let onTabSelected: (Int) -> Void
119
+
120
+ @Environment(\.appTheme) private var theme
121
+
122
+ private var isRight: Bool { sideBar.edge == .right }
123
+
124
+ var body: some View {
125
+ VStack(spacing: Spacing.M) {
126
+ ZStack(alignment: .top) {
127
+ Capsule()
128
+ .fill(theme.colors.background.selected)
129
+ .frame(width: railBubbleWidth, height: railBubbleHeight)
130
+ .offset(y: railPadding - (railBubbleHeight - itemSize) / 2
131
+ + CGFloat(selectedIndex) * (itemSize + railGap))
132
+ .animation(tabSpring, value: selectedIndex)
133
+
134
+ VStack(spacing: railGap) {
135
+ ForEach(items.indices, id: \.self) { index in
136
+ SwiftUI.Button { onTabSelected(index) } label: {
137
+ DuoTabIcon(item: items[index], selected: index == selectedIndex)
138
+ .frame(width: itemSize, height: itemSize)
139
+ .contentShape(Rectangle())
140
+ }
141
+ .buttonStyle(.plain)
142
+ .accessibilityLabel(items[index].label)
143
+ }
144
+ }
145
+ .padding(.vertical, railPadding)
146
+ }
147
+ .frame(width: itemSize)
148
+ .modifier(DuoBarMaterial(shape: RoundedRectangle(cornerRadius: Radius.XL)))
149
+ .accessibilityIdentifier("bottom_tab_bar")
150
+
151
+ if let floatingButton {
152
+ DuoQrButton(data: floatingButton, size: itemSize)
153
+ }
154
+ }
155
+ .padding(isRight ? .leading : .trailing, Spacing.M)
156
+ .frame(width: sideBar.width, alignment: isRight ? .leading : .trailing)
157
+ .padding(.bottom, Spacing.XL)
158
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: isRight ? .bottomTrailing : .bottomLeading)
159
+ .ignoresSafeArea()
160
+ }
161
+ }
162
+
163
+ /// Floating labelled bar for the unfolded portrait display, QR in the middle cell (Figma MT1-M).
164
+ private struct DuoFloatingTabBar: View {
165
+ let items: [BottomTabItem]
166
+ let floatingButton: BottomTabFloatingButton?
167
+ let selectedIndex: Int
168
+ let onTabSelected: (Int) -> Void
169
+
170
+ @Environment(\.screenLayout) private var layout
171
+ @Environment(\.appTheme) private var theme
172
+
173
+ private var barWidth: CGFloat {
174
+ let columnWidth = (layout.width - 2 * Spacing.M - (gridColumns - 1) * Spacing.M) / gridColumns
175
+ return columnWidth * gridSpan + (gridSpan - 1) * Spacing.M
176
+ }
177
+
178
+ private var useQr: Bool { floatingButton != nil && items.count % 2 == 0 }
179
+ private var middle: Int { items.count / 2 }
180
+ private var cellCount: Int { items.count + (useQr ? 1 : 0) }
181
+ private var cellWidth: CGFloat { (barWidth - floatingPadding * 2) / CGFloat(max(cellCount, 1)) }
182
+ private var visualIndex: Int { useQr && selectedIndex >= middle ? selectedIndex + 1 : selectedIndex }
183
+
184
+ var body: some View {
185
+ ZStack(alignment: .leading) {
186
+ Capsule()
187
+ .fill(theme.colors.background.selected)
188
+ .frame(width: cellWidth)
189
+ .offset(x: CGFloat(visualIndex) * cellWidth)
190
+ .animation(tabSpring, value: visualIndex)
191
+
192
+ HStack(spacing: 0) {
193
+ ForEach(0..<cellCount, id: \.self) { cell in
194
+ if useQr && cell == middle, let floatingButton {
195
+ DuoQrButton(data: floatingButton, size: floatingQrSize)
196
+ .frame(width: cellWidth)
197
+ } else {
198
+ let index = useQr && cell > middle ? cell - 1 : cell
199
+ tabCell(index)
200
+ }
201
+ }
202
+ }
203
+ }
204
+ .padding(floatingPadding)
205
+ .frame(width: barWidth, height: duoFloatingTabBarHeight)
206
+ .modifier(DuoBarMaterial(shape: Capsule()))
207
+ .accessibilityIdentifier("bottom_tab_bar")
208
+ .padding(.bottom, layout.insets.bottom + Spacing.M)
209
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
210
+ .ignoresSafeArea()
211
+ }
212
+
213
+ private func tabCell(_ index: Int) -> some View {
214
+ let selected = index == selectedIndex
215
+ return SwiftUI.Button { onTabSelected(index) } label: {
216
+ VStack(spacing: Spacing.XXS) {
217
+ DuoTabIcon(item: items[index], selected: selected)
218
+ MomoText(
219
+ items[index].label,
220
+ typography: .labelXsMedium,
221
+ color: selected ? theme.colors.primary : theme.colors.text.default,
222
+ textAlign: .center,
223
+ maxLines: 1
224
+ )
225
+ }
226
+ .frame(width: cellWidth)
227
+ .frame(maxHeight: .infinity)
228
+ .contentShape(Rectangle())
229
+ }
230
+ .buttonStyle(.plain)
231
+ .accessibilityLabel(items[index].label)
232
+ }
233
+ }
234
+
235
+ private struct DuoTabIcon: View {
236
+ let item: BottomTabItem
237
+ let selected: Bool
238
+
239
+ @Environment(\.appTheme) private var theme
240
+
241
+ private var badgeLabel: String? {
242
+ guard let label = item.badgeLabel else { return nil }
243
+ if let n = Int(label), n == 0 { return nil }
244
+ return label
245
+ }
246
+
247
+ var body: some View {
248
+ Icon(source: item.icon, size: iconSize, color: selected ? theme.colors.primary : theme.colors.text.default)
249
+ .overlay(alignment: .topTrailing) {
250
+ if let badgeLabel, !badgeLabel.isEmpty {
251
+ Badge(label: badgeLabel).offset(x: Spacing.S, y: -Spacing.XS)
252
+ } else if badgeLabel != nil || item.showDot {
253
+ BadgeDot(size: .small)
254
+ }
255
+ }
256
+ }
257
+ }
258
+
259
+ private struct DuoQrButton: View {
260
+ let data: BottomTabFloatingButton
261
+ let size: CGFloat
262
+
263
+ @Environment(\.appTheme) private var theme
264
+
265
+ var body: some View {
266
+ SwiftUI.Button(action: data.onPress) {
267
+ Circle()
268
+ .fill(theme.colors.primary)
269
+ .frame(width: size, height: size)
270
+ .overlay(Icon(source: data.icon, size: qrIconSize, color: Colors.black01))
271
+ }
272
+ .buttonStyle(.plain)
273
+ .accessibilityLabel(data.label)
274
+ }
275
+ }
@@ -20,8 +20,11 @@ public struct HeaderUserView: View {
20
20
 
21
21
  private let avatarSize: CGFloat = Spacing.XXL // 32
22
22
 
23
+ @Environment(\.screenLayout) private var layout
24
+
25
+ // back button and toolkit share the bar unless the iPhone Duo side bar holds them
23
26
  private var maxWidth: CGFloat {
24
- UIScreen.main.bounds.width - 172
27
+ layout.sideBar != nil ? layout.contentWidth - Spacing.M * 2 : layout.width - 172
25
28
  }
26
29
 
27
30
  public var body: some View {
@@ -88,6 +88,7 @@ public struct NavigationContainer<Initial: View>: View {
88
88
  .momoLocalize(localize)
89
89
  .momoFontScale(context: applicationEnvironment.applicationContext)
90
90
  // AI-GENERATED END: route Localize + font-scale injection through the observing modifiers
91
+ .momoScreenLayout()
91
92
  }
92
93
  }
93
94
  .onAppear {
@@ -167,6 +168,8 @@ private struct ScreenHost: View {
167
168
  screenName: screen.name,
168
169
  content: { screen.content() }
169
170
  )
171
+ // Per screen, not around the NavigationStack: a state-driven reader there stops path pushes rendering.
172
+ .momoScreenLayout()
170
173
  }
171
174
  }
172
175
 
@@ -45,6 +45,7 @@ struct BottomSheetView<Content: View>: View {
45
45
  @EnvironmentObject private var navigator: Navigator
46
46
  @Environment(\.appTheme) private var theme
47
47
  @Environment(\.applicationEnvironment) private var appEnv
48
+ @Environment(\.screenLayout) private var layout
48
49
 
49
50
  /// Vertical translation of the sheet; starts off-screen and animates to 0.
50
51
  @State private var sheetOffset: CGFloat = UIScreen.main.bounds.height
@@ -66,7 +67,36 @@ struct BottomSheetView<Content: View>: View {
66
67
  /// steady-state (fully open) scrim peaks at 0.6, matching ModalScreen's peak.
67
68
  private let maxDynamicAlpha: CGFloat = 0.3
68
69
 
69
- private var screenHeight: CGFloat { UIScreen.main.bounds.height }
70
+ // UIScreen stays on the outer display on iPhone Duo, even when unfolded
71
+ private var screenHeight: CGFloat { isDuo ? layout.height : UIScreen.main.bounds.height }
72
+
73
+ private var isDuo: Bool { layout.isDuoDevice }
74
+ // iPhone Duo inner display: center the sheet instead of spanning the width
75
+ private var isDuoCentered: Bool { isDuo && layout.width >= 600 }
76
+
77
+ /// Keeps the portrait width in landscape, like system sheets.
78
+ private var duoCenteredWidth: CGFloat {
79
+ min(
80
+ min(layout.width, layout.height) - Spacing.L * 2,
81
+ layout.width - max(layout.insets.leading, layout.insets.trailing, Spacing.L) * 2
82
+ )
83
+ }
84
+
85
+ /// Landscape side buttons span the full height, so the content is inset; in portrait the
86
+ /// side status bar only covers the top corner.
87
+ private var sideInsetLeading: CGFloat {
88
+ isDuo && !isDuoCentered && layout.width > layout.height ? layout.insets.leading : 0
89
+ }
90
+
91
+ private var sideInsetTrailing: CGFloat {
92
+ isDuo && !isDuoCentered && layout.width > layout.height ? layout.insets.trailing : 0
93
+ }
94
+
95
+ /// The keyboard filler is part of the sheet, so capping the sheet shrinks the content by it.
96
+ private var duoMaxHeight: CGFloat? {
97
+ guard isDuo else { return nil }
98
+ return layout.height - max(layout.duoTopInset, Spacing.XL)
99
+ }
70
100
  private var bottomInset: CGFloat { safeAreaBottomInset() }
71
101
  /// While the keyboard is up, it already covers the home-indicator area, so it
72
102
  /// replaces (rather than adds to) the usual bottom-inset filler.
@@ -110,7 +140,7 @@ struct BottomSheetView<Content: View>: View {
110
140
  .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in
111
141
  guard let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
112
142
  withAnimation(.easeOut(duration: 0.25)) {
113
- keyboardHeight = UIScreen.main.bounds.height - endFrame.origin.y
143
+ keyboardHeight = screenHeight - endFrame.origin.y
114
144
  }
115
145
  }
116
146
  .onAppear {
@@ -138,6 +168,8 @@ struct BottomSheetView<Content: View>: View {
138
168
  headerView
139
169
  .frame(height: 56)
140
170
  .padding(.horizontal, Spacing.M)
171
+ .padding(.leading, sideInsetLeading)
172
+ .padding(.trailing, sideInsetTrailing)
141
173
  }
142
174
  .frame(maxWidth: .infinity)
143
175
  .frame(height: 72)
@@ -149,12 +181,17 @@ struct BottomSheetView<Content: View>: View {
149
181
  .frame(height: 1)
150
182
 
151
183
  content()
184
+ .padding(.leading, sideInsetLeading)
185
+ .padding(.trailing, sideInsetTrailing)
186
+ // a tall sheet on iPhone Duo keeps its header and gives the content what is left
187
+ .layoutPriority(-1)
152
188
 
153
189
  backgroundColor
154
190
  .frame(height: effectiveBottomInset)
155
191
  }
156
- .frame(maxWidth: .infinity)
157
- .frame(alignment: .bottom)
192
+ .frame(maxWidth: isDuoCentered ? duoCenteredWidth : .infinity)
193
+ .frame(maxHeight: duoMaxHeight, alignment: .bottom)
194
+ .clipped()
158
195
  .background(
159
196
  GeometryReader { geo in
160
197
  Color.clear.onAppear { sheetHeight = geo.size.height }
@@ -16,6 +16,7 @@ struct ModalScreen<Content: View>: View {
16
16
 
17
17
  @EnvironmentObject private var navigator: Navigator
18
18
  @Environment(\.applicationEnvironment) private var appEnv
19
+ @Environment(\.screenLayout) private var layout
19
20
 
20
21
  @State private var scrimOpacity: CGFloat = 0
21
22
  @State private var contentScale: CGFloat = 0.8
@@ -35,8 +36,11 @@ struct ModalScreen<Content: View>: View {
35
36
  content()
36
37
  .scaleEffect(contentScale)
37
38
  .opacity(contentOpacity)
39
+ // iPhone Duo: center below the side status bar and above the home indicator
40
+ .padding(.top, layout.isDuoDevice ? layout.duoTopInset : 0)
41
+ .padding(.bottom, layout.isDuoDevice ? layout.insets.bottom : 0)
38
42
  }
39
- // Center on the full screen like Compose; landscape safe areas are asymmetric (camera on one side).
43
+ // Full screen like RN's Modal, so asymmetric landscape insets don't shift the content sideways.
40
44
  .frame(maxWidth: .infinity, maxHeight: .infinity)
41
45
  .ignoresSafeArea(.container)
42
46
  .onAppear {
@@ -0,0 +1,55 @@
1
+ //
2
+ // DuoScrollGradient.swift
3
+ // MoMoUIKits
4
+ //
5
+ // Port of RN `DuoScrollGradient` (Components/DuoGlassBackground.tsx): with the iPhone Duo
6
+ // glass header the content scrolls under the bar, so the header gradient is drawn under it.
7
+ //
8
+
9
+ import SwiftUI
10
+
11
+ // extended headers run their gradient this far below the header, as off Duo
12
+ private let extendedGradientExtra: CGFloat = 52
13
+ // scroll distance over which the extended gradient collapses to the header
14
+ private let extendedCollapseDistance: CGFloat = 100
15
+
16
+ struct DuoScrollGradient: View {
17
+ let scrollOffset: CGFloat
18
+ let headerHeight: CGFloat
19
+ let extended: Bool
20
+
21
+ @Environment(\.appTheme) private var theme
22
+ @Environment(\.screenLayout) private var layout
23
+
24
+ private var scroll: CGFloat { max(scrollOffset, 0) }
25
+
26
+ private var gradient: LinearGradient {
27
+ LinearGradient(
28
+ colors: [theme.colors.gradient, theme.colors.gradient.opacity(0)],
29
+ startPoint: .top,
30
+ endPoint: .bottom
31
+ )
32
+ }
33
+
34
+ var body: some View {
35
+ Group {
36
+ if extended {
37
+ // extended squeezes the whole fade into its box and fades out
38
+ let progress = min(scroll / extendedCollapseDistance, 1)
39
+ gradient
40
+ .frame(height: headerHeight + extendedGradientExtra * (1 - progress))
41
+ .opacity(1 - min(scroll / extendedGradientExtra, 1))
42
+ } else {
43
+ // default keeps the off-Duo 375:154 shape, the header clips it, and scrolls away
44
+ gradient
45
+ .frame(height: layout.width * 154 / 375)
46
+ .frame(height: headerHeight, alignment: .top)
47
+ .clipped()
48
+ .offset(y: -scroll)
49
+ }
50
+ }
51
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
52
+ .ignoresSafeArea()
53
+ .allowsHitTesting(false)
54
+ }
55
+ }
@@ -60,7 +60,11 @@ public struct NavigationHeader: View {
60
60
  self.titleOpacity = titleOpacity
61
61
  }
62
62
 
63
+ @Environment(\.screenLayout) private var layout
64
+
63
65
  private var isAnimated: Bool { animatedHeader != nil }
66
+ /// iPhone Duo side bar holds back and the toolkit; a custom header right stays in the bar.
67
+ private var useSideBar: Bool { layout.sideBar != nil }
64
68
  private var isSurface: Bool { animatedHeader?.isSurface ?? true }
65
69
 
66
70
  private var headerColor: HeaderColor {
@@ -79,7 +83,7 @@ public struct NavigationHeader: View {
79
83
  if headerType != .none {
80
84
  ZStack {
81
85
  HStack(spacing: 0) {
82
- if let goBack {
86
+ if let goBack, !useSideBar {
83
87
  BackButton(color: headerColor, onBack: goBack)
84
88
  Spacer().frame(width: Spacing.S)
85
89
  }
@@ -91,7 +95,7 @@ public struct NavigationHeader: View {
91
95
  .frame(maxWidth: .infinity, alignment: .leading)
92
96
  }
93
97
 
94
- if let headerRight {
98
+ if let headerRight, !(useSideBar && toolkitHeaderRight(headerRight) != nil) {
95
99
  AnyView(headerRight())
96
100
  .background(
97
101
  GeometryReader { geo in
@@ -107,8 +111,10 @@ public struct NavigationHeader: View {
107
111
  .padding(.horizontal, Spacing.M)
108
112
  }
109
113
  }
110
- .frame(height: HEADER_HEIGHT)
114
+ .frame(height: layout.headerBarHeight)
111
115
  .overlay(alignment: .bottom) { VerticalShadow(opacity: opacity) }
116
+ // status bar off the top edge: the row spans the whole header from the window top
117
+ .padding(.top, min(layout.headerStatusBarHeight - layout.insets.top, 0))
112
118
  }
113
119
  }
114
120
 
@@ -43,10 +43,11 @@ public struct NavigationHeaderBackground: View {
43
43
  )
44
44
  }
45
45
 
46
+ @Environment(\.screenLayout) private var layout
47
+
46
48
  public var body: some View {
47
- let statusBarHeight = safeAreaTopInset()
48
49
  let opacity = max(0, 1 - scrollState / CGFloat(HEADER_HEIGHT))
49
- let minHeight = fullScreenContent ? 0 : statusBarHeight + HEADER_HEIGHT
50
+ let minHeight = fullScreenContent ? 0 : layout.headerHeight
50
51
  let maxHeight: CGFloat = 154
51
52
  let backgroundColor = headerTransparent ? Color.clear : Colors.black01
52
53
  let topColor = opacity == 0 ? backgroundColor : Color(hex: 0xFDCADE)
@@ -69,7 +69,7 @@ public struct NavigationFooter: View {
69
69
  .padding(.bottom, Self.bottomPadding)
70
70
  // Compose paints the surface all the way to the bottom of the screen; the
71
71
  // footer's own frame stops at the safe area, so only the fill bleeds down.
72
- .background(theme.colors.background.surface.ignoresSafeArea(.container, edges: .bottom))
72
+ .background(theme.colors.background.surface.ignoresSafeArea(.container, edges: [.bottom, .horizontal]))
73
73
  .overlay(alignment: .top) { shadow }
74
74
  .offset(y: Self.verticalOffset(keyboardSize: keyboardSize))
75
75
  }
@@ -26,6 +26,8 @@ public struct NavigationHeaderRight: View {
26
26
  struct ToolkitNavigationHeaderRight: View {
27
27
  var headerRight: HeaderRightData?
28
28
  var tintColor: Color?
29
+ /// iPhone Duo side bar column instead of the bar row.
30
+ var vertical: Bool = false
29
31
  @State private var isFavorite: Bool = false
30
32
  @State private var isLoading: Bool = false
31
33
  @Environment(\.applicationEnvironment) private var environment
@@ -142,15 +144,68 @@ struct ToolkitNavigationHeaderRight: View {
142
144
  return NavigationButtonConfig(icon: icon, onPress: onClickHandler)
143
145
  }
144
146
 
147
+ private var showHelpCenter: Bool {
148
+ environment.applicationContext != nil && showsHelpCenter(environment.applicationContext)
149
+ }
150
+
151
+ private var closeIcon: String { headerRight?.closeIcon ?? "16_basic_home" }
152
+
145
153
  var body: some View {
154
+ Group {
155
+ if vertical {
156
+ verticalBody
157
+ } else {
158
+ horizontalBody
159
+ }
160
+ }
161
+ .onAppear {
162
+ loadFavoriteState()
163
+ }
164
+ }
165
+
166
+ private var showBadge: Bool {
167
+ headerRight?.tools.contains { group in
168
+ group.items.contains { $0.showBadge }
169
+ } ?? false
170
+ }
171
+
172
+ private var verticalBody: some View {
173
+ let navButtonConfig = getNavigationButtonConfig()
174
+ return VStack(spacing: Spacing.M) {
175
+ if shouldShowShortcut {
176
+ SideBarButton(
177
+ icon: navButtonConfig.icon,
178
+ tintColor: tintColor,
179
+ showBadge: showBadge,
180
+ disabled: isLoading,
181
+ action: navButtonConfig.onPress
182
+ )
183
+ }
184
+ SideBarGroup(tintColor: tintColor, itemCount: showHelpCenter ? 2 : 1) {
185
+ if showHelpCenter {
186
+ SideBarGroupItem(
187
+ icon: "help_center",
188
+ tintColor: tintColor,
189
+ accessibilityId: "btn_navigation_help_center",
190
+ action: onPressHelpCenter
191
+ )
192
+ }
193
+ SideBarGroupItem(
194
+ icon: closeIcon,
195
+ tintColor: tintColor,
196
+ accessibilityId: "btn_navigation_close",
197
+ action: onPressClose
198
+ )
199
+ }
200
+ }
201
+ }
202
+
203
+ private var horizontalBody: some View {
146
204
  let backgroundButtonColor: Color = tintColor == Colors.black01 ? Colors.black20.opacity(0.6) : Colors.black01.opacity(0.6)
147
205
  let borderColor: Color = tintColor == Colors.black01 ? Colors.black01.opacity(0.2) : Colors.black20.opacity(0.2)
148
206
  let navButtonConfig = getNavigationButtonConfig()
149
- let showBadge = headerRight?.tools.contains { group in
150
- group.items.contains { $0.showBadge }
151
- } ?? false
152
207
 
153
- HStack(alignment: .center, spacing: 0) {
208
+ return HStack(alignment: .center, spacing: 0) {
154
209
  if shouldShowShortcut {
155
210
  NavigationButton(
156
211
  disabled: isLoading,
@@ -162,7 +217,7 @@ struct ToolkitNavigationHeaderRight: View {
162
217
  }
163
218
 
164
219
  HStack(alignment: .center, spacing: 0) {
165
- if environment.applicationContext != nil {
220
+ if showHelpCenter {
166
221
  Icon(source: "help_center", size: 20, color: tintColor ?? Colors.black17)
167
222
  .padding(4)
168
223
  .onTapGesture {
@@ -174,7 +229,7 @@ struct ToolkitNavigationHeaderRight: View {
174
229
  .frame(width: 0.5, height: 12)
175
230
  }
176
231
 
177
- Icon(source: "16_basic_home", size: 20, color: tintColor ?? Colors.black17)
232
+ Icon(source: closeIcon, size: 20, color: tintColor ?? Colors.black17)
178
233
  .padding(4)
179
234
  .onTapGesture {
180
235
  onPressClose()
@@ -189,8 +244,5 @@ struct ToolkitNavigationHeaderRight: View {
189
244
  )
190
245
  .padding(.leading, Spacing.S)
191
246
  }
192
- .onAppear {
193
- loadFavoriteState()
194
- }
195
247
  }
196
248
  }