@momo-kits/native-kits 0.168.1-beta.2-debug → 0.168.1-beta.24-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 (62) hide show
  1. package/CLAUDE.md +10 -6
  2. package/compose/build.gradle.kts +1 -1
  3. package/compose/compose.podspec +1 -1
  4. package/compose/src/androidMain/kotlin/vn/momo/kits/const/HostFont.android.kt +4 -3
  5. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +1 -1
  6. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +0 -14
  7. package/compose/src/commonMain/kotlin/vn/momo/kits/application/DesignSystemKits.kt +62 -0
  8. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +25 -16
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +3 -3
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +2 -1
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +18 -14
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +25 -13
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +206 -45
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +14 -3
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +2 -2
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +53 -30
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +2 -3
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +124 -59
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +1 -1
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +3 -1
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +2 -2
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +10 -3
  23. package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +3 -1
  24. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +9 -8
  25. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +54 -10
  26. package/gradle.properties +1 -1
  27. package/ios/Application/ApplicationEnvironment.swift +7 -6
  28. package/ios/Application/DesignSystemKits.swift +163 -0
  29. package/ios/Application/DesignSystemKitsPayload.swift +68 -0
  30. package/ios/Badge/BadgeRibbon.swift +8 -1
  31. package/ios/Extensions/Color++.swift +2 -4
  32. package/ios/Input/Input.swift +13 -3
  33. package/ios/Input/InputPhoneNumber.swift +1 -3
  34. package/ios/Input/InputSearch.swift +1 -2
  35. package/ios/Input/InputTextArea.swift +5 -3
  36. package/ios/InputMoney/InputMoney.swift +6 -7
  37. package/ios/Layout/ScreenLayout.swift +65 -10
  38. package/ios/Navigation/BottomSheet.swift +153 -23
  39. package/ios/Navigation/BottomTab/DuoBarCompression.swift +2 -3
  40. package/ios/Navigation/BottomTab/DuoTabBar.swift +94 -69
  41. package/ios/Navigation/Component/HeaderBackground.swift +22 -1
  42. package/ios/Navigation/Component/SnackBar.swift +4 -0
  43. package/ios/Navigation/ModalScreen.swift +7 -3
  44. package/ios/Navigation/StackScreen.swift +3 -7
  45. package/ios/Popup/PopupDisplay.swift +2 -2
  46. package/ios/Popup/PopupInput.swift +2 -2
  47. package/ios/Popup/PopupLayout.swift +15 -10
  48. package/ios/Popup/PopupNotify.swift +2 -2
  49. package/ios/Stepper/Stepper.swift +1 -2
  50. package/ios/Template/TrustBanner/TrustBanner.swift +4 -2
  51. package/ios/Typography/FontScale.swift +29 -15
  52. package/ios/Typography/Text.swift +86 -12
  53. package/ios/Typography/Typography.swift +2 -2
  54. package/ios/native-kits.podspec +1 -1
  55. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  56. package/local.properties +8 -0
  57. package/package.json +1 -1
  58. package/.claude/settings.local.json +0 -9
  59. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitDesignSystem.kt +0 -18
  60. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitFontScale.kt +0 -22
  61. package/ios/Typography/FontScaleStore.swift +0 -58
  62. package/ios/Typography/KitDesignSystem.swift +0 -17
@@ -15,16 +15,25 @@ import SwiftUI
15
15
 
16
16
  /// Mirrors Compose's `sealed class BottomHeader` (Title / Custom).
17
17
  public struct BottomSheetHeader {
18
+ struct Back {
19
+ let icon: String
20
+ let action: () -> Void
21
+ }
22
+
18
23
  enum Style {
19
- case title(String)
24
+ case title(String, back: Back?)
20
25
  case custom(() -> AnyView)
21
26
  }
22
27
 
23
28
  let style: Style
24
29
 
25
- /// Mirrors `BottomHeader.Title`.
26
- public static func title(_ text: String = "Bottom Sheet Title") -> BottomSheetHeader {
27
- BottomSheetHeader(style: .title(text))
30
+ /// Mirrors `BottomHeader.Title`. `onBack` shows a back button; the sheet closes, then it runs.
31
+ public static func title(
32
+ _ text: String = "Bottom Sheet Title",
33
+ backIcon: String = "arrow-back",
34
+ onBack: (() -> Void)? = nil
35
+ ) -> BottomSheetHeader {
36
+ BottomSheetHeader(style: .title(text, back: onBack.map { Back(icon: backIcon, action: $0) }))
28
37
  }
29
38
 
30
39
  /// Mirrors `BottomHeader.Custom`.
@@ -68,6 +77,9 @@ struct BottomSheetView<Content: View>: View {
68
77
  /// Mirrors Compose's `sheetCloseOffset` (100dp drag threshold to dismiss).
69
78
  private let closeThreshold: CGFloat = 100
70
79
 
80
+ private let indicatorHeight: CGFloat = 16
81
+ private let headerRowHeight: CGFloat = 56
82
+
71
83
  /// Mirrors Compose's per-term peak (0.3f). Combined with `backgroundAlpha` the
72
84
  /// steady-state (fully open) scrim peaks at 0.6, matching ModalScreen's peak.
73
85
  private let maxDynamicAlpha: CGFloat = 0.3
@@ -77,7 +89,23 @@ struct BottomSheetView<Content: View>: View {
77
89
 
78
90
  private var isDuo: Bool { layout.isDuoDevice }
79
91
  // iPhone Duo inner display: center the sheet instead of spanning the width
80
- private var isDuoCentered: Bool { isDuo && layout.width >= 600 }
92
+ // short side, as RN: the outer display stays floating in landscape (678pt wide) too
93
+ private var isDuoCentered: Bool { isDuo && min(layout.width, layout.height) >= 600 }
94
+ /// Outer display: the sheet floats Spacing.M off the side edges like system sheets, its content
95
+ /// stays in the safe area and its buttons move to the side bar column (HIG).
96
+ private var isDuoFloating: Bool { isDuo && !isDuoCentered }
97
+
98
+ private var titleBack: BottomSheetHeader.Back? {
99
+ if case .title(_, let back) = header.style { return back }
100
+ return nil
101
+ }
102
+
103
+ private var sideColumn: SideBarLayout? {
104
+ guard isDuoFloating, case .title = header.style, let sideBar = layout.sideBar,
105
+ sideBar.width - Spacing.M >= DuoMetrics.controlSize + Spacing.M
106
+ else { return nil }
107
+ return sideBar
108
+ }
81
109
 
82
110
  /// Keeps the portrait width in landscape, like system sheets.
83
111
  private var duoCenteredWidth: CGFloat {
@@ -87,18 +115,20 @@ struct BottomSheetView<Content: View>: View {
87
115
  )
88
116
  }
89
117
 
90
- /// Landscape side buttons span the full height, so the content is inset; in portrait the
91
- /// side status bar only covers the top corner.
118
+ /// The floating sheet spans the side safe area, its content stays clear of it.
92
119
  private var sideInsetLeading: CGFloat {
93
- isDuo && !isDuoCentered && layout.width > layout.height ? layout.insets.leading : 0
120
+ isDuoFloating ? max(layout.insets.leading - Spacing.M, 0) : 0
94
121
  }
95
122
 
96
123
  private var sideInsetTrailing: CGFloat {
97
- isDuo && !isDuoCentered && layout.width > layout.height ? layout.insets.trailing : 0
124
+ isDuoFloating ? max(layout.insets.trailing - Spacing.M, 0) : 0
98
125
  }
99
126
 
100
127
  private var maxHeight: CGFloat {
101
- isDuo ? layout.height - max(layout.duoTopInset, Spacing.XL) : screenHeight - 90
128
+ guard isDuo else { return screenHeight - 90 }
129
+ // stays below the side status bar: unlike RN (StatusBar.pushStackEntry) nothing restyles it, so a light
130
+ // clock over the sheet would vanish
131
+ return layout.height - max(layout.duoTopInset, Spacing.XL)
102
132
  }
103
133
  private var bottomInset: CGFloat { useBottomInset ? safeAreaBottomInset() : 0 }
104
134
  /// While the keyboard is up, it already covers the home-indicator area, so it
@@ -170,19 +200,21 @@ struct BottomSheetView<Content: View>: View {
170
200
  .frame(width: 40, height: 4)
171
201
  Spacer().frame(height: 4)
172
202
  headerView
173
- .frame(height: 56)
203
+ .frame(height: headerRowHeight)
174
204
  .padding(.horizontal, Spacing.M)
175
- .padding(.leading, sideInsetLeading)
176
- .padding(.trailing, sideInsetTrailing)
177
205
  }
206
+ .padding(.leading, sideInsetLeading)
207
+ .padding(.trailing, sideInsetTrailing)
178
208
  .frame(maxWidth: .infinity)
179
- .frame(height: 72)
209
+ .frame(height: indicatorHeight + headerRowHeight)
180
210
  .contentShape(Rectangle())
181
211
  .gesture(dragGesture, including: draggable ? .all : .subviews)
182
212
 
183
213
  Rectangle()
184
214
  .fill(theme.colors.border.default)
185
215
  .frame(height: 1)
216
+ .padding(.leading, sideInsetLeading)
217
+ .padding(.trailing, sideInsetTrailing)
186
218
 
187
219
  content()
188
220
  .padding(.leading, sideInsetLeading)
@@ -191,7 +223,7 @@ struct BottomSheetView<Content: View>: View {
191
223
  .layoutPriority(-1)
192
224
 
193
225
  if let footer {
194
- BottomSheetFooter(content: footer)
226
+ BottomSheetFooter(content: footer, leadingInset: sideInsetLeading, trailingInset: sideInsetTrailing)
195
227
  }
196
228
  }
197
229
  // The filler stays outside the cap, which shrinks by it instead: the sheet still ends at
@@ -209,27 +241,61 @@ struct BottomSheetView<Content: View>: View {
209
241
  }
210
242
  )
211
243
  .background(backgroundColor)
212
- .clipShape(RoundedCorner(radius: Radius.M, corners: [.topLeft, .topRight]))
244
+ .overlay {
245
+ if let sideColumn {
246
+ BottomSheetSideColumn(
247
+ sideBar: sideColumn,
248
+ windowHeight: layout.height,
249
+ topOffset: indicatorHeight + (headerRowHeight - DuoMetrics.controlSize) / 2,
250
+ back: titleBack,
251
+ onBack: goBack,
252
+ onClose: { close() }
253
+ )
254
+ }
255
+ }
256
+ .clipShape(RoundedCorner(radius: isDuo ? Radius.XL : Radius.M, corners: [.topLeft, .topRight]))
257
+ .padding(.horizontal, isDuoFloating ? Spacing.M : 0)
213
258
  }
214
259
 
215
260
  @ViewBuilder
216
261
  private var headerView: some View {
217
262
  switch header.style {
218
- case .title(let text):
263
+ case .title(let text, let back):
264
+ // the side column holds back and close, the row keeps only the title
265
+ let showsButtons = sideColumn == nil
219
266
  HStack(spacing: 0) {
220
- Color.clear.frame(width: 24, height: 24)
267
+ if showsButtons {
268
+ if let back {
269
+ headerButton(icon: back.icon, accessibilityId: "btn_bottom_sheet_back") { goBack(back) }
270
+ } else {
271
+ Color.clear.frame(width: 24, height: 24)
272
+ }
273
+ }
221
274
  MomoText(text, typography: .headerDefaultBold, color: Colors.black17)
222
275
  .frame(maxWidth: .infinity, alignment: .center)
223
- Icon(source: "navigation_close", size: 24, color: Colors.black17)
224
- .frame(width: 24, height: 24)
225
- .contentShape(Rectangle())
226
- .onTapGesture { close() }
276
+ .padding(.horizontal, showsButtons ? 0 : Spacing.M)
277
+ if showsButtons {
278
+ headerButton(icon: "navigation_close", accessibilityId: "btn_bottom_sheet_close") { close() }
279
+ }
227
280
  }
228
281
  case .custom(let builder):
229
282
  builder()
230
283
  }
231
284
  }
232
285
 
286
+ private func headerButton(icon: String, accessibilityId: String, action: @escaping () -> Void) -> some View {
287
+ Icon(source: icon, size: 24, color: Colors.black17)
288
+ .frame(width: 24, height: 24)
289
+ .contentShape(Rectangle())
290
+ .onTapGesture(perform: action)
291
+ .accessibilityIdentifier(accessibilityId)
292
+ }
293
+
294
+ /// RN `leftOptions.onPressIconLeft`: the sheet closes first, then the back runs.
295
+ private func goBack(_ back: BottomSheetHeader.Back) {
296
+ close(then: back.action)
297
+ }
298
+
233
299
  // MARK: - Drag handling (mirrors Compose's detectDragGestures)
234
300
 
235
301
  private var dragGesture: some Gesture {
@@ -282,7 +348,7 @@ struct BottomSheetView<Content: View>: View {
282
348
 
283
349
  /// Mirrors Compose's `closeEvent`: slide out over 200ms, then fade the scrim
284
350
  /// out over 100ms only after the slide finishes, then dismiss.
285
- private func close() {
351
+ private func close(then onClosed: (() -> Void)? = nil) {
286
352
  guard !isClosing else { return }
287
353
  isClosing = true
288
354
  withAnimation(.timingCurve(0.3, 0, 0.8, 0.15, duration: 0.2)) {
@@ -294,7 +360,67 @@ struct BottomSheetView<Content: View>: View {
294
360
  }
295
361
  }
296
362
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
363
+ // a newer overlay replaced this one: it stays, and this sheet's back is dropped
364
+ guard navigator.overplay?.id == overplayId else { return }
297
365
  navigator.finishOverplayDismiss(id: overplayId)
366
+ onClosed?()
367
+ }
368
+ }
369
+ }
370
+
371
+ /// iPhone Duo outer display: back and close sit on the part of the sheet under the side safe area,
372
+ /// 12pt from the strip's inner edge like the screen side bar. They follow the header row while the
373
+ /// sheet is short and stay below the status bar cluster while it is tall.
374
+ private struct BottomSheetSideColumn: View {
375
+ let sideBar: SideBarLayout
376
+ let windowHeight: CGFloat
377
+ /// Lines the controls up with the header row.
378
+ let topOffset: CGFloat
379
+ let back: BottomSheetHeader.Back?
380
+ let onBack: (BottomSheetHeader.Back) -> Void
381
+ let onClose: () -> Void
382
+
383
+ @Environment(\.appTheme) private var theme
384
+
385
+ private var isRight: Bool { sideBar.edge == .right }
386
+
387
+ private var columnHeight: CGFloat {
388
+ let count: CGFloat = back != nil ? 2 : 1
389
+ return count * DuoMetrics.controlSize + (count - 1) * Spacing.M
390
+ }
391
+
392
+ private var headerColor: HeaderColor {
393
+ getHeaderColor(isAnimated: false, isSurface: true, opacity: 1, tintColor: nil, defaultColor: theme.colors.text.default)
394
+ }
395
+
396
+ var body: some View {
397
+ GeometryReader { proxy in
398
+ let sheetHeight = proxy.size.height
399
+ // the sheet rests on the window bottom; its slide is an offset outside this layout
400
+ let sheetTop = windowHeight - sheetHeight
401
+ // clear of a camera at the bottom end of the strip when it can
402
+ let lowest = sheetHeight - sideBar.bottom - columnHeight
403
+ let top = max(min(max(sideBar.top - sheetTop, topOffset), lowest), 0)
404
+ VStack(alignment: isRight ? .leading : .trailing, spacing: Spacing.M) {
405
+ if let back {
406
+ SideBarButton(
407
+ icon: back.icon,
408
+ headerColor: headerColor,
409
+ accessibilityId: "btn_bottom_sheet_back",
410
+ action: { onBack(back) }
411
+ )
412
+ }
413
+ SideBarButton(
414
+ icon: "navigation_close",
415
+ headerColor: headerColor,
416
+ accessibilityId: "btn_bottom_sheet_close",
417
+ action: onClose
418
+ )
419
+ }
420
+ .padding(isRight ? .leading : .trailing, Spacing.M)
421
+ .frame(width: sideBar.width - Spacing.M, alignment: isRight ? .leading : .trailing)
422
+ .padding(.top, top)
423
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: isRight ? .topTrailing : .topLeading)
298
424
  }
299
425
  }
300
426
  }
@@ -371,12 +497,16 @@ private struct CapHeightLayout: Layout {
371
497
  /// Mirrors Compose's `BottomSheetFooter`: a surface band with a soft shadow cast on the content.
372
498
  private struct BottomSheetFooter: View {
373
499
  let content: () -> AnyView
500
+ var leadingInset: CGFloat = 0
501
+ var trailingInset: CGFloat = 0
374
502
 
375
503
  @Environment(\.appTheme) private var theme
376
504
 
377
505
  var body: some View {
378
506
  content()
379
507
  .frame(maxWidth: .infinity)
508
+ .padding(.leading, leadingInset)
509
+ .padding(.trailing, trailingInset)
380
510
  .padding(.vertical, Spacing.S)
381
511
  .background(theme.colors.background.surface)
382
512
  .overlay(alignment: .top) {
@@ -35,8 +35,6 @@ struct DuoBarCompression {
35
35
  static let defaultToolbarHeight = DuoMetrics.controlSize + Spacing.M + 2 * DuoMetrics.itemPitch
36
36
  /// Back, then the single button the toolbar folds into.
37
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
38
 
41
39
  /// - both fit: both stay whole
42
40
  /// - one fits whole beside the other compressed: one is compressed, tapping it swaps them
@@ -72,7 +70,8 @@ struct DuoBarCompression {
72
70
 
73
71
  let expandToolbar = { opened.wrappedValue = .toolbar }
74
72
  let expandTabBar = { opened.wrappedValue = .tabBar }
75
- if opened.wrappedValue == .tabBar && fits(backOnlyHeight, tabBar) {
73
+ // an opened rail always shows, even when only the group gap is short: it closes once a tab is picked
74
+ if opened.wrappedValue == .tabBar {
76
75
  return DuoBarCompression(
77
76
  toolbarCompressed: true,
78
77
  tabBarCompressed: false,
@@ -3,7 +3,7 @@
3
3
  // MoMoUIKits
4
4
  //
5
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.
6
+ // a rail in the side bar, or a floating bar when unfolded portrait.
7
7
  //
8
8
 
9
9
  import SwiftUI
@@ -16,12 +16,15 @@ private let railGap = Spacing.XXS
16
16
  // selection bubble overflows the 48 rail cell by 5 above and below (Figma MT2)
17
17
  private let railBubbleWidth: CGFloat = 44
18
18
  private let railBubbleHeight: CGFloat = 58
19
- let duoFloatingTabBarHeight: CGFloat = 62
19
+ private let floatingHeight = DuoMetrics.floatingTabBarHeight
20
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
21
+ // the system bar sizes its pill to the tabs instead of stretching it across the display
22
+ private let floatingItemWidth: CGFloat = 80
23
+ // while pressed the selection bubble grows into a lens past the pill's edges
24
+ private let lensScaleX: CGFloat = 1.2
25
+ private let lensScaleY: CGFloat = 1.3
26
+ // Figma Home-2026 tab bar "Selection": brand pink at 8%
27
+ private let selectionTintOpacity: Double = 0x14 / 255
25
28
  private let tabSpring = Animation.interpolatingSpring(mass: 0.8, stiffness: 220, damping: 18)
26
29
 
27
30
  /// Room tab screens leave for the Duo tab bar, provided by BottomTab to its tab StackScreens.
@@ -51,47 +54,34 @@ func duoRailHeight(itemCount: Int, hasQr: Bool, compressed: Bool = false) -> CGF
51
54
  }
52
55
 
53
56
  extension ScreenLayout {
57
+ /// Overlaps the home indicator inset like the system tab bar (21pt above the bottom with a 34pt inset).
58
+ var duoFloatingTabBarBottom: CGFloat {
59
+ max(insets.bottom - DuoMetrics.floatingTabBarInsetOverlap, Spacing.S)
60
+ }
61
+
54
62
  func duoTabBarInsets(itemCount: Int, hasFloatingButton: Bool, tabBarCompressed: Bool = false) -> DuoTabBarInsets {
55
63
  guard isIPhoneDuo else { return DuoTabBarInsets() }
56
64
  guard let sideBar else {
57
- return DuoTabBarInsets(overlay: duoFloatingTabBarHeight + Spacing.M)
65
+ // overlay is measured from the bottom safe area, the bar reaches into it
66
+ return DuoTabBarInsets(overlay: floatingHeight + duoFloatingTabBarBottom - insets.bottom)
58
67
  }
59
68
  let rail = duoRailHeight(itemCount: itemCount, hasQr: hasFloatingButton, compressed: tabBarCompressed)
60
69
  return DuoTabBarInsets(sideBarReserve: sideBar.bottom + rail + DuoBarCompression.groupGap)
61
70
  }
62
71
  }
63
72
 
64
- /// Glass on iOS 26, a system blur before it (RN GlassView's fallback).
65
- struct DuoBarMaterial<S: Shape>: ViewModifier {
73
+ /// RN `BarMaterial`: surface with a 1pt border.
74
+ struct DuoBarMaterial<S: InsettableShape>: ViewModifier {
66
75
  let shape: S
67
- var visible: Bool = true
68
-
69
- func body(content: Content) -> some View {
70
- content.background(material.allowsHitTesting(false))
71
- }
72
76
 
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
- }
77
+ @Environment(\.appTheme) private var theme
89
78
 
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)
79
+ func body(content: Content) -> some View {
80
+ content.background(
81
+ shape.fill(theme.colors.background.surface)
82
+ .overlay(shape.strokeBorder(theme.colors.border.default, lineWidth: 1))
83
+ .allowsHitTesting(false)
84
+ )
95
85
  }
96
86
  }
97
87
 
@@ -126,7 +116,7 @@ private struct DuoRailTabBar: View {
126
116
  let onTabSelected: (Int) -> Void
127
117
  let compression: DuoBarCompression
128
118
 
129
- @Environment(\.appTheme) private var theme
119
+ @State private var pressedIndex: Int?
130
120
 
131
121
  private var isRight: Bool { sideBar.edge == .right }
132
122
 
@@ -157,15 +147,15 @@ private struct DuoRailTabBar: View {
157
147
  .ignoresSafeArea()
158
148
  }
159
149
 
160
- /// Glass column of tab items with the sliding selection bubble.
150
+ /// Column of icon-only tab items with the sliding selection bubble.
161
151
  private var rail: some View {
162
152
  ZStack(alignment: .top) {
163
- Capsule()
164
- .fill(theme.colors.background.selected)
153
+ DuoSelectionMaterial(lens: pressedIndex != nil)
165
154
  .frame(width: railBubbleWidth, height: railBubbleHeight)
166
155
  .offset(y: railPadding - (railBubbleHeight - itemSize) / 2
167
- + CGFloat(selectedIndex) * (itemSize + railGap))
156
+ + CGFloat(pressedIndex ?? selectedIndex) * (itemSize + railGap))
168
157
  .animation(tabSpring, value: selectedIndex)
158
+ .animation(tabSpring, value: pressedIndex)
169
159
 
170
160
  VStack(spacing: railGap) {
171
161
  ForEach(items.indices, id: \.self) { index in
@@ -177,19 +167,19 @@ private struct DuoRailTabBar: View {
177
167
  .frame(width: itemSize, height: itemSize)
178
168
  .contentShape(Rectangle())
179
169
  }
180
- .buttonStyle(.plain)
170
+ .buttonStyle(DuoLensPressStyle(index: index, pressedIndex: $pressedIndex))
181
171
  .accessibilityLabel(items[index].label)
182
172
  }
183
173
  }
184
174
  .padding(.vertical, railPadding)
185
175
  }
186
176
  .frame(width: itemSize)
187
- .modifier(DuoBarMaterial(shape: RoundedRectangle(cornerRadius: Radius.XL)))
177
+ .modifier(DuoBarMaterial(shape: Capsule()))
188
178
  .accessibilityIdentifier("bottom_tab_bar")
189
179
  }
190
180
  }
191
181
 
192
- /// Floating labelled bar for the unfolded portrait display, QR in the middle cell (Figma MT1-M).
182
+ /// Floating labelled bar for the unfolded portrait display, QR pinned in its own platter beside it.
193
183
  private struct DuoFloatingTabBar: View {
194
184
  let items: [BottomTabItem]
195
185
  let floatingButton: BottomTabFloatingButton?
@@ -198,45 +188,48 @@ private struct DuoFloatingTabBar: View {
198
188
 
199
189
  @Environment(\.screenLayout) private var layout
200
190
  @Environment(\.appTheme) private var theme
191
+ @State private var pressedIndex: Int?
201
192
 
202
193
  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
194
+ let margins = layout.insets.leading + layout.insets.trailing + 2 * DuoMetrics.floatingTabBarMargin
195
+ let qr = floatingButton == nil ? 0 : floatingHeight + Spacing.S
196
+ let preferred = CGFloat(items.count) * floatingItemWidth + 2 * floatingPadding
197
+ return max(min(preferred, layout.width - margins - qr), 0)
205
198
  }
206
199
 
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 }
200
+ private var cellWidth: CGFloat { (barWidth - floatingPadding * 2) / CGFloat(max(items.count, 1)) }
212
201
 
213
202
  var body: some View {
203
+ HStack(spacing: Spacing.S) {
204
+ bar
205
+ if let floatingButton {
206
+ DuoQrButton(data: floatingButton, size: floatingHeight)
207
+ .shadow(color: Color.black.opacity(0.16), radius: 16, x: 0, y: 6)
208
+ }
209
+ }
210
+ .padding(.bottom, layout.duoFloatingTabBarBottom)
211
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
212
+ .ignoresSafeArea()
213
+ }
214
+
215
+ private var bar: some View {
214
216
  ZStack(alignment: .leading) {
215
- Capsule()
216
- .fill(theme.colors.background.selected)
217
+ DuoSelectionMaterial(lens: pressedIndex != nil)
217
218
  .frame(width: cellWidth)
218
- .offset(x: CGFloat(visualIndex) * cellWidth)
219
- .animation(tabSpring, value: visualIndex)
219
+ .offset(x: CGFloat(pressedIndex ?? selectedIndex) * cellWidth)
220
+ .animation(tabSpring, value: selectedIndex)
221
+ .animation(tabSpring, value: pressedIndex)
220
222
 
221
223
  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
- }
224
+ ForEach(items.indices, id: \.self) { index in
225
+ tabCell(index)
230
226
  }
231
227
  }
232
228
  }
233
229
  .padding(floatingPadding)
234
- .frame(width: barWidth, height: duoFloatingTabBarHeight)
230
+ .frame(width: barWidth, height: floatingHeight)
235
231
  .modifier(DuoBarMaterial(shape: Capsule()))
236
232
  .accessibilityIdentifier("bottom_tab_bar")
237
- .padding(.bottom, layout.insets.bottom + Spacing.M)
238
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
239
- .ignoresSafeArea()
240
233
  }
241
234
 
242
235
  private func tabCell(_ index: Int) -> some View {
@@ -247,20 +240,52 @@ private struct DuoFloatingTabBar: View {
247
240
  MomoText(
248
241
  items[index].label,
249
242
  typography: .labelXsMedium,
250
- color: selected ? theme.colors.primary : theme.colors.text.default,
243
+ color: selected ? theme.colors.primary : theme.colors.text.hint,
251
244
  textAlign: .center,
252
245
  maxLines: 1
253
246
  )
247
+ // like the system tab bar the label keeps its design size; a constant snapshot, nothing to observe
248
+ .environment(\.momoFontScale, FontScale(userScaleRate: 1, useOSScaleRate: false))
254
249
  }
255
250
  .frame(width: cellWidth)
256
251
  .frame(maxHeight: .infinity)
257
252
  .contentShape(Rectangle())
258
253
  }
259
- .buttonStyle(.plain)
254
+ .buttonStyle(DuoLensPressStyle(index: index, pressedIndex: $pressedIndex))
260
255
  .accessibilityLabel(items[index].label)
261
256
  }
262
257
  }
263
258
 
259
+ /// Pink tinted capsule under the selected tab; while pressed it grows into a lens (80x54 to about 96x70).
260
+ private struct DuoSelectionMaterial: View {
261
+ let lens: Bool
262
+
263
+ @Environment(\.appTheme) private var theme
264
+
265
+ var body: some View {
266
+ Capsule()
267
+ .fill(theme.colors.primary.opacity(selectionTintOpacity))
268
+ .scaleEffect(x: lens ? lensScaleX : 1, y: lens ? lensScaleY : 1)
269
+ .allowsHitTesting(false)
270
+ }
271
+ }
272
+
273
+ /// Tracks the finger so the selection lens jumps to the pressed tab before it is selected.
274
+ private struct DuoLensPressStyle: ButtonStyle {
275
+ let index: Int
276
+ @Binding var pressedIndex: Int?
277
+
278
+ func makeBody(configuration: Configuration) -> some View {
279
+ configuration.label.onChange(of: configuration.isPressed) { pressed in
280
+ if pressed {
281
+ pressedIndex = index
282
+ } else if pressedIndex == index {
283
+ pressedIndex = nil
284
+ }
285
+ }
286
+ }
287
+ }
288
+
264
289
  private struct DuoTabIcon: View {
265
290
  let item: BottomTabItem
266
291
  let selected: Bool
@@ -274,7 +299,7 @@ private struct DuoTabIcon: View {
274
299
  }
275
300
 
276
301
  var body: some View {
277
- Icon(source: item.icon, size: iconSize, color: selected ? theme.colors.primary : theme.colors.text.default)
302
+ Icon(source: item.icon, size: iconSize, color: selected ? theme.colors.primary : theme.colors.text.hint)
278
303
  .overlay(alignment: .topTrailing) {
279
304
  if let badgeLabel, !badgeLabel.isEmpty {
280
305
  Badge(label: badgeLabel).offset(x: Spacing.S, y: -Spacing.XS)
@@ -90,6 +90,27 @@ private let duoGradientExtra: CGFloat = 52
90
90
  // scroll distance over which the extended gradient collapses to the header
91
91
  private let duoExtendedCollapseDistance: CGFloat = 100
92
92
 
93
+ // scroll distance over which the bar fades in
94
+ private let duoBarFadeDistance: CGFloat = 16
95
+
96
+ /// Port of RN `DuoGlassBackground`: translucent surface with a hairline, shown once the content
97
+ /// scrolls `fadeStart` under the header.
98
+ struct DuoHeaderBar: View {
99
+ let scrollOffset: CGFloat
100
+ var fadeStart: CGFloat = 0
101
+
102
+ @Environment(\.appTheme) private var theme
103
+
104
+ var body: some View {
105
+ theme.colors.background.surface.opacity(0.8)
106
+ .overlay(alignment: .bottom) {
107
+ theme.colors.border.default.frame(height: 0.5)
108
+ }
109
+ .opacity(min(max((scrollOffset - fadeStart) / duoBarFadeDistance, 0), 1))
110
+ .allowsHitTesting(false)
111
+ }
112
+ }
113
+
93
114
  /// Port of RN `DuoScrollGradient`: with the glass header the content scrolls under the bar, so the
94
115
  /// header gradient is drawn under it. Compose draws this inside `HeaderBackground`.
95
116
  struct DuoScrollGradient: View {
@@ -114,7 +135,7 @@ struct DuoScrollGradient: View {
114
135
  let progress = min(scroll / duoExtendedCollapseDistance, 1)
115
136
  gradient
116
137
  .frame(height: headerHeight + (extended ? duoGradientExtra * (1 - progress) : 0))
117
- .opacity(1 - min(scroll / duoGradientExtra, 1))
138
+ .opacity(Double(1 - min(scroll / duoGradientExtra, 1)))
118
139
  }
119
140
  }
120
141
  .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
@@ -57,6 +57,7 @@ struct SnackBarView: View {
57
57
  let bottomInset: CGFloat
58
58
 
59
59
  @EnvironmentObject private var navigator: Navigator
60
+ @Environment(\.screenLayout) private var layout
60
61
 
61
62
  @State private var contentHeight: CGFloat = 0
62
63
  @State private var offsetY: CGFloat = 0
@@ -75,6 +76,9 @@ struct SnackBarView: View {
75
76
  )
76
77
  .offset(y: offsetY)
77
78
  .padding(.bottom, bottomInset)
79
+ // iPhone Duo: clear of the side toolbar strip, like the screen content (0 elsewhere)
80
+ .padding(.leading, layout.insetLeft)
81
+ .padding(.trailing, layout.insetRight)
78
82
  }
79
83
  .frame(maxWidth: .infinity, maxHeight: .infinity)
80
84
  .ignoresSafeArea()
@@ -34,11 +34,15 @@ struct ModalScreen<Content: View>: View {
34
34
  }
35
35
 
36
36
  content()
37
+ // iPhone Duo: capped so it stays a dialog on the inner display; nil leaves the size alone
38
+ .frame(
39
+ maxWidth: layout.isDuoDevice ? layout.duoModalMaxWidth : nil,
40
+ maxHeight: layout.isDuoDevice ? layout.duoModalMaxHeight : nil
41
+ )
37
42
  .scaleEffect(contentScale)
38
43
  .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)
44
+ // iPhone Duo: center in the safe area, clear of the side toolbar strip and the home indicator
45
+ .padding(layout.isDuoDevice ? layout.insets : EdgeInsets())
42
46
  }
43
47
  // Full screen like RN's Modal, so asymmetric landscape insets don't shift the content sideways.
44
48
  .frame(maxWidth: .infinity, maxHeight: .infinity)