@momo-kits/native-kits 0.168.1-beta.9-debug → 0.168.1-sizescale.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/CLAUDE.md +4 -2
  2. package/ios/Application/DesignSystemKits.swift +64 -1
  3. package/ios/Extensions/Color++.swift +2 -4
  4. package/ios/Layout/ScreenLayout.swift +23 -3
  5. package/ios/Navigation/BottomSheet.swift +153 -23
  6. package/ios/Navigation/Component/SnackBar.swift +4 -0
  7. package/ios/Navigation/ModalScreen.swift +7 -3
  8. package/ios/Popup/PopupDisplay.swift +2 -2
  9. package/ios/Popup/PopupInput.swift +2 -2
  10. package/ios/Popup/PopupLayout.swift +15 -8
  11. package/ios/Popup/PopupNotify.swift +2 -2
  12. package/ios/Typography/FontScale.swift +15 -4
  13. package/ios/native-kits.podspec +1 -1
  14. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  15. package/package.json +1 -1
  16. package/.claude/settings.local.json +0 -185
  17. package/build.gradle.kts +0 -11
  18. package/compose/build.gradle.kts +0 -198
  19. package/compose/build.gradle.kts.backup +0 -198
  20. package/compose/compose.podspec +0 -47
  21. package/compose/src/androidMain/kotlin/vn/momo/kits/const/HostFont.android.kt +0 -20
  22. package/compose/src/androidMain/kotlin/vn/momo/kits/navigation/ScrollToTop.android.kt +0 -6
  23. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/BackGesture.android.kt +0 -12
  24. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/OsFontScale.android.kt +0 -7
  25. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +0 -153
  26. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +0 -6
  27. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +0 -110
  28. package/compose/src/commonMain/kotlin/vn/momo/kits/application/DesignSystemKits.kt +0 -62
  29. package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +0 -1071
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Localize.kt +0 -269
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/application/WidgetContainer.kt +0 -56
  32. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Avatar.kt +0 -157
  33. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +0 -81
  34. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeDot.kt +0 -32
  35. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeRibbon.kt +0 -338
  36. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BaselineView.kt +0 -175
  37. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +0 -357
  38. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +0 -115
  39. package/compose/src/commonMain/kotlin/vn/momo/kits/components/CheckBox.kt +0 -89
  40. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +0 -128
  41. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Collapse.kt +0 -209
  42. package/compose/src/commonMain/kotlin/vn/momo/kits/components/CupertinoOverscroll.kt +0 -542
  43. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +0 -49
  44. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +0 -86
  45. package/compose/src/commonMain/kotlin/vn/momo/kits/components/IconButton.kt +0 -125
  46. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Image.kt +0 -231
  47. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +0 -189
  48. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +0 -478
  49. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputDropDown.kt +0 -114
  50. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +0 -265
  51. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +0 -235
  52. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +0 -223
  53. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +0 -265
  54. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +0 -185
  55. package/compose/src/commonMain/kotlin/vn/momo/kits/components/LazyColumnWithBouncing.kt +0 -343
  56. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Loader.kt +0 -108
  57. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationDot.kt +0 -56
  58. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationNumber.kt +0 -41
  59. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationScroll.kt +0 -84
  60. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationWhiteDot.kt +0 -40
  61. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +0 -361
  62. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +0 -108
  63. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +0 -308
  64. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +0 -59
  65. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Rating.kt +0 -87
  66. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ScaleSizeScope.kt +0 -21
  67. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Skeleton.kt +0 -90
  68. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +0 -330
  69. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Stepper.kt +0 -220
  70. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Steps.kt +0 -473
  71. package/compose/src/commonMain/kotlin/vn/momo/kits/components/SuggestAction.kt +0 -122
  72. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +0 -207
  73. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +0 -92
  74. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +0 -560
  75. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +0 -86
  76. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +0 -125
  77. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Title.kt +0 -207
  78. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +0 -501
  79. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TrustBanner.kt +0 -176
  80. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Uploader.kt +0 -182
  81. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePicker.kt +0 -193
  82. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerTypes.kt +0 -29
  83. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +0 -236
  84. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +0 -180
  85. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Colors.kt +0 -325
  86. package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +0 -17
  87. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Radius.kt +0 -12
  88. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Spacing.kt +0 -16
  89. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Theme.kt +0 -194
  90. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +0 -287
  91. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Card.kt +0 -54
  92. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +0 -50
  93. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +0 -306
  94. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Section.kt +0 -41
  95. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/AutomationId.kt +0 -52
  96. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Clickable.kt +0 -66
  97. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Conditional.kt +0 -11
  98. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/DeprecatedModifier.kt +0 -104
  99. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Shadow.kt +0 -48
  100. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Size.kt +0 -51
  101. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +0 -338
  102. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/HostBackGestureBridge.kt +0 -58
  103. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +0 -137
  104. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +0 -116
  105. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +0 -205
  106. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +0 -399
  107. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ScrollToTop.kt +0 -8
  108. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +0 -732
  109. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackTransition.kt +0 -148
  110. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/SwipeBackHaptic.kt +0 -35
  111. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +0 -228
  112. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +0 -217
  113. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/CurvedContainer.kt +0 -86
  114. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +0 -97
  115. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +0 -377
  116. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/FloatingButton.kt +0 -169
  117. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +0 -455
  118. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +0 -105
  119. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +0 -106
  120. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +0 -74
  121. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +0 -399
  122. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +0 -39
  123. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +0 -363
  124. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +0 -192
  125. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +0 -133
  126. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/tracking/ScreenTracker.kt +0 -127
  127. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/BackGesture.kt +0 -12
  128. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ComposeLottieAnimation.kt +0 -91
  129. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/OsFontScale.kt +0 -9
  130. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +0 -74
  131. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +0 -20
  132. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Icons.kt +0 -1329
  133. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Resources.kt +0 -55
  134. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Tracking.kt +0 -15
  135. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +0 -118
  136. package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +0 -42
  137. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/HostBackGesture.ios.kt +0 -45
  138. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/ScrollToTop.ios.kt +0 -32
  139. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/BackGesture.ios.kt +0 -38
  140. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/OsFontScale.ios.kt +0 -66
  141. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +0 -217
  142. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +0 -138
  143. package/gradle/libs.versions.toml +0 -69
  144. package/gradle/wrapper/gradle-wrapper.jar +0 -0
  145. package/gradle/wrapper/gradle-wrapper.properties +0 -8
  146. package/gradle.properties +0 -26
  147. package/gradlew +0 -252
  148. package/gradlew.bat +0 -94
  149. package/ios-demo/README.md +0 -120
  150. package/local.properties +0 -8
  151. package/settings.gradle.kts +0 -64
package/CLAUDE.md CHANGED
@@ -71,7 +71,7 @@ Publishing is automated (`publish.sh` + GitLab CI on `[ci build]` / `main`). Do
71
71
  ## Screen layout / iPhone Duo (port of RN `useScreenLayout`, momo-kits !664)
72
72
 
73
73
  - `layout/ScreenLayout.kt`: `getScreenLayout()` returns window size, raw top/bottom + side insets, `contentWidth`, header geometry, the iPhone Duo `sideBar` and `duoTopInset`. `NavigationContainer` computes it once and provides `LocalScreenLayout`; reactive to fold/unfold and rotation (window size + `WindowInsets`). Metrics (`DuoMetrics`, 72pt iPhone header floor, 20pt status-bar threshold) mirror RN `Layout/utils.ts` — change both together.
74
- - iPhone-only: everything gates on `isPhoneIdiom()` (`platform/`), never on "iOS", so iPad windows keep the regular layout. Two detectors, as in RN: `isIPhoneDuo` (window aspect < 1.6 or asymmetric side insets) drives layout; `isIPhoneDuoDevice` (Apple's foldable check: a `.division` reserved region, queried with include-inactive; Swift also latches on `UIHingeInteraction`) drives popups, modal and bottom sheet.
74
+ - iPhone-only: everything gates on `isPhoneIdiom()` (`platform/`), never on "iOS", so iPad windows keep the regular layout. Two detectors, as in RN: `isIPhoneDuo` (window aspect < 1.6 or asymmetric side insets) drives layout; `isIPhoneDuoDevice` (Apple's foldable check: a `.division` reserved region, queried with include-inactive; Swift also latches on `UIHingeInteraction`; or asymmetric side insets, since the outer display reports no hinge) drives popups, modal and bottom sheet.
75
75
  - Header height is `headerHeight` (not `AppStatusBar + HEADER_HEIGHT`); the bar row is `headerBarHeight` and starts `headerStatusBarHeight` below the window top. When the status bar is off the top edge (iPhone landscape, iPhone Duo) `headerStatusBarHeight` is 0 so the title stays centered — except on the Duo outer display: in portrait the header is 76pt and the row starts at 14pt so the title centers on the camera punchout; in landscape the row centers on the camera when it sits at the top end of the strip (else on the back button) and the header reaches 8pt below the back button.
76
76
  - iPhone Duo inner display in portrait (status bar on top): the header row shares the status bar band — `headerHeight` 94pt (2 × the clock digits' y 47), `headerStatusBarHeight` 0 — and `ScreenLayout.statusBarClearance` keeps it between the clock / battery clusters (from reserved regions, else 128pt on the right). Back and toolkit use the 48pt side bar controls laid out horizontally (`ToolkitLayout.SideBarRow`); the collapsed search and a centered title stay clear of the clusters. Port of momo-kits `90af33ba2`.
77
77
  - iPhone Duo with a side strip ≥ 60pt: back + toolkit leave the bar for `HeaderSideBar` (`navigation/component/SideBar.kt`, 48pt glass controls); custom header-right stays in the bar. `sideBar.top` is 170pt in portrait, 120pt on the inner display in landscape (height ≥ 600), 24pt on the outer display in landscape, or below the header when the status bar is on top; UIKit's `statusBarFrame` is useless on Duo. Unlike RN the column is clamped above footer (24pt gap) / rail (12pt gap).
@@ -80,8 +80,10 @@ Publishing is automated (`publish.sh` + GitLab CI on `[ci build]` / `main`). Do
80
80
  - Bar compression (`bottomtab/DuoBarCompression.kt`, HIG "Vertical controls"): when toolbar + rail don't fit in the strip, one is compressed — the toolkit into a `navigation_menu` button, the rail into one button showing the current tab — and tapping it swaps them; `BottomTab(compressionBehavior)` picks which starts compressed. If neither fits beside the other compressed, both rest compressed and a tap opens one for a moment. Side bar columns report their full height (0 when the screen puts nothing there) so the decision follows each screen's toolkit.
81
81
  - Glass header: on Duo with a side bar, Default/Extended headers without search and with a `ScrollState` let content scroll under the bar (`usesDuoGlassHeader`); the bar fades in a translucent surface + hairline over 16pt (Compose has no backdrop blur — RN's fallback look), the gradient leaves with the content.
82
82
  - Quick return header: glass headers and Animated headers (with a `ScrollState`) on the Duo side bar layout slide out with the content and back in on a reverse scroll; a half-shown header settles in or out once the scroll stops. The side bar is drawn apart from the header, so it stays put.
83
- - Overlays on Duo: popups cap at 440pt wide and the room between `duoTopInset` and the bottom inset (body scrolls, actions stay); modal centers inside that room; bottom sheet uses the window height, centers at width ≥ 600 and pads by side insets in landscape.
83
+ - Overlays on Duo: the modal sits inside the safe area (all four insets, so clear of the side toolbar strip) and caps its content at `duoModalMaxWidth` / `duoModalMaxHeight` (440 × 640pt, at least XL off the safe area); popups fill that width with radius XL and drop their own side padding (body scrolls, actions stay). Snackbars keep clear of the strip.
84
+ - Bottom sheet on Duo: radius XL, window height less `duoTopInset` (it never passes the side status bar: unlike RN, nothing restyles the clock over it), centered when the short side ≥ 600 (inner display in either orientation, buttons stay in the header row; RN parity). Below that (outer display, landscape too) it floats `Spacing.M` off the side edges, pads header/content/footer by `inset − M`, and a `Title` header moves back / close into 48pt side bar buttons on the strip part of the sheet (HIG: sheet controls go vertical on the outer display) when the strip fits one; they follow the header row while the sheet is short and stay below `sideBar.top` when it is tall. `BottomHeader.Title(onBack)` / `.title(onBack:)` closes the sheet, then runs (RN `leftOptions.onPressIconLeft`).
84
85
  - Font scale deliberately uses the short side (`min(w, h)`), unlike RN which reads the window width — landscape must not inflate every font.
86
+ - Font scale measures the **window**, never `UIScreen.main`: on iPhone Duo the main screen stays on one display while the window follows the one in use. Compose iOS `getScreenDimensions()` reads `LocalWindowInfo.containerSize` (recomposes on fold/unfold); SwiftUI `DesignSystemKits` KVO-observes each `UIWindowScene.effectiveGeometry` and republishes `windowShortSide`.
85
87
  - `StackScreen` pads content/footer/search/FAB by the side insets and provides an inset-consumed layout to its content; a bottom-tab root stays full width (tab bar + each tab's StackScreen handle insets). Grid columns use `contentWidth`.
86
88
 
87
89
  ## i18n / Localize (port of RN `Localize`)
@@ -36,6 +36,10 @@ public final class DesignSystemKits: ObservableObject {
36
36
 
37
37
  @Published private var configs = DesignSystemKitsConfigs()
38
38
  @Published public private(set) var osFontScale: CGFloat = DesignSystemKits.currentOsFontScale()
39
+ // AI-GENERATED START: republish the font scale when the window scene is resized (iPhone Duo fold/unfold)
40
+ @Published public private(set) var windowShortSide: CGFloat = DesignSystemKits.currentWindowShortSide()
41
+ private var geometryObservations: [ObjectIdentifier: NSKeyValueObservation] = [:]
42
+ // AI-GENERATED END: republish the font scale when the window scene is resized (iPhone Duo fold/unfold)
39
43
 
40
44
  private init() {
41
45
  NotificationCenter.default.addObserver(
@@ -44,6 +48,19 @@ public final class DesignSystemKits: ObservableObject {
44
48
  name: UIContentSizeCategory.didChangeNotification,
45
49
  object: nil
46
50
  )
51
+ // AI-GENERATED START: republish the font scale when the window scene is resized (iPhone Duo fold/unfold)
52
+ NotificationCenter.default.addObserver(
53
+ self,
54
+ selector: #selector(sceneDidActivate(_:)),
55
+ name: UIScene.didActivateNotification,
56
+ object: nil
57
+ )
58
+ onMain {
59
+ UIApplication.shared.connectedScenes
60
+ .compactMap { $0 as? UIWindowScene }
61
+ .forEach(self.observeGeometry(of:))
62
+ }
63
+ // AI-GENERATED END: republish the font scale when the window scene is resized (iPhone Duo fold/unfold)
47
64
  }
48
65
 
49
66
  public func setConfigs(_ configs: DesignSystemKitsConfigs) {
@@ -62,7 +79,10 @@ public final class DesignSystemKits: ObservableObject {
62
79
  FontScale(
63
80
  userScaleRate: configs.userScaleRate,
64
81
  useOSScaleRate: configs.useOSScaleRate,
65
- osFontScale: osFontScale
82
+ osFontScale: osFontScale,
83
+ // AI-GENERATED START: republish the font scale when the window scene is resized (iPhone Duo fold/unfold)
84
+ windowShortSide: windowShortSide
85
+ // AI-GENERATED END: republish the font scale when the window scene is resized (iPhone Duo fold/unfold)
66
86
  )
67
87
  }
68
88
 
@@ -72,6 +92,49 @@ public final class DesignSystemKits: ObservableObject {
72
92
  osFontScale = next
73
93
  }
74
94
 
95
+ // AI-GENERATED START: republish the font scale when the window scene is resized (iPhone Duo fold/unfold)
96
+ @objc private func sceneDidActivate(_ notification: Notification) {
97
+ guard let scene = notification.object as? UIWindowScene else { return }
98
+ observeGeometry(of: scene)
99
+ refreshWindowShortSide()
100
+ }
101
+
102
+ private func observeGeometry(of scene: UIWindowScene) {
103
+ guard #available(iOS 16.0, *) else { return }
104
+ let key = ObjectIdentifier(scene)
105
+ guard geometryObservations[key] == nil else { return }
106
+ geometryObservations[key] = scene.observe(\.effectiveGeometry, options: [.new]) { [weak self] _, _ in
107
+ // The window is resized after the scene geometry, so read it on the next turn.
108
+ DispatchQueue.main.async { self?.refreshWindowShortSide() }
109
+ }
110
+ }
111
+
112
+ private func refreshWindowShortSide() {
113
+ let next = Self.currentWindowShortSide()
114
+ guard next > 0, next != windowShortSide else { return }
115
+ windowShortSide = next
116
+ }
117
+
118
+ /// UIScreen.main stays on one display on iPhone Duo, so measure the foreground window instead.
119
+ static func currentWindowShortSide() -> CGFloat {
120
+ guard Thread.isMainThread else {
121
+ return min(UIScreen.main.bounds.width, UIScreen.main.bounds.height)
122
+ }
123
+ let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
124
+ let scene = scenes.first { $0.activationState == .foregroundActive } ?? scenes.first
125
+ let window = scene?.windows.first { $0.isKeyWindow } ?? scene?.windows.first
126
+ let size: CGSize
127
+ if let window, window.bounds.width > 0 {
128
+ size = window.bounds.size
129
+ } else if let scene {
130
+ size = scene.coordinateSpace.bounds.size
131
+ } else {
132
+ size = UIScreen.main.bounds.size
133
+ }
134
+ return min(size.width, size.height)
135
+ }
136
+ // AI-GENERATED END: republish the font scale when the window scene is resized (iPhone Duo fold/unfold)
137
+
75
138
  /// Same factor Compose's iOS `ProvideOsFontScale` derives from
76
139
  /// `UIFont.preferredFont(forTextStyle: .body).pointSize / 17`, so both iOS surfaces scale alike.
77
140
  static func currentOsFontScale() -> CGFloat {
@@ -4,16 +4,14 @@ import Foundation
4
4
  private final class MoMoUIKitsBundleToken {}
5
5
 
6
6
  enum MoMoUIKitsResources {
7
+
7
8
  static let bundle: Bundle = {
8
9
  let bundleName = "MoMoUIKitsResources"
9
10
  let tokenBundle = Bundle(for: MoMoUIKitsBundleToken.self)
10
- let frameworkBundle = Bundle(identifier: "org.cocoapods.MoMoUIKits")
11
11
  let candidates = [
12
12
  Bundle.main.resourceURL,
13
13
  tokenBundle.resourceURL,
14
- tokenBundle.bundleURL,
15
- frameworkBundle?.resourceURL,
16
- frameworkBundle?.bundleURL
14
+ tokenBundle.bundleURL
17
15
  ]
18
16
 
19
17
  for candidate in candidates {
@@ -32,7 +32,8 @@ public struct ScreenLayout: Equatable {
32
32
  public let width: CGFloat
33
33
  public let height: CGFloat
34
34
  public let isIPhoneDuo: Bool
35
- /// The device has a hinge, folded or not, whatever the window size; drives popups, modal and bottom sheet.
35
+ /// iPhone Duo folded or not, whatever the window size (its hinge, or the outer display's one-sided strip);
36
+ /// drives popups, modal and bottom sheet.
36
37
  public let isDuoDevice: Bool
37
38
  public let insets: EdgeInsets
38
39
  public let insetLeft: CGFloat
@@ -75,6 +76,11 @@ enum DuoMetrics {
75
76
  static let floatingTabBarHeight: CGFloat = 62
76
77
  static let floatingTabBarMargin: CGFloat = 20
77
78
  static let floatingTabBarInsetOverlap: CGFloat = 13
79
+ // Width of a system alert on the inner display, in both orientations.
80
+ static let modalMaxWidth: CGFloat = 440
81
+ // Fits a PopupNotify with image, scrolled description and actions at the max width (RN
82
+ // Application/device.ts); taller content scrolls instead of filling the inner display.
83
+ static let modalMaxHeight: CGFloat = 640
78
84
  }
79
85
 
80
86
  enum HeaderMetrics {
@@ -178,8 +184,10 @@ extension ScreenLayout {
178
184
  )
179
185
  }
180
186
 
187
+ // the outer display reports no hinge; its one-sided strip still gives it away
181
188
  return ScreenLayout(
182
- width: width, height: height, isIPhoneDuo: true, isDuoDevice: isDuoDevice, insets: insets,
189
+ width: width, height: height, isIPhoneDuo: true,
190
+ isDuoDevice: isDuoDevice || insets.leading != insets.trailing, insets: insets,
183
191
  insetLeft: insets.leading, insetRight: insets.trailing,
184
192
  contentWidth: width - insets.leading - insets.trailing,
185
193
  headerHeight: headerHeight, headerStatusBarHeight: headerStatusBarHeight, sideBar: sideBar,
@@ -256,12 +264,24 @@ extension ScreenLayout {
256
264
  public var duoTopInset: CGFloat {
257
265
  max(insets.top, height > width ? max(insets.leading, insets.trailing) * 2 : 0)
258
266
  }
267
+
268
+ /// Room for a dialog on iPhone Duo: inside the safe area, so clear of the side toolbar strip, and
269
+ /// capped so it stays a dialog on the inner display.
270
+ var duoModalMaxWidth: CGFloat {
271
+ min(width - insets.leading - insets.trailing - Spacing.XL * 2, DuoMetrics.modalMaxWidth)
272
+ }
273
+
274
+ var duoModalMaxHeight: CGFloat {
275
+ min(height - insets.top - insets.bottom - Spacing.XL * 2, DuoMetrics.modalMaxHeight)
276
+ }
259
277
  }
260
278
 
261
279
  /// Snapshot for call sites without `\.screenLayout`; not reactive. `windowSize` is ignored,
262
280
  /// kept for host call sites.
263
281
  public func isIPhoneDuoDevice(windowSize: CGSize? = nil) -> Bool {
264
- isPhoneIdiom && FoldableDevice.isFoldable(measuredIn: keyWindow)
282
+ guard isPhoneIdiom else { return false }
283
+ let insets = keyWindow?.safeAreaInsets
284
+ return FoldableDevice.isFoldable(measuredIn: keyWindow) || insets.map { $0.left != $0.right } == true
265
285
  }
266
286
 
267
287
  /// Apple's foldable check (as the host's `SideTabRailView.isFoldableDevice`): only a device with a
@@ -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) {
@@ -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)
@@ -107,7 +107,7 @@ public struct PopupDisplay: View {
107
107
  }
108
108
  .fixedSize(horizontal: false, vertical: true)
109
109
  .background(theme.colors.background.surface)
110
- .clipShape(RoundedRectangle(cornerRadius: Radius.L))
110
+ .clipShape(RoundedRectangle(cornerRadius: layout.popupRadius()))
111
111
 
112
112
  if isShowCloseIcon {
113
113
  PopupCloseButton(action: onClose)
@@ -115,7 +115,7 @@ public struct PopupDisplay: View {
115
115
  }
116
116
  }
117
117
  .frame(maxWidth: layout.popupMaxWidth)
118
- .padding(.horizontal, 12)
118
+ .padding(.horizontal, layout.popupHorizontalPadding(12))
119
119
  .accessibilityElement(children: .contain)
120
120
  .accessibilityIdentifier("popup_notify")
121
121
  }
@@ -59,11 +59,11 @@ public struct PopupInput: View {
59
59
  .offset(x: 11, y: -11)
60
60
  }
61
61
  }
62
- .background(theme.colors.background.surface.cornerRadius(8))
62
+ .background(theme.colors.background.surface.cornerRadius(layout.popupRadius(8)))
63
63
  .shadow(color: .black.opacity(0.08), radius: 2, x: 0, y: 0)
64
64
  .shadow(color: .black.opacity(0.16), radius: 24, x: 0, y: 0)
65
65
  .frame(maxWidth: layout.popupMaxWidth)
66
- .padding(.horizontal, 40)
66
+ .padding(.horizontal, layout.popupHorizontalPadding(40))
67
67
  }
68
68
 
69
69
  // MARK: Internal
@@ -1,19 +1,26 @@
1
1
  import SDWebImageSwiftUI
2
2
  import SwiftUI
3
3
 
4
- // Width of a system alert on the iPhone Duo inner display, in both orientations (RN Popup/utils.ts).
5
- private let duoPopupMaxWidth: CGFloat = 440
6
-
7
4
  extension ScreenLayout {
8
- /// Card width cap (Compose `popupMaxWidth`); unbounded off iPhone Duo, where the popup's own padding sets the width.
5
+ /// Card width cap on iPhone Duo (the modal bounds, Compose `popupMaxWidth`); unbounded elsewhere,
6
+ /// where the popup's own padding sets the width.
9
7
  var popupMaxWidth: CGFloat {
10
- isDuoDevice ? duoPopupMaxWidth : .infinity
8
+ isDuoDevice ? duoModalMaxWidth : .infinity
9
+ }
10
+
11
+ /// The modal bounds already keep iPhone Duo popups off the edges, so their own side padding goes.
12
+ func popupHorizontalPadding(_ padding: CGFloat) -> CGFloat {
13
+ isDuoDevice ? 0 : padding
11
14
  }
12
15
 
13
- /// Room left for a popup on iPhone Duo, where landscape is too short for it; nil elsewhere.
16
+ /// Tallest a popup gets on iPhone Duo, landscape and the inner display included; nil elsewhere.
14
17
  var popupMaxHeight: CGFloat? {
15
- guard isDuoDevice else { return nil }
16
- return height - duoTopInset - insets.bottom - Spacing.XL * 2
18
+ isDuoDevice ? duoModalMaxHeight : nil
19
+ }
20
+
21
+ /// Rounder on iPhone Duo, closer to its system alerts.
22
+ func popupRadius(_ regular: CGFloat = Radius.L) -> CGFloat {
23
+ isDuoDevice ? Radius.XL : regular
17
24
  }
18
25
  }
19
26
 
@@ -105,7 +105,7 @@ public struct PopupNotify: View {
105
105
  .popupReadHeight($actionsHeight)
106
106
  }
107
107
  .background(theme.colors.background.surface)
108
- .clipShape(RoundedRectangle(cornerRadius: Radius.L))
108
+ .clipShape(RoundedRectangle(cornerRadius: layout.popupRadius()))
109
109
 
110
110
  if props.isShowCloseIcon {
111
111
  PopupCloseButton(action: props.onIconClose)
@@ -113,7 +113,7 @@ public struct PopupNotify: View {
113
113
  }
114
114
  }
115
115
  .frame(maxWidth: layout.popupMaxWidth)
116
- .padding(.horizontal, Spacing.M)
116
+ .padding(.horizontal, layout.popupHorizontalPadding(Spacing.M))
117
117
  .accessibilityElement(children: .contain)
118
118
  .accessibilityIdentifier("popup_notify")
119
119
  }