@momo-kits/native-kits 0.162.49-debug → 0.162.50-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.
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, centered at width ≥ 600 (inner display, buttons stay in the header row). Below that (outer display) 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`)
@@ -40,7 +40,7 @@ kotlin {
40
40
  }
41
41
 
42
42
  cocoapods {
43
- version = "0.162.49-debug"
43
+ version = "0.162.50-debug"
44
44
  summary = "IOS Shared module"
45
45
  homepage = "https://momo.vn"
46
46
  ios.deploymentTarget = "15.0"
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |spec|
2
2
  spec.name = 'compose'
3
- spec.version = '0.167.1-beta.22-debug'
3
+ spec.version = '0.168.1-beta.8-debug'
4
4
  spec.homepage = 'https://momo.vn'
5
5
  spec.source = { :http=> ''}
6
6
  spec.authors = ''
@@ -22,6 +22,8 @@ import vn.momo.kits.application.LocalLocalize
22
22
  import vn.momo.kits.const.*
23
23
  import vn.momo.kits.modifier.conditional
24
24
  import vn.momo.kits.modifier.setAutomationId
25
+ import vn.momo.kits.layout.duoModalMaxHeight
26
+ import vn.momo.kits.layout.duoModalMaxWidth
25
27
  import vn.momo.kits.layout.getScreenLayout
26
28
  import kotlin.math.min
27
29
 
@@ -36,22 +38,29 @@ enum class PopupActionDirection {
36
38
  AUTO
37
39
  }
38
40
 
39
- // Width of a system alert on the iPhone Duo inner display, in both orientations (RN Popup/utils.ts).
40
- private val DUO_POPUP_MAX_WIDTH = 440.dp
41
+ // Card width cap on iPhone Duo (the modal bounds, RN Popup/utils.ts); unbounded elsewhere.
42
+ @Composable
43
+ internal fun popupMaxWidth(): Dp {
44
+ val layout = getScreenLayout()
45
+ return if (layout.isIPhoneDuoDevice) layout.duoModalMaxWidth else Dp.Unspecified
46
+ }
41
47
 
42
- // Card width cap plus the popup's own side padding; unbounded off iPhone Duo.
48
+ // The modal bounds already keep iPhone Duo popups off the edges, so their own side padding goes.
43
49
  @Composable
44
- internal fun popupMaxWidth(horizontalPadding: Dp): Dp =
45
- if (getScreenLayout().isIPhoneDuoDevice) DUO_POPUP_MAX_WIDTH + horizontalPadding * 2 else Dp.Unspecified
50
+ internal fun popupHorizontalPadding(padding: Dp): Dp =
51
+ if (getScreenLayout().isIPhoneDuoDevice) 0.dp else padding
46
52
 
47
- // Room left for a popup on iPhone Duo, where landscape is too short for it; null elsewhere.
53
+ // Tallest a popup gets on iPhone Duo, landscape and the inner display included; null elsewhere.
48
54
  @Composable
49
55
  internal fun popupMaxHeight(): Dp? {
50
56
  val layout = getScreenLayout()
51
- if (!layout.isIPhoneDuoDevice) return null
52
- return layout.height - layout.duoTopInset - layout.insetBottom - Spacing.XL * 2
57
+ return if (layout.isIPhoneDuoDevice) layout.duoModalMaxHeight else null
53
58
  }
54
59
 
60
+ // Rounder on iPhone Duo, closer to its system alerts.
61
+ @Composable
62
+ internal fun popupRadius(): Dp = if (getScreenLayout().isIPhoneDuoDevice) Radius.XL else Radius.L
63
+
55
64
  private fun heightForLines(
56
65
  layout: TextLayoutResult,
57
66
  ): Float {
@@ -145,20 +154,19 @@ fun PopupNotify(
145
154
 
146
155
  val maxHeight = popupMaxHeight()
147
156
  val bodyScrollState = rememberScrollState()
157
+ val horizontalPadding = popupHorizontalPadding(12.dp)
158
+ val shape = RoundedCornerShape(popupRadius())
148
159
 
149
160
  Box(
150
161
  Modifier.setAutomationId("popup_notify"), contentAlignment = Alignment.TopEnd
151
162
  ) {
152
163
  Column(
153
164
  modifier = Modifier
154
- .widthIn(max = popupMaxWidth(horizontalPadding = 12.dp))
165
+ .widthIn(max = popupMaxWidth())
155
166
  .fillMaxWidth()
156
- .padding(horizontal = 12.dp)
157
- .clip(RoundedCornerShape(Radius.L))
158
- .background(
159
- AppTheme.current.colors.background.surface,
160
- RoundedCornerShape(Radius.L)
161
- )
167
+ .padding(horizontal = horizontalPadding)
168
+ .clip(shape)
169
+ .background(AppTheme.current.colors.background.surface, shape)
162
170
  .conditional(IsShowBaseLineDebug) {
163
171
  border(1.dp, Colors.blue_03)
164
172
  }
@@ -215,7 +223,8 @@ fun PopupNotify(
215
223
  Modifier
216
224
  .width(22.dp)
217
225
  .height(22.dp)
218
- .offset(x = -(1).dp, y = (-11).dp)
226
+ // centered on the card's top right corner
227
+ .offset(x = 11.dp - horizontalPadding, y = (-11).dp)
219
228
  .background(
220
229
  color = AppTheme.current.colors.text.default,
221
230
  shape = RoundedCornerShape(Radius.M)
@@ -32,15 +32,15 @@ fun PopupPromotion(
32
32
  onClose: (() -> Unit) = {},
33
33
  ) {
34
34
  val maxHeight = popupMaxHeight()
35
- var maxWidth = popupMaxWidth(horizontalPadding = Spacing.M)
35
+ var maxWidth = popupMaxWidth()
36
36
  if (maxHeight != null) {
37
37
  // fit the portrait image by height when iPhone Duo leaves too little room
38
- maxWidth = minOf(maxWidth, (maxHeight - CLOSE_ROW_HEIGHT) * ASPECT_RATIO + Spacing.M * 2)
38
+ maxWidth = minOf(maxWidth, (maxHeight - CLOSE_ROW_HEIGHT) * ASPECT_RATIO)
39
39
  }
40
40
 
41
41
  Column(Modifier
42
42
  .widthIn(max = maxWidth)
43
- .padding(horizontal = Spacing.M)
43
+ .padding(horizontal = popupHorizontalPadding(Spacing.M))
44
44
  .conditional(IsShowBaseLineDebug) {
45
45
  border(1.dp, Colors.blue_03)
46
46
  }
@@ -51,7 +51,8 @@ data class ScreenLayout(
51
51
  val headerStatusBarHeight: Dp,
52
52
  // iPhone Duo moves back and toolkit into a column in the side safe area; null while they stay in the bar.
53
53
  val sideBar: SideBarLayout? = null,
54
- // The device has a hinge, folded or not, whatever the window size; drives popups, modal and bottom sheet.
54
+ // iPhone Duo folded or not, whatever the window size (its hinge, or the outer display's one-sided strip);
55
+ // drives popups, modal and bottom sheet.
55
56
  val isIPhoneDuoDevice: Boolean = isIPhoneDuo,
56
57
  // Portrait iPhone Duo puts the status bar in the side strip, about twice as tall as the strip is wide.
57
58
  // Stored so it survives consumeHorizontalInsets().
@@ -90,6 +91,11 @@ internal object DuoMetrics {
90
91
  val floatingTabBarHeight = 62.dp
91
92
  val floatingTabBarMargin = 20.dp
92
93
  val floatingTabBarInsetOverlap = 13.dp
94
+ // Width of a system alert on the inner display, in both orientations.
95
+ val modalMaxWidth = 440.dp
96
+ // Fits a PopupNotify with image, scrolled description and actions at the max width (RN
97
+ // Application/device.ts); taller content scrolls instead of filling the inner display.
98
+ val modalMaxHeight = 640.dp
93
99
  }
94
100
 
95
101
  private val IPHONE_HEADER_MIN_HEIGHT = 72.dp
@@ -152,6 +158,14 @@ internal fun calculateScreenLayout(topInset: Dp, parent: ScreenLayout? = null):
152
158
  }
153
159
  }
154
160
 
161
+ // Room for a dialog on iPhone Duo: inside the safe area, so clear of the side toolbar strip, and
162
+ // capped so it stays a dialog on the inner display.
163
+ internal val ScreenLayout.duoModalMaxWidth: Dp
164
+ get() = minOf(width - insetLeft - insetRight - Spacing.XL * 2, DuoMetrics.modalMaxWidth)
165
+
166
+ internal val ScreenLayout.duoModalMaxHeight: Dp
167
+ get() = minOf(height - insetTop - insetBottom - Spacing.XL * 2, DuoMetrics.modalMaxHeight)
168
+
155
169
  // Content inside the safe-area padding: the insets are spent and the side bar belongs to the screen.
156
170
  internal fun ScreenLayout.consumeHorizontalInsets(): ScreenLayout =
157
171
  copy(width = contentWidth, insetLeft = 0.dp, insetRight = 0.dp, sideBar = null)
@@ -299,7 +313,8 @@ private fun screenLayoutOf(
299
313
  headerHeight = headerHeight,
300
314
  headerStatusBarHeight = headerStatusBarHeight,
301
315
  sideBar = sideBar,
302
- isIPhoneDuoDevice = isDuoDevice,
316
+ // the outer display reports no hinge; its one-sided strip still gives it away
317
+ isIPhoneDuoDevice = isDuoDevice || (isPhone && asymmetricInsets),
303
318
  duoTopInset = maxOf(topInset, if (height > width) sideInset * 2 else 0.dp),
304
319
  statusBarClearance = statusBarClearance,
305
320
  )
@@ -6,7 +6,9 @@ import androidx.compose.animation.core.tween
6
6
  import androidx.compose.foundation.background
7
7
  import androidx.compose.foundation.border
8
8
  import androidx.compose.foundation.gestures.detectDragGestures
9
+ import androidx.compose.foundation.layout.Arrangement
9
10
  import androidx.compose.foundation.layout.Box
11
+ import androidx.compose.foundation.layout.BoxScope
10
12
  import androidx.compose.foundation.layout.BoxWithConstraints
11
13
  import androidx.compose.foundation.layout.Column
12
14
  import androidx.compose.foundation.layout.Row
@@ -17,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
17
19
  import androidx.compose.foundation.layout.height
18
20
  import androidx.compose.foundation.layout.heightIn
19
21
  import androidx.compose.foundation.layout.offset
22
+ import androidx.compose.foundation.layout.PaddingValues
20
23
  import androidx.compose.foundation.layout.padding
21
24
  import androidx.compose.foundation.layout.size
22
25
  import androidx.compose.foundation.layout.width
@@ -25,15 +28,22 @@ import androidx.compose.foundation.shape.RoundedCornerShape
25
28
  import androidx.compose.runtime.Composable
26
29
  import androidx.compose.runtime.DisposableEffect
27
30
  import androidx.compose.runtime.LaunchedEffect
31
+ import androidx.compose.runtime.getValue
32
+ import androidx.compose.runtime.mutableStateOf
28
33
  import androidx.compose.runtime.remember
34
+ import androidx.compose.runtime.setValue
29
35
  import androidx.compose.runtime.rememberCoroutineScope
36
+ import androidx.compose.ui.AbsoluteAlignment
30
37
  import androidx.compose.ui.Alignment
31
38
  import androidx.compose.ui.Modifier
32
39
  import androidx.compose.ui.graphics.Brush
33
40
  import androidx.compose.ui.graphics.Color
34
41
  import androidx.compose.ui.input.pointer.pointerInput
42
+ import androidx.compose.ui.layout.layout
35
43
  import androidx.compose.ui.platform.LocalDensity
44
+ import androidx.compose.ui.unit.Dp
36
45
  import androidx.compose.ui.unit.IntOffset
46
+ import androidx.compose.ui.unit.constrainHeight
37
47
  import androidx.compose.ui.unit.dp
38
48
  import kotlinx.coroutines.launch
39
49
  import vn.momo.kits.components.Divider
@@ -52,11 +62,24 @@ import vn.momo.kits.modifier.conditional
52
62
  import vn.momo.kits.modifier.noFeedbackClickable
53
63
  import vn.momo.kits.platform.BackHandler
54
64
  import vn.momo.kits.platform.getScreenHeight
65
+ import vn.momo.kits.layout.DuoMetrics
66
+ import vn.momo.kits.layout.SideBarEdge
67
+ import vn.momo.kits.layout.SideBarLayout
55
68
  import vn.momo.kits.layout.getScreenLayout
69
+ import vn.momo.kits.modifier.setAutomationId
70
+ import vn.momo.kits.navigation.component.HeaderType
71
+ import vn.momo.kits.navigation.component.SideBarButton
72
+ import vn.momo.kits.navigation.component.getHeaderColor
56
73
 
57
74
  // iPhone Duo inner display: center the sheet instead of spanning the width (RN BottomSheet).
58
75
  private val DUO_REGULAR_MIN_WIDTH = 600.dp
59
76
 
77
+ private val INDICATOR_HEIGHT = 16.dp
78
+ private val HEADER_ROW_HEIGHT = 56.dp
79
+
80
+ // Side column controls line up with the header row while the sheet is short.
81
+ private val SIDE_COLUMN_OFFSET = INDICATOR_HEIGHT + (HEADER_ROW_HEIGHT - DuoMetrics.controlSize) / 2
82
+
60
83
  @Composable
61
84
  internal fun BottomSheet(
62
85
  content: @Composable () -> Unit,
@@ -71,6 +94,22 @@ internal fun BottomSheet(
71
94
  val layout = getScreenLayout()
72
95
  val isDuo = layout.isIPhoneDuoDevice
73
96
  val isDuoCentered = isDuo && layout.width >= DUO_REGULAR_MIN_WIDTH
97
+ // outer display: the sheet floats Spacing.M off the side edges like system sheets, its content
98
+ // stays in the safe area and its buttons move to the side bar column (HIG)
99
+ val isDuoFloating = isDuo && !isDuoCentered
100
+ val title = header as? BottomHeader.Title
101
+ val sideColumn = layout.sideBar?.takeIf {
102
+ isDuoFloating && title != null && it.width - Spacing.M >= DuoMetrics.controlSize + Spacing.M
103
+ }
104
+ // the floating sheet spans the side safe area, its content stays clear of it
105
+ val sidePadding = if (isDuoFloating) {
106
+ PaddingValues.Absolute(
107
+ left = (layout.insetLeft - Spacing.M).coerceAtLeast(0.dp),
108
+ right = (layout.insetRight - Spacing.M).coerceAtLeast(0.dp),
109
+ )
110
+ } else {
111
+ PaddingValues()
112
+ }
74
113
  // the screen is the outer display on iPhone Duo, even when unfolded
75
114
  val screenHeightDp = if (isDuo) layout.height else getScreenHeight()
76
115
  val screenHeightPx = with(LocalDensity.current) { screenHeightDp.toPx() }
@@ -96,6 +135,11 @@ internal fun BottomSheet(
96
135
  onDispose { }
97
136
  }
98
137
 
138
+ // a closing sheet keeps going: a second close, the open slide or a drag would cancel its animation
139
+ var isClosing by remember { mutableStateOf(false) }
140
+ // runs once the sheet has closed itself, after its onDismiss
141
+ var afterDismiss by remember { mutableStateOf<(() -> Unit)?>(null) }
142
+
99
143
  suspend fun openEvent(){
100
144
  backgroundAlpha.animateTo(
101
145
  targetValue = 0.3f,
@@ -104,6 +148,7 @@ internal fun BottomSheet(
104
148
  easing = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)
105
149
  )
106
150
  )
151
+ if (isClosing) return
107
152
  sheetOffset.animateTo(
108
153
  targetValue = 0f,
109
154
  animationSpec = tween(
@@ -113,7 +158,9 @@ internal fun BottomSheet(
113
158
  )
114
159
  }
115
160
 
116
- fun closeEvent(){
161
+ fun closeEvent(then: (() -> Unit)? = null){
162
+ if (isClosing) return
163
+ isClosing = true
117
164
  coroutineScope.launch {
118
165
  sheetOffset.animateTo(
119
166
  targetValue = screenHeightPx,
@@ -129,10 +176,15 @@ internal fun BottomSheet(
129
176
  easing = CubicBezierEasing(0.3f, 0.0f, 0.8f, 0.15f)
130
177
  )
131
178
  )
179
+ // not reached when a newer overlay replaced this one, so its back is dropped
180
+ afterDismiss = then
132
181
  OverplayComponentRegistry.hardClearAfterDismiss()
133
182
  }
134
183
  }
135
184
 
185
+ // RN `leftOptions.onPressIconLeft`: the sheet closes first, then the back runs
186
+ val onBack = title?.onBack?.let { back -> { closeEvent(then = back) } }
187
+
136
188
  LaunchedEffect(Unit) {
137
189
  openEvent()
138
190
  }
@@ -141,6 +193,7 @@ internal fun BottomSheet(
141
193
  OverplayComponentRegistry.bindClose { closeEvent() }
142
194
  onDispose {
143
195
  onDismiss?.invoke()
196
+ afterDismiss?.invoke()
144
197
  }
145
198
  }
146
199
 
@@ -158,34 +211,34 @@ internal fun BottomSheet(
158
211
  ) {
159
212
  val parentHeight = with(LocalDensity.current) { constraints.maxHeight.toDp() }
160
213
  val maxHeight = if (isDuo) {
161
- parentHeight - maxOf(layout.duoTopInset, Spacing.XL)
214
+ // with its buttons in the side column the sheet can pass the side status bar
215
+ parentHeight - maxOf(if (sideColumn != null) layout.insetTop else layout.duoTopInset, Spacing.XL)
162
216
  } else {
163
217
  parentHeight - 90.dp
164
218
  }
165
- // landscape side buttons span the full height; in portrait the side status bar only covers the top
166
- val sideInset = isDuo && !isDuoCentered && layout.width > layout.height
219
+ val radius = if (isDuo) Radius.XL else Radius.M
167
220
 
168
221
  Box(
169
222
  modifier = Modifier
170
223
  .align(Alignment.BottomCenter)
171
224
  .offset { IntOffset(x = 0, y = sheetOffset.value.toInt()) }
172
225
  .then(
173
- if (isDuoCentered) {
226
+ when {
174
227
  // keeps the portrait width in landscape, like system sheets
175
- Modifier.width(
228
+ isDuoCentered -> Modifier.width(
176
229
  minOf(
177
230
  minOf(layout.width, layout.height) - Spacing.L * 2,
178
231
  layout.width - maxOf(layout.insetLeft, layout.insetRight, Spacing.L) * 2,
179
232
  )
180
233
  )
181
- } else {
182
- Modifier.fillMaxWidth()
234
+ isDuoFloating -> Modifier.fillMaxWidth().padding(horizontal = Spacing.M)
235
+ else -> Modifier.fillMaxWidth()
183
236
  }
184
237
  )
185
238
  .heightIn(max = maxHeight)
186
239
  .background(
187
240
  color = if (isSurface) AppTheme.current.colors.background.surface else AppTheme.current.colors.background.default,
188
- shape = RoundedCornerShape(topStart = Radius.M, topEnd = Radius.M)
241
+ shape = RoundedCornerShape(topStart = radius, topEnd = radius)
189
242
  )
190
243
  .conditional(IsShowBaseLineDebug) {
191
244
  border(1.dp, Colors.blue_03)
@@ -196,28 +249,24 @@ internal fun BottomSheet(
196
249
  val bottomInset = if (useBottomInset) {
197
250
  (AppNavigationBar.current - keyboardSizeState().value).coerceAtLeast(0.dp)
198
251
  } else 0.dp
199
- Column(
200
- Modifier
201
- .padding(bottom = bottomInset)
202
- .conditional(sideInset) {
203
- absolutePadding(left = layout.insetLeft, right = layout.insetRight)
204
- }
205
- ) {
252
+ Column(Modifier.padding(bottom = bottomInset)) {
206
253
  Box(
207
254
  modifier = Modifier
208
- .height(72.dp)
255
+ .height(INDICATOR_HEIGHT + HEADER_ROW_HEIGHT)
209
256
  .fillMaxWidth()
210
257
  .conditional(draggable) {
211
258
  pointerInput(Unit) {
212
259
  detectDragGestures(
213
260
  onDrag = { change, dragAmount ->
214
261
  change.consume()
262
+ if (isClosing) return@detectDragGestures
215
263
  coroutineScope.launch {
216
264
  val newOffset = (sheetOffset.value + dragAmount.y).coerceAtLeast(0f)
217
265
  sheetOffset.snapTo(newOffset)
218
266
  }
219
267
  },
220
268
  onDragEnd = {
269
+ if (isClosing) return@detectDragGestures
221
270
  coroutineScope.launch {
222
271
  if (sheetOffset.value > sheetCloseOffset) {
223
272
  closeEvent()
@@ -233,6 +282,7 @@ internal fun BottomSheet(
233
282
  Column(
234
283
  modifier = Modifier
235
284
  .fillMaxSize()
285
+ .padding(sidePadding)
236
286
  .padding(horizontal = Spacing.M),
237
287
  horizontalAlignment = Alignment.CenterHorizontally
238
288
  ) {
@@ -247,29 +297,16 @@ internal fun BottomSheet(
247
297
  )
248
298
  )
249
299
  Spacer(Modifier.height(4.dp))
250
- Box(Modifier.fillMaxWidth().height(56.dp), contentAlignment = Alignment.Center){
300
+ Box(Modifier.fillMaxWidth().height(HEADER_ROW_HEIGHT), contentAlignment = Alignment.Center){
251
301
  when (header) {
252
- is BottomHeader.Title -> {
253
- Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
254
- Box(Modifier.size(24.dp))
255
- Box(Modifier.fillMaxHeight().weight(1f), contentAlignment = Alignment.Center){
256
- Text(
257
- text = header.data,
258
- color = Colors.black_17,
259
- style = Typography.headerDefaultBold
260
- )
261
- }
262
- Box(Modifier
263
- .size(24.dp)
264
- .noFeedbackClickable {
265
- closeEvent()
266
- },
267
- contentAlignment = Alignment.Center
268
- ){
269
- Icon(source = "navigation_close")
270
- }
271
- }
272
- }
302
+ is BottomHeader.Title -> BottomSheetTitle(
303
+ text = header.data,
304
+ backIcon = header.backIcon,
305
+ // the side column holds back and close, the row keeps only the title
306
+ showButtons = sideColumn == null,
307
+ onBack = onBack,
308
+ onClose = { closeEvent() },
309
+ )
273
310
  is BottomHeader.Custom -> {
274
311
  header.content()
275
312
  }
@@ -277,27 +314,141 @@ internal fun BottomSheet(
277
314
  }
278
315
  }
279
316
  }
280
- Divider()
317
+ Box(Modifier.padding(sidePadding)) {
318
+ Divider()
319
+ }
281
320
  if (footerComponent != null) {
282
- Box(Modifier.weight(1f, fill = false)) {
321
+ Box(Modifier.weight(1f, fill = false).padding(sidePadding)) {
283
322
  content()
284
323
  }
285
- BottomSheetFooter(footerComponent)
324
+ BottomSheetFooter(footerComponent, sidePadding)
286
325
  } else if (isDuo) {
287
326
  // content shrinks so a tall sheet keeps its header
288
- Box(Modifier.weight(1f, fill = false)) {
327
+ Box(Modifier.weight(1f, fill = false).padding(sidePadding)) {
289
328
  content()
290
329
  }
291
330
  } else {
292
331
  content()
293
332
  }
294
333
  }
334
+ if (sideColumn != null && title != null) {
335
+ BottomSheetSideColumn(
336
+ sideBar = sideColumn,
337
+ parentHeight = parentHeight,
338
+ backIcon = title.backIcon,
339
+ onBack = onBack,
340
+ onClose = { closeEvent() },
341
+ )
342
+ }
295
343
  }
296
344
  }
297
345
  }
298
346
 
299
347
  @Composable
300
- private fun BottomSheetFooter(footerComponent: @Composable () -> Unit) {
348
+ private fun BottomSheetTitle(
349
+ text: String,
350
+ backIcon: String,
351
+ showButtons: Boolean,
352
+ onBack: (() -> Unit)?,
353
+ onClose: () -> Unit,
354
+ ) {
355
+ Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
356
+ if (showButtons) {
357
+ if (onBack != null) {
358
+ BottomSheetHeaderButton(icon = backIcon, automationId = "btn_bottom_sheet_back", onClick = onBack)
359
+ } else {
360
+ Box(Modifier.size(24.dp))
361
+ }
362
+ }
363
+ Box(
364
+ Modifier
365
+ .fillMaxHeight()
366
+ .weight(1f)
367
+ .conditional(!showButtons) { padding(horizontal = Spacing.M) },
368
+ contentAlignment = Alignment.Center
369
+ ) {
370
+ Text(
371
+ text = text,
372
+ color = Colors.black_17,
373
+ style = Typography.headerDefaultBold
374
+ )
375
+ }
376
+ if (showButtons) {
377
+ BottomSheetHeaderButton(icon = "navigation_close", automationId = "btn_bottom_sheet_close", onClick = onClose)
378
+ }
379
+ }
380
+ }
381
+
382
+ @Composable
383
+ private fun BottomSheetHeaderButton(icon: String, automationId: String, onClick: () -> Unit) {
384
+ Box(
385
+ Modifier
386
+ .size(24.dp)
387
+ .noFeedbackClickable(onClick = onClick)
388
+ .setAutomationId(automationId),
389
+ contentAlignment = Alignment.Center
390
+ ) {
391
+ Icon(source = icon)
392
+ }
393
+ }
394
+
395
+ // iPhone Duo outer display: back and close sit on the part of the sheet under the side safe area,
396
+ // 12pt from the strip's inner edge like the screen side bar. They follow the header row while the
397
+ // sheet is short and stay below the status bar cluster while it is tall.
398
+ @Composable
399
+ private fun BoxScope.BottomSheetSideColumn(
400
+ sideBar: SideBarLayout,
401
+ parentHeight: Dp,
402
+ backIcon: String,
403
+ onBack: (() -> Unit)?,
404
+ onClose: () -> Unit,
405
+ ) {
406
+ val isRight = sideBar.edge == SideBarEdge.RIGHT
407
+ val headerColor = getHeaderColor(HeaderType.None, 1f, null, AppTheme.current.colors.text.default)
408
+
409
+ Box(Modifier.matchParentSize()) {
410
+ Column(
411
+ modifier = Modifier
412
+ .align(if (isRight) AbsoluteAlignment.TopRight else AbsoluteAlignment.TopLeft)
413
+ .width(sideBar.width - Spacing.M)
414
+ .layout { measurable, constraints ->
415
+ val placeable = measurable.measure(constraints.copy(minHeight = 0))
416
+ val sheetHeight = constraints.maxHeight
417
+ // the sheet rests on the bottom of the overlay, which starts at the window top
418
+ val sheetTop = parentHeight.roundToPx() - sheetHeight
419
+ // clear of a camera at the bottom end of the strip when it can
420
+ val lowest = sheetHeight - sideBar.bottom.roundToPx() - placeable.height
421
+ val y = maxOf(sideBar.top.roundToPx() - sheetTop, SIDE_COLUMN_OFFSET.roundToPx())
422
+ .coerceAtMost(lowest)
423
+ .coerceAtLeast(0)
424
+ layout(placeable.width, constraints.constrainHeight(y + placeable.height)) {
425
+ placeable.place(0, y)
426
+ }
427
+ }
428
+ .absolutePadding(left = if (isRight) Spacing.M else 0.dp, right = if (isRight) 0.dp else Spacing.M),
429
+ horizontalAlignment = if (isRight) AbsoluteAlignment.Left else AbsoluteAlignment.Right,
430
+ verticalArrangement = Arrangement.spacedBy(Spacing.M),
431
+ ) {
432
+ if (onBack != null) {
433
+ SideBarButton(
434
+ icon = backIcon,
435
+ headerColor = headerColor,
436
+ automationId = "btn_bottom_sheet_back",
437
+ onClick = onBack,
438
+ )
439
+ }
440
+ SideBarButton(
441
+ icon = "navigation_close",
442
+ headerColor = headerColor,
443
+ automationId = "btn_bottom_sheet_close",
444
+ onClick = onClose,
445
+ )
446
+ }
447
+ }
448
+ }
449
+
450
+ @Composable
451
+ private fun BottomSheetFooter(footerComponent: @Composable () -> Unit, sidePadding: PaddingValues) {
301
452
  val shadowBrush = remember {
302
453
  Brush.verticalGradient(
303
454
  colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.05f))
@@ -312,6 +463,7 @@ private fun BottomSheetFooter(footerComponent: @Composable () -> Unit) {
312
463
  .conditional(IsShowBaseLineDebug) {
313
464
  border(1.dp, Colors.blue_03)
314
465
  }
466
+ .padding(sidePadding)
315
467
  .padding(vertical = Spacing.S)
316
468
  ) {
317
469
  footerComponent()
@@ -330,7 +482,19 @@ private fun BottomSheetFooter(footerComponent: @Composable () -> Unit) {
330
482
  sealed class BottomHeader {
331
483
  data class Title(
332
484
  val data: String = "Bottom Sheet Title",
333
- ) : BottomHeader()
485
+ ) : BottomHeader() {
486
+ // Outside the primary constructor so code built against older kits keeps Title() and copy().
487
+ // onBack shows a back button: the sheet closes (onDismiss fires), then it runs.
488
+ var onBack: (() -> Unit)? = null
489
+ private set
490
+ var backIcon: String = "arrow-back"
491
+ private set
492
+
493
+ constructor(data: String, onBack: (() -> Unit)?, backIcon: String = "arrow-back") : this(data) {
494
+ this.onBack = onBack
495
+ this.backIcon = backIcon
496
+ }
497
+ }
334
498
 
335
499
  data class Custom(
336
500
  val content: @Composable () -> Unit