@momo-kits/native-kits 0.167.1-duo.1-debug → 0.167.1-duo.10-debug

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/CLAUDE.md +32 -0
  2. package/compose/build.gradle.kts +15 -1
  3. package/compose/build.gradle.kts.backup +14 -0
  4. package/compose/compose.podspec +1 -1
  5. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +2 -0
  6. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +6 -0
  7. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +20 -21
  8. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +2 -1
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +4 -2
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +1 -1
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +1 -1
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +1 -1
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +1 -1
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +9 -6
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +11 -12
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +4 -9
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +2 -2
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +10 -9
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +6 -6
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +53 -26
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +12 -2
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +7 -9
  23. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +1 -1
  24. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +13 -6
  25. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +13 -5
  26. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +3 -2
  27. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +14 -6
  28. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +19 -21
  29. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +2 -2
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +5 -2
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +3 -1
  32. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +7 -15
  33. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Card.kt +13 -11
  34. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +21 -9
  35. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +232 -0
  36. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Section.kt +11 -9
  37. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +43 -4
  38. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +8 -0
  39. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +4 -0
  40. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +143 -31
  41. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +100 -66
  42. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +22 -16
  43. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +98 -0
  44. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +312 -0
  45. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +66 -18
  46. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +23 -14
  47. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +3 -3
  48. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +1 -2
  49. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +81 -24
  50. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +1 -2
  51. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +185 -0
  52. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +2 -1
  53. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +3 -0
  54. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +13 -0
  55. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +4 -1
  56. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +4 -0
  57. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +86 -0
  58. package/gradle.properties +1 -1
  59. package/ios/Application/ApplicationEnvironment.swift +17 -0
  60. package/ios/Application/ComposeApi.swift +2 -1
  61. package/ios/Application/{Components.swift → LegacyHeader.swift} +7 -65
  62. package/ios/Application/Screen.swift +3 -2
  63. package/ios/Avatar/Avatar.swift +4 -3
  64. package/ios/Badge/Badge.swift +6 -4
  65. package/ios/Badge/BadgeDot.swift +1 -1
  66. package/ios/Badge/BadgeRibbon.swift +0 -1
  67. package/ios/Button/Button.swift +15 -12
  68. package/ios/CalculatorKeyboard/CalculatorKeyboard.swift +64 -18
  69. package/ios/Carousel/Carousel.swift +29 -12
  70. package/ios/Checkbox/Checkbox.swift +7 -3
  71. package/ios/Chip/Chip.swift +5 -4
  72. package/ios/Collapse/Collapse.swift +70 -19
  73. package/ios/Colors+Radius+Spacing/Colors.swift +10 -1
  74. package/ios/DateTimePicker/DateTimePicker.swift +54 -20
  75. package/ios/DateTimePicker/WheelPicker.swift +103 -105
  76. package/ios/Divider/Divider.swift +37 -4
  77. package/ios/Extensions/Color++.swift +38 -9
  78. package/ios/Extensions/OptionalEnvironmentObject.swift +12 -0
  79. package/ios/Extensions/SkeletonUICompat.swift +11 -0
  80. package/ios/Icon/Icon.swift +21 -23
  81. package/ios/Icon/Icons.swift +1329 -0
  82. package/ios/IconButton/IconButton.swift +15 -7
  83. package/ios/Image/Image.swift +7 -23
  84. package/ios/Information/Information.swift +27 -9
  85. package/ios/Input/ErrorView.swift +2 -2
  86. package/ios/Input/Input.swift +160 -147
  87. package/ios/Input/InputPhoneNumber.swift +12 -9
  88. package/ios/Input/InputSearch.swift +102 -75
  89. package/ios/Input/InputTextArea.swift +128 -145
  90. package/ios/InputDropDown/InputDropDown.swift +10 -7
  91. package/ios/InputMoney/InputMoney.swift +33 -11
  92. package/ios/InputOTP/InputOTP.swift +22 -12
  93. package/ios/Layout/Card.swift +50 -0
  94. package/ios/Layout/GridContext.swift +123 -3
  95. package/ios/Layout/Item.swift +5 -4
  96. package/ios/Layout/ScreenLayout.swift +312 -0
  97. package/ios/Layout/Section.swift +4 -100
  98. package/ios/Loader/Loader.swift +5 -9
  99. package/ios/Lottie/LottieView.swift +14 -2
  100. package/ios/{Application/Navigation/Overplay → Navigation}/BottomSheet.swift +97 -8
  101. package/ios/Navigation/BottomTab/BottomTab.swift +187 -0
  102. package/ios/{Application/Navigation → Navigation}/BottomTab/BottomTabBar.swift +15 -7
  103. package/ios/Navigation/BottomTab/DuoBarCompression.swift +105 -0
  104. package/ios/Navigation/BottomTab/DuoTabBar.swift +304 -0
  105. package/ios/{Application → Navigation/Component}/FloatingButton.swift +14 -3
  106. package/ios/Navigation/Component/Header.swift +493 -0
  107. package/ios/Navigation/Component/HeaderBackground.swift +134 -0
  108. package/ios/Navigation/Component/HeaderJourney.swift +73 -0
  109. package/ios/Navigation/Component/HeaderLocation.swift +48 -0
  110. package/ios/Navigation/Component/HeaderRight.swift +386 -0
  111. package/ios/Navigation/Component/HeaderTitle.swift +36 -0
  112. package/ios/{Application/Navigation → Navigation/Component}/HeaderUser.swift +21 -24
  113. package/ios/Navigation/Component/SideBar.swift +176 -0
  114. package/ios/{Application/Navigation/Overplay → Navigation/Component}/SnackBar.swift +5 -5
  115. package/ios/Navigation/HostBackGestureBridge.swift +57 -0
  116. package/ios/{Application/Navigation/Overplay → Navigation}/ModalScreen.swift +5 -1
  117. package/ios/Navigation/Navigation.swift +190 -0
  118. package/ios/{Application/Navigation → Navigation}/NavigationContainer.swift +92 -81
  119. package/ios/{Application/Navigation → Navigation}/Navigator.swift +185 -36
  120. package/ios/Navigation/StackScreen.swift +782 -0
  121. package/ios/{Application/Navigation → Navigation}/Tracking/ScreenTracker.swift +12 -5
  122. package/ios/OTPKeyboard/OTPKeyboard.swift +8 -11
  123. package/ios/Pagination/PaginationScroll.swift +2 -2
  124. package/ios/Popup/PopupDisplay.swift +72 -136
  125. package/ios/Popup/PopupInput.swift +10 -13
  126. package/ios/Popup/PopupLayout.swift +140 -0
  127. package/ios/Popup/PopupNotify.swift +56 -73
  128. package/ios/Popup/PopupPromotion.swift +31 -47
  129. package/ios/PopupView/FullscreenPopup.swift +13 -19
  130. package/ios/PopupView/PopupView.swift +1 -1
  131. package/ios/PopupView/Utils++.swift +0 -8
  132. package/ios/ProgressInfo/ProgressInfo.swift +4 -2
  133. package/ios/Radio/Radio.swift +2 -1
  134. package/ios/Rating/Rating.swift +9 -1
  135. package/ios/Resources/dot_loading.json +558 -0
  136. package/ios/Resources/lottie_circle_loader.json +1 -0
  137. package/ios/ScrollIndicator/ScrollIndicator.swift +11 -10
  138. package/ios/Skeleton/Skeleton.swift +22 -27
  139. package/ios/Slider/Slider.swift +16 -18
  140. package/ios/Stepper/Stepper.swift +6 -1
  141. package/ios/Steps/Steps.swift +8 -6
  142. package/ios/SuggestAction/SuggestAction.swift +5 -0
  143. package/ios/Swipeable/SwipeCell.swift +10 -6
  144. package/ios/Switch/Switch.swift +22 -20
  145. package/ios/TabView/TabView.swift +102 -21
  146. package/ios/Tag/Tag.swift +15 -4
  147. package/ios/Template/Logo/Logo.swift +2 -1
  148. package/ios/Template/TrustBanner/TrustBanner.swift +106 -54
  149. package/ios/Title/Title.swift +6 -17
  150. package/ios/Tooltip/Tooltip.swift +29 -14
  151. package/ios/Typography/Text.swift +61 -19
  152. package/ios/Uploader/Uploader.swift +3 -1
  153. package/ios/native-kits.podspec +2 -1
  154. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  155. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ContentView.swift +1 -1
  156. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +80 -0
  157. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +1 -0
  158. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +173 -0
  159. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +77 -1
  160. package/package.json +1 -1
  161. package/scripts/gen-ios-icons.mjs +36 -0
  162. package/.claude/settings.local.json +0 -185
  163. package/ios/Application/HeaderRight.swift +0 -306
  164. package/ios/Application/Navigation/BottomTab/BottomTab.swift +0 -104
  165. package/ios/Application/Navigation/HeaderBackProps.swift +0 -66
  166. package/ios/Application/Navigation/HeaderTitle.swift +0 -57
  167. package/ios/Application/Navigation/NavigationOptions.swift +0 -109
  168. package/ios/Application/Navigation/NavigatorManager.swift +0 -22
  169. package/ios/Application/Navigation/component/Header.swift +0 -178
  170. package/ios/Application/Navigation/component/HeaderBackground.swift +0 -83
  171. package/ios/Application/Navigation/component/HeaderColor.swift +0 -65
  172. package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +0 -11
  173. package/ios/Application/Navigation/component/NavigationFooter.swift +0 -90
  174. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +0 -196
  175. package/ios/Application/StackScreen.swift +0 -492
  176. package/local.properties +0 -8
  177. /package/ios/{Application/Navigation → Navigation}/BottomTab/CurvedContainer.swift +0 -0
@@ -0,0 +1,187 @@
1
+ //
2
+ // BottomTab.swift
3
+ // MoMoUIKits
4
+ //
5
+ // SwiftUI mirror of Compose's `navigation/bottomtab/BottomTab.kt`: tabbed screens, each in
6
+ // `StackScreen` chrome built from its own `Navigation`, over a `BottomTabBar` (or the iPhone Duo
7
+ // rail / floating bar). Switching tabs cross-fades + scales the content.
8
+ //
9
+
10
+ import SwiftUI
11
+
12
+ @available(iOS 16.0, *)
13
+ public struct BottomTab: View {
14
+ private let items: [BottomTabItem]
15
+ private let floatingButton: BottomTabFloatingButton?
16
+ private let compressionBehavior: DuoBarCompressionBehavior
17
+
18
+ @EnvironmentObject private var navigator: Navigator
19
+ /// The screen hosting the tabs (Compose's `LocalNavigation` in BottomTab).
20
+ @OptionalEnvironmentObject private var hostNavigation: Navigation?
21
+ @Environment(\.screenLayout) private var layout
22
+ @Environment(\.appTheme) private var theme
23
+ @StateObject private var tabNavigations = TabNavigations()
24
+ @State private var selectedIndex: Int = 0
25
+ @State private var toolbarHeight = DuoBarCompression.defaultToolbarHeight
26
+ @State private var expandedBar: DuoBar?
27
+ @State private var openedBar: DuoBar?
28
+
29
+ /// `compressionBehavior`: on iPhone Duo, when the side bar can't hold both bars, which one
30
+ /// starts compressed; tapping the compressed one swaps them.
31
+ public init(
32
+ items: [BottomTabItem],
33
+ floatingButton: BottomTabFloatingButton? = nil,
34
+ compressionBehavior: DuoBarCompressionBehavior = .toolbar
35
+ ) {
36
+ self.items = items
37
+ self.floatingButton = floatingButton
38
+ self.compressionBehavior = compressionBehavior
39
+ }
40
+
41
+ private var bottomInset: CGFloat { safeAreaBottomInset() }
42
+ // the Duo bars overlay the content or sit in the side strip, so only the home indicator is kept clear
43
+ private var reservedBottom: CGFloat {
44
+ layout.isIPhoneDuo ? layout.insets.bottom : BOTTOM_TAB_BAR_HEIGHT + bottomInset + Spacing.S
45
+ }
46
+
47
+ private var compression: DuoBarCompression {
48
+ DuoBarCompression.resolve(
49
+ layout: layout,
50
+ tabCount: items.count,
51
+ hasQr: floatingButton != nil,
52
+ toolbarHeight: toolbarHeight,
53
+ // 'toolbar' compression keeps the tab bar whole until the toolbar is tapped
54
+ expanded: Binding(
55
+ get: { expandedBar ?? (compressionBehavior == .toolbar ? .tabBar : .toolbar) },
56
+ set: { expandedBar = $0 }
57
+ ),
58
+ opened: $openedBar,
59
+ setToolbarHeight: { height in
60
+ if toolbarHeight != height { toolbarHeight = height }
61
+ }
62
+ )
63
+ }
64
+
65
+ private func duoInsets(_ compression: DuoBarCompression) -> DuoTabBarInsets {
66
+ layout.duoTabBarInsets(
67
+ itemCount: items.count,
68
+ hasFloatingButton: floatingButton != nil,
69
+ tabBarCompressed: compression.tabBarCompressed
70
+ )
71
+ }
72
+
73
+ public var body: some View {
74
+ let compression = compression
75
+ ZStack(alignment: .bottom) {
76
+ // Tab content area, inset above the bar.
77
+ ZStack {
78
+ ForEach(items.indices, id: \.self) { index in
79
+ if index == selectedIndex {
80
+ tabScreen(for: index)
81
+ .transition(.scale(scale: 0.97).combined(with: .opacity))
82
+ }
83
+ }
84
+ }
85
+ .padding(.bottom, reservedBottom)
86
+ .environment(\.duoTabBarInsets, duoInsets(compression))
87
+ .environment(\.duoBarCompression, compression)
88
+ .animation(.easeInOut(duration: 0.2), value: selectedIndex)
89
+
90
+ if layout.isIPhoneDuo {
91
+ DuoTabBar(
92
+ items: items,
93
+ floatingButton: floatingButton,
94
+ selectedIndex: selectedIndex,
95
+ onTabSelected: selectTab,
96
+ compression: compression
97
+ )
98
+ } else {
99
+ // Tab bar + home-indicator filler.
100
+ VStack(spacing: 0) {
101
+ BottomTabBar(
102
+ items: items,
103
+ floatingButton: floatingButton,
104
+ selectedIndex: selectedIndex,
105
+ onTabSelected: selectTab
106
+ )
107
+ theme.colors.background.surface
108
+ .frame(height: bottomInset + Spacing.S)
109
+ .ignoresSafeArea(edges: .horizontal)
110
+ }
111
+ }
112
+ }
113
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
114
+ .ignoresSafeArea(edges: .bottom)
115
+ .preference(key: IsBottomTabRootKey.self, value: true)
116
+ .onAppear {
117
+ hostNavigation?.setOptions {
118
+ $0.headerType = .none
119
+ $0.useAvoidKeyboard = false
120
+ }
121
+ updateTabBack()
122
+ }
123
+ .onChange(of: selectedIndex) { _ in updateTabBack() }
124
+ .onDisappear {
125
+ // both live outside this view
126
+ navigator.tabBackHandler = nil
127
+ HostBackGestureBridge.shared.shouldUseFloatingArrowBack = false
128
+ }
129
+ }
130
+
131
+ private func selectTab(_ index: Int) {
132
+ if index != selectedIndex { selectedIndex = index }
133
+ }
134
+
135
+ /// Back off the first tab returns to it; the host shows its floating arrow there (RN parity).
136
+ private func updateTabBack() {
137
+ let isFirstTab = selectedIndex == 0
138
+ HostBackGestureBridge.shared.shouldUseFloatingArrowBack = !isFirstTab
139
+ navigator.tabBackHandler = isFirstTab ? nil : (hostNavigation?.id ?? -1, { selectedIndex = 0 })
140
+ }
141
+
142
+ private func tabScreen(for index: Int) -> some View {
143
+ let item = items[index]
144
+ return TabScreenHost(
145
+ navigation: tabNavigations.navigation(for: index, options: item.options),
146
+ screen: item.screen,
147
+ goBack: { navigator.onBackSafe() }
148
+ )
149
+ }
150
+ }
151
+
152
+ /// Keeps each tab's `Navigation`, so options a tab set at runtime survive switching away.
153
+ private final class TabNavigations: ObservableObject {
154
+ private var navigations: [Int: Navigation] = [:]
155
+
156
+ func navigation(for index: Int, options: NavigationOptions?) -> Navigation {
157
+ if let navigation = navigations[index] { return navigation }
158
+ let navigation = Navigation(bottomTabIndex: index, options: options)
159
+ navigations[index] = navigation
160
+ return navigation
161
+ }
162
+ }
163
+
164
+ private struct TabScreenHost: View {
165
+ @ObservedObject var navigation: Navigation
166
+ let screen: () -> AnyView
167
+ let goBack: () -> Void
168
+
169
+ var body: some View {
170
+ OptionsStackScreen(
171
+ options: navigation.options,
172
+ screenName: navigation.options.screenName ?? "",
173
+ goBack: goBack,
174
+ content: screen
175
+ )
176
+ .environmentObject(navigation)
177
+ }
178
+ }
179
+
180
+ /// Bottom safe-area inset (home indicator).
181
+ @available(iOS 16.0, *)
182
+ private func safeAreaBottomInset() -> CGFloat {
183
+ let bottomInset = UIApplication.shared.connectedScenes
184
+ .compactMap { ($0 as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom }
185
+ .first ?? 0
186
+ return min(bottomInset, 21)
187
+ }
@@ -2,7 +2,7 @@
2
2
  // BottomTabBar.swift
3
3
  // MoMoUIKits
4
4
  //
5
- // SwiftUI mirror of Compose's `BottomTabBar` (navigation/bottomtab/BottomTabBar.kt).
5
+ // SwiftUI mirror of Compose's `navigation/bottomtab/BottomTabBar.kt`.
6
6
  // Renders the row of tab items, an animated selection indicator, a top shadow,
7
7
  // badges/dots, and an optional center floating button (with the curved notch).
8
8
  //
@@ -65,7 +65,11 @@ 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
+ @Environment(\.appTheme) private var theme
70
+
71
+ // the bar sits between the side safe areas
72
+ private var screenWidth: CGFloat { layout.width - layout.insets.leading - layout.insets.trailing }
69
73
  private var mid: Int { items.count / 2 }
70
74
 
71
75
  private var tabWidth: CGFloat {
@@ -95,11 +99,11 @@ struct BottomTabBar: View {
95
99
  }
96
100
  .frame(maxWidth: .infinity)
97
101
  .frame(height: BOTTOM_TAB_BAR_HEIGHT)
98
- .background(Colors.black01)
102
+ .background(theme.colors.background.surface.ignoresSafeArea(edges: .horizontal))
99
103
 
100
104
  // Animated selection indicator.
101
105
  RoundedRectangle(cornerRadius: 2)
102
- .fill(Colors.primary)
106
+ .fill(theme.colors.primary)
103
107
  .frame(width: 44, height: 2)
104
108
  .offset(x: indicatorOffset, y: -BOTTOM_TAB_BAR_HEIGHT + 2)
105
109
  .animation(.easeInOut(duration: 0.25), value: selectedIndex)
@@ -140,7 +144,9 @@ private struct TabBarItem: View {
140
144
  let selected: Bool
141
145
  let onClick: () -> Void
142
146
 
143
- private var tintColor: Color { selected ? Colors.primary : Colors.black10 }
147
+ @Environment(\.appTheme) private var theme
148
+
149
+ private var tintColor: Color { selected ? theme.colors.primary : theme.colors.text.hint }
144
150
 
145
151
  /// Mirrors Compose's `formatLabel`: hide a badge that is the number "0".
146
152
  private var badgeLabel: String? {
@@ -192,18 +198,20 @@ private struct TabBarItem: View {
192
198
  private struct CenterFloatingButton: View {
193
199
  let data: BottomTabFloatingButton
194
200
 
201
+ @Environment(\.appTheme) private var theme
202
+
195
203
  var body: some View {
196
204
  VStack(spacing: 0) {
197
205
  CurvedContainer()
198
206
  Circle()
199
- .fill(Colors.primary)
207
+ .fill(theme.colors.primary)
200
208
  .frame(width: 48, height: 48)
201
209
  .overlay(Icon(source: data.icon, size: 28, color: .white))
202
210
  Spacer().frame(height: 4)
203
211
  MomoText(data.label, typography: .labelXsMedium, color: Colors.black01, textAlign: .center, maxLines: 1)
204
212
  .frame(maxWidth: .infinity)
205
213
  .frame(height: 14)
206
- .background(Colors.primary)
214
+ .background(theme.colors.primary)
207
215
  .clipShape(RoundedRectangle(cornerRadius: Radius.XL))
208
216
  }
209
217
  .frame(width: bottomTabFloatingButtonWidth)
@@ -0,0 +1,105 @@
1
+ //
2
+ // DuoBarCompression.swift
3
+ // MoMoUIKits
4
+ //
5
+ // Port of RN `BottomTab/DuoBarCompression.tsx` (HIG, Designing for iPhone Duo: Vertical controls):
6
+ // keeps the side bar's toolbar and tab bar clear of each other.
7
+ //
8
+
9
+ import SwiftUI
10
+
11
+ /// iPhone Duo: which side bar bar starts compressed when the strip cannot hold both whole.
12
+ public enum DuoBarCompressionBehavior {
13
+ case toolbar, tabBar
14
+ }
15
+
16
+ enum DuoBar {
17
+ case toolbar, tabBar
18
+ }
19
+
20
+ struct DuoBarCompression {
21
+ var toolbarCompressed = false
22
+ var tabBarCompressed = false
23
+ /// The open rail needs the toolbar button slot too: only Back stays.
24
+ var toolbarHidden = false
25
+ var expandToolbar: () -> Void = {}
26
+ var expandTabBar: () -> Void = {}
27
+ /// A rail opened from the compressed state closes once a tab is picked.
28
+ var onTabSelected: () -> Void = {}
29
+ /// Side bar toolkits report their full height so the tab bar knows what is left.
30
+ var setToolbarHeight: (CGFloat) -> Void = { _ in }
31
+
32
+ /// Space kept between the top and the bottom groups (HIG Vertical controls).
33
+ static let groupGap = Spacing.M
34
+ /// Back, then the right toolkit: help + home capsule.
35
+ static let defaultToolbarHeight = DuoMetrics.controlSize + Spacing.M + 2 * DuoMetrics.itemPitch
36
+ /// Back, then the single button the toolbar folds into.
37
+ private static let compressedToolbarHeight = 2 * DuoMetrics.controlSize + Spacing.M
38
+ /// Back alone, while a rail opened from the compressed state takes the toolbar slot.
39
+ private static let backOnlyHeight = DuoMetrics.controlSize
40
+
41
+ /// - both fit: both stay whole
42
+ /// - one fits whole beside the other compressed: one is compressed, tapping it swaps them
43
+ /// - otherwise both rest compressed; tapping one opens it for a moment (the rail closes
44
+ /// again once a tab is picked)
45
+ static func resolve(
46
+ layout: ScreenLayout,
47
+ tabCount: Int,
48
+ hasQr: Bool,
49
+ toolbarHeight: CGFloat,
50
+ expanded: Binding<DuoBar>,
51
+ opened: Binding<DuoBar?>,
52
+ setToolbarHeight: @escaping (CGFloat) -> Void
53
+ ) -> DuoBarCompression {
54
+ guard let sideBar = layout.sideBar else { return DuoBarCompression() }
55
+ let room = layout.height - sideBar.bottom - sideBar.top - groupGap
56
+ let tabBar = duoRailHeight(itemCount: tabCount, hasQr: hasQr)
57
+ let compressedTabBar = duoRailHeight(itemCount: 1, hasQr: hasQr, compressed: true)
58
+ func fits(_ toolbar: CGFloat, _ tabs: CGFloat) -> Bool { toolbar + tabs <= room }
59
+
60
+ if fits(toolbarHeight, tabBar) {
61
+ return DuoBarCompression(setToolbarHeight: setToolbarHeight)
62
+ }
63
+ if fits(compressedToolbarHeight, tabBar) && fits(toolbarHeight, compressedTabBar) {
64
+ return DuoBarCompression(
65
+ toolbarCompressed: expanded.wrappedValue == .tabBar,
66
+ tabBarCompressed: expanded.wrappedValue == .toolbar,
67
+ expandToolbar: { expanded.wrappedValue = .toolbar },
68
+ expandTabBar: { expanded.wrappedValue = .tabBar },
69
+ setToolbarHeight: setToolbarHeight
70
+ )
71
+ }
72
+
73
+ let expandToolbar = { opened.wrappedValue = .toolbar }
74
+ let expandTabBar = { opened.wrappedValue = .tabBar }
75
+ if opened.wrappedValue == .tabBar && fits(backOnlyHeight, tabBar) {
76
+ return DuoBarCompression(
77
+ toolbarCompressed: true,
78
+ tabBarCompressed: false,
79
+ toolbarHidden: true,
80
+ expandToolbar: expandToolbar,
81
+ expandTabBar: expandTabBar,
82
+ onTabSelected: { opened.wrappedValue = nil },
83
+ setToolbarHeight: setToolbarHeight
84
+ )
85
+ }
86
+ return DuoBarCompression(
87
+ toolbarCompressed: opened.wrappedValue != .toolbar,
88
+ tabBarCompressed: true,
89
+ expandToolbar: expandToolbar,
90
+ expandTabBar: expandTabBar,
91
+ setToolbarHeight: setToolbarHeight
92
+ )
93
+ }
94
+ }
95
+
96
+ private struct DuoBarCompressionKey: EnvironmentKey {
97
+ static let defaultValue = DuoBarCompression()
98
+ }
99
+
100
+ extension EnvironmentValues {
101
+ var duoBarCompression: DuoBarCompression {
102
+ get { self[DuoBarCompressionKey.self] }
103
+ set { self[DuoBarCompressionKey.self] = newValue }
104
+ }
105
+ }
@@ -0,0 +1,304 @@
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 this far above the window bottom.
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
+ /// Rail plus the QR button under it; compressed, the rail is a single button.
47
+ func duoRailHeight(itemCount: Int, hasQr: Bool, compressed: Bool = false) -> CGFloat {
48
+ let count = CGFloat(itemCount)
49
+ let rail = compressed ? itemSize : railPadding * 2 + count * itemSize + max(count - 1, 0) * railGap
50
+ return rail + (hasQr ? Spacing.M + itemSize : 0)
51
+ }
52
+
53
+ extension ScreenLayout {
54
+ func duoTabBarInsets(itemCount: Int, hasFloatingButton: Bool, tabBarCompressed: Bool = false) -> DuoTabBarInsets {
55
+ guard isIPhoneDuo else { return DuoTabBarInsets() }
56
+ guard let sideBar else {
57
+ return DuoTabBarInsets(overlay: duoFloatingTabBarHeight + Spacing.M)
58
+ }
59
+ let rail = duoRailHeight(itemCount: itemCount, hasQr: hasFloatingButton, compressed: tabBarCompressed)
60
+ return DuoTabBarInsets(sideBarReserve: sideBar.bottom + rail + DuoBarCompression.groupGap)
61
+ }
62
+ }
63
+
64
+ /// Glass on iOS 26, a system blur before it (RN GlassView's fallback).
65
+ struct DuoBarMaterial<S: Shape>: ViewModifier {
66
+ let shape: S
67
+ var visible: Bool = true
68
+
69
+ func body(content: Content) -> some View {
70
+ content.background(material.allowsHitTesting(false))
71
+ }
72
+
73
+ @ViewBuilder
74
+ private var material: some View {
75
+ #if compiler(>=6.2)
76
+ if #available(iOS 26.0, *) {
77
+ // glass materializes by toggling its effect, it does not fade with opacity
78
+ Color.clear
79
+ .glassEffect(visible ? .regular : .identity, in: shape)
80
+ .shadow(color: Color.black.opacity(visible ? 0.16 : 0), radius: 16, x: 0, y: 6)
81
+ .animation(.easeInOut(duration: 0.25), value: visible)
82
+ } else {
83
+ blur
84
+ }
85
+ #else
86
+ blur
87
+ #endif
88
+ }
89
+
90
+ private var blur: some View {
91
+ shape.fill(.regularMaterial)
92
+ .shadow(color: Color.black.opacity(0.16), radius: 16, x: 0, y: 6)
93
+ .opacity(visible ? 1 : 0)
94
+ .animation(.easeInOut(duration: 0.25), value: visible)
95
+ }
96
+ }
97
+
98
+ struct DuoTabBar: View {
99
+ let items: [BottomTabItem]
100
+ let floatingButton: BottomTabFloatingButton?
101
+ let selectedIndex: Int
102
+ let onTabSelected: (Int) -> Void
103
+ var compression = DuoBarCompression()
104
+
105
+ @Environment(\.screenLayout) private var layout
106
+
107
+ var body: some View {
108
+ if let sideBar = layout.sideBar {
109
+ DuoRailTabBar(sideBar: sideBar, items: items, floatingButton: floatingButton,
110
+ selectedIndex: selectedIndex, onTabSelected: onTabSelected, compression: compression)
111
+ } else {
112
+ DuoFloatingTabBar(items: items, floatingButton: floatingButton,
113
+ selectedIndex: selectedIndex, onTabSelected: onTabSelected)
114
+ }
115
+ }
116
+ }
117
+
118
+ /// Vertical bar anchored to the bottom of the side bar, QR button below it (Figma MT1-C/E).
119
+ /// When the side bar is short it can shrink to one button showing the current tab
120
+ /// (HIG "Tab bar compressed"); tapping it expands the rail and compresses the toolbar.
121
+ private struct DuoRailTabBar: View {
122
+ let sideBar: SideBarLayout
123
+ let items: [BottomTabItem]
124
+ let floatingButton: BottomTabFloatingButton?
125
+ let selectedIndex: Int
126
+ let onTabSelected: (Int) -> Void
127
+ let compression: DuoBarCompression
128
+
129
+ @Environment(\.appTheme) private var theme
130
+
131
+ private var isRight: Bool { sideBar.edge == .right }
132
+
133
+ var body: some View {
134
+ VStack(spacing: Spacing.M) {
135
+ if compression.tabBarCompressed, items.indices.contains(selectedIndex) {
136
+ SwiftUI.Button(action: compression.expandTabBar) {
137
+ DuoTabIcon(item: items[selectedIndex], selected: true)
138
+ .frame(width: itemSize, height: itemSize)
139
+ .contentShape(Circle())
140
+ }
141
+ .buttonStyle(.plain)
142
+ .modifier(DuoBarMaterial(shape: Circle()))
143
+ .accessibilityLabel(items[selectedIndex].label)
144
+ .accessibilityIdentifier("bottom_tab_bar")
145
+ } else {
146
+ rail
147
+ }
148
+
149
+ if let floatingButton {
150
+ DuoQrButton(data: floatingButton, size: itemSize)
151
+ }
152
+ }
153
+ .padding(isRight ? .leading : .trailing, Spacing.M)
154
+ .frame(width: sideBar.width, alignment: isRight ? .leading : .trailing)
155
+ .padding(.bottom, sideBar.bottom)
156
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: isRight ? .bottomTrailing : .bottomLeading)
157
+ .ignoresSafeArea()
158
+ }
159
+
160
+ /// Glass column of tab items with the sliding selection bubble.
161
+ private var rail: some View {
162
+ ZStack(alignment: .top) {
163
+ Capsule()
164
+ .fill(theme.colors.background.selected)
165
+ .frame(width: railBubbleWidth, height: railBubbleHeight)
166
+ .offset(y: railPadding - (railBubbleHeight - itemSize) / 2
167
+ + CGFloat(selectedIndex) * (itemSize + railGap))
168
+ .animation(tabSpring, value: selectedIndex)
169
+
170
+ VStack(spacing: railGap) {
171
+ ForEach(items.indices, id: \.self) { index in
172
+ SwiftUI.Button {
173
+ onTabSelected(index)
174
+ compression.onTabSelected()
175
+ } label: {
176
+ DuoTabIcon(item: items[index], selected: index == selectedIndex)
177
+ .frame(width: itemSize, height: itemSize)
178
+ .contentShape(Rectangle())
179
+ }
180
+ .buttonStyle(.plain)
181
+ .accessibilityLabel(items[index].label)
182
+ }
183
+ }
184
+ .padding(.vertical, railPadding)
185
+ }
186
+ .frame(width: itemSize)
187
+ .modifier(DuoBarMaterial(shape: RoundedRectangle(cornerRadius: Radius.XL)))
188
+ .accessibilityIdentifier("bottom_tab_bar")
189
+ }
190
+ }
191
+
192
+ /// Floating labelled bar for the unfolded portrait display, QR in the middle cell (Figma MT1-M).
193
+ private struct DuoFloatingTabBar: View {
194
+ let items: [BottomTabItem]
195
+ let floatingButton: BottomTabFloatingButton?
196
+ let selectedIndex: Int
197
+ let onTabSelected: (Int) -> Void
198
+
199
+ @Environment(\.screenLayout) private var layout
200
+ @Environment(\.appTheme) private var theme
201
+
202
+ private var barWidth: CGFloat {
203
+ let columnWidth = (layout.width - 2 * Spacing.M - (gridColumns - 1) * Spacing.M) / gridColumns
204
+ return columnWidth * gridSpan + (gridSpan - 1) * Spacing.M
205
+ }
206
+
207
+ private var useQr: Bool { floatingButton != nil && items.count % 2 == 0 }
208
+ private var middle: Int { items.count / 2 }
209
+ private var cellCount: Int { items.count + (useQr ? 1 : 0) }
210
+ private var cellWidth: CGFloat { (barWidth - floatingPadding * 2) / CGFloat(max(cellCount, 1)) }
211
+ private var visualIndex: Int { useQr && selectedIndex >= middle ? selectedIndex + 1 : selectedIndex }
212
+
213
+ var body: some View {
214
+ ZStack(alignment: .leading) {
215
+ Capsule()
216
+ .fill(theme.colors.background.selected)
217
+ .frame(width: cellWidth)
218
+ .offset(x: CGFloat(visualIndex) * cellWidth)
219
+ .animation(tabSpring, value: visualIndex)
220
+
221
+ HStack(spacing: 0) {
222
+ ForEach(0..<cellCount, id: \.self) { cell in
223
+ if useQr && cell == middle, let floatingButton {
224
+ DuoQrButton(data: floatingButton, size: floatingQrSize)
225
+ .frame(width: cellWidth)
226
+ } else {
227
+ let index = useQr && cell > middle ? cell - 1 : cell
228
+ tabCell(index)
229
+ }
230
+ }
231
+ }
232
+ }
233
+ .padding(floatingPadding)
234
+ .frame(width: barWidth, height: duoFloatingTabBarHeight)
235
+ .modifier(DuoBarMaterial(shape: Capsule()))
236
+ .accessibilityIdentifier("bottom_tab_bar")
237
+ .padding(.bottom, layout.insets.bottom + Spacing.M)
238
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
239
+ .ignoresSafeArea()
240
+ }
241
+
242
+ private func tabCell(_ index: Int) -> some View {
243
+ let selected = index == selectedIndex
244
+ return SwiftUI.Button { onTabSelected(index) } label: {
245
+ VStack(spacing: Spacing.XXS) {
246
+ DuoTabIcon(item: items[index], selected: selected)
247
+ MomoText(
248
+ items[index].label,
249
+ typography: .labelXsMedium,
250
+ color: selected ? theme.colors.primary : theme.colors.text.default,
251
+ textAlign: .center,
252
+ maxLines: 1
253
+ )
254
+ }
255
+ .frame(width: cellWidth)
256
+ .frame(maxHeight: .infinity)
257
+ .contentShape(Rectangle())
258
+ }
259
+ .buttonStyle(.plain)
260
+ .accessibilityLabel(items[index].label)
261
+ }
262
+ }
263
+
264
+ private struct DuoTabIcon: View {
265
+ let item: BottomTabItem
266
+ let selected: Bool
267
+
268
+ @Environment(\.appTheme) private var theme
269
+
270
+ private var badgeLabel: String? {
271
+ guard let label = item.badgeLabel else { return nil }
272
+ if let n = Int(label), n == 0 { return nil }
273
+ return label
274
+ }
275
+
276
+ var body: some View {
277
+ Icon(source: item.icon, size: iconSize, color: selected ? theme.colors.primary : theme.colors.text.default)
278
+ .overlay(alignment: .topTrailing) {
279
+ if let badgeLabel, !badgeLabel.isEmpty {
280
+ Badge(label: badgeLabel).offset(x: Spacing.S, y: -Spacing.XS)
281
+ } else if badgeLabel != nil || item.showDot {
282
+ BadgeDot(size: .small)
283
+ }
284
+ }
285
+ }
286
+ }
287
+
288
+ private struct DuoQrButton: View {
289
+ let data: BottomTabFloatingButton
290
+ let size: CGFloat
291
+
292
+ @Environment(\.appTheme) private var theme
293
+
294
+ var body: some View {
295
+ SwiftUI.Button(action: data.onPress) {
296
+ Circle()
297
+ .fill(theme.colors.primary)
298
+ .frame(width: size, height: size)
299
+ .overlay(Icon(source: data.icon, size: qrIconSize, color: Colors.black01))
300
+ }
301
+ .buttonStyle(.plain)
302
+ .accessibilityLabel(data.label)
303
+ }
304
+ }
@@ -1,3 +1,11 @@
1
+ //
2
+ // FloatingButton.swift
3
+ // MoMoUIKits
4
+ //
5
+ // SwiftUI mirror of Compose's `navigation/component/FloatingButton.kt`: the screen FAB, whose
6
+ // label collapses while the content scrolls down.
7
+ //
8
+
1
9
  import SwiftUI
2
10
  import Combine
3
11
 
@@ -111,13 +119,16 @@ public struct FloatingButton: View {
111
119
  content
112
120
  }
113
121
  }
114
- .onTapGesture {
115
- onClick?()
116
- }
117
122
  .padding(Spacing.M)
118
123
  .frame(height: height + (Spacing.M * 2))
119
124
  .background(containerColor)
120
125
  .cornerRadius(100)
126
+ // Compose FloatingActionButton elevation 4dp
127
+ .shadow(color: Color.black.opacity(0.2), radius: 4, x: 0, y: 2)
128
+ .contentShape(Capsule())
129
+ .onTapGesture {
130
+ onClick?()
131
+ }
121
132
  .padding(.bottom, bottomPadding)
122
133
  .frame(alignment: position == .center ? .center : .trailing)
123
134
  .onAppear {