@momo-kits/native-kits 0.167.1-duo.17-debug → 0.167.1-duo.18-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
@@ -69,6 +69,7 @@ Publishing is automated (`publish.sh` + GitLab CI on `[ci build]` / `main`). Do
69
69
  - `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.
70
70
  - 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` (short side of screen or window > 440, RN `device.ts`) drives popups, modal and bottom sheet.
71
71
  - 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.
72
+ - 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`.
72
73
  - 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).
73
74
  - Reserved regions (iOS 27.1 `reservedRegionsOfKind:`, reached through the ObjC runtime so older SDKs still build): a probe `UIView` (`platform/ReservedRegions.ios.kt` on the Compose root view; `ReservedRegionsProbe` inside SwiftUI `momoScreenLayout()`) re-reads them in `layoutSubviews`, which UIKit re-runs when they change. They push `sideBar.top` below the status cluster as it grows for calls / Live Activities, and `sideBar.bottom` (≥ 24pt) above a camera at the bottom end of the strip.
74
75
  - Tab bar on Duo is `bottomtab/DuoTabBar.kt`: a rail in the side strip (`sideBar.bottom` above the window bottom, QR below it) or, without a side bar, a floating bar spanning 6/8 grid columns. Both overlay the content; `duoFloatingTabBarInset` pads tab content, footer and FAB.
@@ -40,7 +40,7 @@ kotlin {
40
40
  }
41
41
 
42
42
  cocoapods {
43
- version = "0.167.1-duo.17-debug"
43
+ version = "0.167.1-duo.18-debug"
44
44
  summary = "IOS Shared module"
45
45
  homepage = "https://momo.vn"
46
46
  ios.deploymentTarget = "15.0"
@@ -32,6 +32,10 @@ data class SideBarLayout(
32
32
  val bottom: Dp,
33
33
  )
34
34
 
35
+ // Room the header row keeps from each side for the status bar clock and battery clusters.
36
+ @Immutable
37
+ data class StatusBarClearance(val left: Dp, val right: Dp)
38
+
35
39
  @Immutable
36
40
  data class ScreenLayout(
37
41
  val width: Dp,
@@ -53,6 +57,8 @@ data class ScreenLayout(
53
57
  // Portrait iPhone Duo puts the status bar in the side strip, about twice as tall as the strip is wide.
54
58
  // Stored so it survives consumeHorizontalInsets().
55
59
  val duoTopInset: Dp = insetTop,
60
+ // iPhone Duo inner display in portrait: the header row shares the status bar band, between its clusters.
61
+ val statusBarClearance: StatusBarClearance? = null,
56
62
  ) {
57
63
  val headerBarHeight: Dp get() = headerHeight - headerStatusBarHeight
58
64
  }
@@ -76,6 +82,10 @@ internal object DuoMetrics {
76
82
  // Outer display in landscape has no status bar in the strip: controls keep this far from
77
83
  // both ends unless a reserved region (the camera at either end) asks for more.
78
84
  val edgeInset = 24.dp
85
+ // Inner display in portrait: the clock and connectivity cluster sits at the top right corner
86
+ // (x 550–643 of 669, clock digits centered on y 47); its reserved region is taller than the glyphs.
87
+ val innerStatusBarCenterY = 47.dp
88
+ val innerStatusBarWidth = 120.dp
79
89
  }
80
90
 
81
91
  // Widest short side of any single-display iPhone.
@@ -99,9 +109,17 @@ internal fun calculateScreenLayout(topInset: Dp, parent: ScreenLayout? = null):
99
109
  if (parent != null) {
100
110
  // the parent's row center reproduces its own centered geometry
101
111
  val titleCenterY = parent.headerStatusBarHeight + parent.headerBarHeight / 2
102
- val (headerHeight, headerStatusBarHeight) =
112
+ val statusBarClearance = parent.statusBarClearance?.takeIf { topInset >= STATUS_BAR_MIN_INSET }
113
+ val (headerHeight, headerStatusBarHeight) = if (statusBarClearance != null) {
114
+ statusBarRowGeometry()
115
+ } else {
103
116
  headerGeometry(topInset, isPhone, parent.width, parent.height, parent.sideBar != null, titleCenterY)
104
- return parent.copy(headerHeight = headerHeight, headerStatusBarHeight = headerStatusBarHeight)
117
+ }
118
+ return parent.copy(
119
+ headerHeight = headerHeight,
120
+ headerStatusBarHeight = headerStatusBarHeight,
121
+ statusBarClearance = statusBarClearance,
122
+ )
105
123
  }
106
124
 
107
125
  val density = LocalDensity.current
@@ -166,6 +184,9 @@ private fun headerGeometry(
166
184
  return maxOf(headerHeight, IPHONE_HEADER_MIN_HEIGHT) to if (topInset < STATUS_BAR_MIN_INSET) 0.dp else topInset
167
185
  }
168
186
 
187
+ // Centered on the clock digits; the cluster's region center sits above them.
188
+ private fun statusBarRowGeometry(): Pair<Dp, Dp> = DuoMetrics.innerStatusBarCenterY * 2 to 0.dp
189
+
169
190
  private data class StripOcclusion(val top: Dp, val topCenter: Dp, val bottom: Dp)
170
191
 
171
192
  // How far system regions reach into a side strip from its top and from its bottom end
@@ -190,6 +211,22 @@ private fun stripOcclusion(regions: List<ReservedRegion>, stripLeft: Dp, stripRi
190
211
  return StripOcclusion(top, topCenter, bottom)
191
212
  }
192
213
 
214
+ // How far the clock and battery clusters of a status bar on the top edge reach in from each side;
215
+ // null without reserved regions there.
216
+ private fun statusBarClusters(regions: List<ReservedRegion>, windowWidth: Dp, topInset: Dp): StatusBarClearance? {
217
+ val midX = windowWidth / 2
218
+ val clusters = regions.filter {
219
+ it.y < topInset && it.y + it.height <= topInset && (it.x + it.width <= midX || it.x >= midX)
220
+ }
221
+ if (clusters.isEmpty()) return null
222
+ var left = 0.dp
223
+ var right = 0.dp
224
+ clusters.forEach {
225
+ if (it.x < midX) left = maxOf(left, it.x + it.width) else right = maxOf(right, windowWidth - it.x)
226
+ }
227
+ return StatusBarClearance(left, right)
228
+ }
229
+
193
230
  // Slab iPhones report equal side insets in landscape; iPhone Duo moves the status bar to one side.
194
231
  private fun isIPhoneDuoWindow(width: Dp, height: Dp, asymmetricInsets: Boolean): Boolean {
195
232
  val shortSide = minOf(width, height)
@@ -210,6 +247,13 @@ private fun screenLayoutOf(
210
247
  reservedRegions: List<ReservedRegion>,
211
248
  ): ScreenLayout {
212
249
  val isIPhoneDuo = isPhone && isIPhoneDuoWindow(width, height, asymmetricInsets)
250
+ val isInnerPortrait = height > width && width >= DuoMetrics.innerMinHeight
251
+ val statusBarClearance = if (isIPhoneDuo && isInnerPortrait && topInset >= STATUS_BAR_MIN_INSET) {
252
+ statusBarClusters(reservedRegions, width, topInset)
253
+ ?: StatusBarClearance(left = 0.dp, right = DuoMetrics.innerStatusBarWidth + Spacing.S)
254
+ } else {
255
+ null
256
+ }
213
257
 
214
258
  var sideBar: SideBarLayout? = null
215
259
  var titleCenterY = 0.dp
@@ -223,6 +267,7 @@ private fun screenLayoutOf(
223
267
  val occlusion = stripOcclusion(reservedRegions, stripLeft, stripLeft + sideInset, height)
224
268
  // UIKit reports no usable status bar frame here (a 2pt top strip), so the spot is inferred like RN
225
269
  val top = when {
270
+ statusBarClearance != null -> statusBarRowGeometry().first + Spacing.M
226
271
  topInset >= STATUS_BAR_MIN_INSET -> topInset + HEADER_HEIGHT.dp + Spacing.M
227
272
  width <= height -> DuoMetrics.firstItemTop
228
273
  height >= DuoMetrics.innerMinHeight -> DuoMetrics.firstItemTopLandscape
@@ -239,8 +284,11 @@ private fun screenLayoutOf(
239
284
  bottom = maxOf(DuoMetrics.edgeInset, occlusion.bottom),
240
285
  )
241
286
  }
242
- val (headerHeight, headerStatusBarHeight) =
287
+ val (headerHeight, headerStatusBarHeight) = if (statusBarClearance != null) {
288
+ statusBarRowGeometry()
289
+ } else {
243
290
  headerGeometry(topInset, isPhone, width, height, sideBar != null, titleCenterY)
291
+ }
244
292
 
245
293
  return ScreenLayout(
246
294
  width = width,
@@ -256,5 +304,6 @@ private fun screenLayoutOf(
256
304
  sideBar = sideBar,
257
305
  isIPhoneDuoDevice = isDuoDevice,
258
306
  duoTopInset = maxOf(topInset, if (height > width) sideInset * 2 else 0.dp),
307
+ statusBarClearance = statusBarClearance,
259
308
  )
260
309
  }
@@ -83,6 +83,7 @@ import vn.momo.kits.utils.horizontalSafeAreaPadding
83
83
  import vn.momo.kits.layout.LocalScreenLayout
84
84
  import vn.momo.kits.layout.ScreenLayout
85
85
  import vn.momo.kits.layout.consumeHorizontalInsets
86
+ import vn.momo.kits.layout.DuoMetrics
86
87
  import vn.momo.kits.layout.getScreenLayout
87
88
  import kotlin.time.Clock
88
89
  import kotlin.time.Duration.Companion.milliseconds
@@ -494,10 +495,14 @@ internal fun SearchAnimated(
494
495
  // collapsed, the search is centered in the bar row
495
496
  val minTopPadding = screenLayout.headerStatusBarHeight + (screenLayout.headerBarHeight - HEADER_HEIGHT.dp) / 2
496
497
  val maxTopPadding = screenLayout.headerHeight
498
+ // collapsed on the iPhone Duo status bar row, the search stays between its clusters (48pt back there)
499
+ val clearance = screenLayout.statusBarClearance
500
+ val backWidth = if (clearance != null && !useSideBar) DuoMetrics.controlSize else 28.dp
497
501
  val minStartPadding = Spacing.M
498
- val maxStartPadding = if (options.hiddenBack || useSideBar) Spacing.M else 48.dp // button back: size + left +right = 28 + 12 + 8 = 48
502
+ val maxStartPadding = (clearance?.left ?: 0.dp) +
503
+ if (options.hiddenBack || useSideBar) Spacing.M else backWidth + Spacing.M + Spacing.S
499
504
  val minEndPadding = Spacing.M
500
- val maxEndPadding = when {
505
+ val maxEndPadding = (clearance?.right ?: 0.dp) + when {
501
506
  options.headerRight is HeaderRight.None -> Spacing.M
502
507
  useSideBar && options.headerRight is HeaderRight.Toolkit -> Spacing.M
503
508
  else -> headerRightWidthDp + Spacing.M * 2
@@ -40,6 +40,7 @@ import vn.momo.kits.const.Colors
40
40
  import vn.momo.kits.components.PopupNotifyProps
41
41
  import vn.momo.kits.application.IsShowBaseLineDebug
42
42
  import vn.momo.kits.const.Spacing
43
+ import vn.momo.kits.layout.DuoMetrics
43
44
  import vn.momo.kits.layout.getScreenLayout
44
45
  import vn.momo.kits.modifier.activeOpacityClickable
45
46
  import vn.momo.kits.modifier.conditional
@@ -59,6 +60,9 @@ enum class TitlePosition { LEFT, CENTER }
59
60
 
60
61
  private val HEADER_ACTIONS_WIDTH = 172.dp
61
62
 
63
+ // Extra width the 48pt back and toolkit take on the iPhone Duo status bar row.
64
+ private val STATUS_BAR_ROW_ACTIONS_EXTRA = 55.dp
65
+
62
66
  @Suppress("FrequentlyChangingValue")
63
67
  @Composable
64
68
  internal fun headerOpacity(): Float {
@@ -82,8 +86,10 @@ internal fun headerOpacity(): Float {
82
86
  @Composable
83
87
  internal fun headerTitleMaxWidth(): Dp {
84
88
  val layout = getScreenLayout()
85
- if (layout.sideBar != null) return layout.contentWidth - Spacing.M * 2
86
- return layout.width - HEADER_ACTIONS_WIDTH
89
+ val clearance = layout.statusBarClearance
90
+ val clearanceWidth = clearance?.let { it.left + it.right } ?: 0.dp
91
+ if (layout.sideBar != null) return layout.contentWidth - Spacing.M * 2 - clearanceWidth
92
+ return layout.width - HEADER_ACTIONS_WIDTH - (if (clearance != null) STATUS_BAR_ROW_ACTIONS_EXTRA else 0.dp) - clearanceWidth
87
93
  }
88
94
 
89
95
  // Scroll distance over which the Duo glass bar fades in.
@@ -117,6 +123,9 @@ internal fun Header(onBackHandler: (() -> Unit)? = null) {
117
123
  val inputSearchType = getInputSearchType(options)
118
124
  val layout = getScreenLayout()
119
125
  val useSideBar = layout.sideBar != null
126
+ // iPhone Duo inner portrait: the row sits on the status bar, between its clusters
127
+ val clearance = layout.statusBarClearance
128
+ val statusBarRow = clearance != null && !useSideBar
120
129
  val opacity = headerOpacity()
121
130
 
122
131
  val headerColor = getHeaderColor(options.headerType, opacity, options.tintColor, Colors.black_17)
@@ -174,19 +183,31 @@ internal fun Header(onBackHandler: (() -> Unit)? = null) {
174
183
  modifier = Modifier.height(layout.headerBarHeight)
175
184
  .fillMaxWidth()
176
185
  .horizontalSafeAreaPadding()
177
- .padding(horizontal = Spacing.M),
186
+ .padding(
187
+ start = Spacing.M + (clearance?.left ?: 0.dp),
188
+ end = Spacing.M + (clearance?.right ?: 0.dp),
189
+ ),
178
190
  verticalAlignment = Alignment.CenterVertically,
179
191
  horizontalArrangement = Arrangement.SpaceBetween
180
192
  ) {
181
193
  if (!options.hiddenBack && !useSideBar) {
182
- BackButton(
183
- borderColor = headerColor.borderColor,
184
- backgroundButton = headerColor.backgroundButton,
185
- tintIconColor = headerColor.tintIconColor,
186
- onBackHandler = {
187
- navigator.onBackSafe { }
188
- }
189
- )
194
+ if (statusBarRow) {
195
+ SideBarButton(
196
+ icon = "arrow-back",
197
+ headerColor = headerColor,
198
+ automationId = "btn_navigation_back",
199
+ onClick = { navigator.onBackSafe { } },
200
+ )
201
+ } else {
202
+ BackButton(
203
+ borderColor = headerColor.borderColor,
204
+ backgroundButton = headerColor.backgroundButton,
205
+ tintIconColor = headerColor.tintIconColor,
206
+ onBackHandler = {
207
+ navigator.onBackSafe { }
208
+ }
209
+ )
210
+ }
190
211
  Spacer(Modifier.width(Spacing.S))
191
212
  }
192
213
 
@@ -203,23 +224,40 @@ internal fun Header(onBackHandler: (() -> Unit)? = null) {
203
224
  Box(Modifier.onGloballyPositioned {
204
225
  if(headerRightWidthPx.intValue != it.size.width) headerRightWidthPx.intValue = it.size.width
205
226
  }){
206
- if (!(useSideBar && options.headerRight is HeaderRight.Toolkit)) {
207
- HeaderRight(options.headerRight, options.tintColor, headerColor)
227
+ val headerRight = options.headerRight
228
+ when {
229
+ useSideBar && headerRight is HeaderRight.Toolkit -> Unit
230
+ statusBarRow && headerRight is HeaderRight.Toolkit -> Toolkit(
231
+ headerRight = headerRight,
232
+ tintColor = options.tintColor,
233
+ headerColor = headerColor,
234
+ layout = ToolkitLayout.SideBarRow,
235
+ )
236
+ else -> HeaderRight(headerRight, options.tintColor, headerColor)
208
237
  }
209
238
  }
210
239
  }
211
240
  if (centerTitle) {
241
+ // on the status bar row the clusters are lopsided, so both sides keep the wider one's room
242
+ val centerInset = if (clearance != null) {
243
+ val back = if (options.hiddenBack || useSideBar) 0.dp else DuoMetrics.controlSize + Spacing.S
244
+ val right = with(LocalDensity.current) { headerRightWidthPx.intValue.toDp() }
245
+ maxOf(clearance.left + back, clearance.right + right) + Spacing.S
246
+ } else {
247
+ 0.dp
248
+ }
212
249
  Box(
213
250
  modifier = Modifier.height(layout.headerBarHeight)
214
251
  .fillMaxWidth()
215
252
  .horizontalSafeAreaPadding()
216
- .padding(horizontal = Spacing.M),
253
+ .padding(horizontal = Spacing.M + centerInset),
217
254
  contentAlignment = Alignment.Center
218
255
  ) {
219
256
  HeaderContent(
220
257
  headerTitle = options.headerTitle,
221
258
  tintIconColor = titleColor,
222
259
  titlePosition = titlePosition,
260
+ centerWidthFraction = if (clearance != null) 1f else 0.5f,
223
261
  modifier = Modifier.fillMaxWidth()
224
262
  )
225
263
  }
@@ -253,6 +291,7 @@ internal fun HeaderContent(
253
291
  headerTitle: HeaderTitle,
254
292
  tintIconColor: Color,
255
293
  titlePosition: TitlePosition = TitlePosition.LEFT,
294
+ centerWidthFraction: Float = 0.5f,
256
295
  modifier: Modifier = Modifier,
257
296
  ){
258
297
  Box(
@@ -264,7 +303,7 @@ internal fun HeaderContent(
264
303
  HeaderTitle(
265
304
  title = headerTitle.title,
266
305
  color = tintIconColor,
267
- modifier = Modifier.fillMaxWidth(fraction = if (titlePosition == TitlePosition.CENTER) 0.5f else 1f),
306
+ modifier = Modifier.fillMaxWidth(fraction = if (titlePosition == TitlePosition.CENTER) centerWidthFraction else 1f),
268
307
  textAlign = if (titlePosition == TitlePosition.CENTER) TextAlign.Center else TextAlign.Start
269
308
  )
270
309
  }
@@ -79,12 +79,15 @@ internal fun toolkitColumnHeight(headerRight: HeaderRight.Toolkit): Dp {
79
79
  return shortcut + if (groupCount > 1) DuoMetrics.itemPitch * groupCount else DuoMetrics.controlSize
80
80
  }
81
81
 
82
+ // SideBarColumn: the iPhone Duo side bar; SideBarRow: the Duo header row on the status bar, same 48pt controls.
83
+ internal enum class ToolkitLayout { Bar, SideBarColumn, SideBarRow }
84
+
82
85
  @Composable
83
86
  internal fun Toolkit(
84
87
  headerRight: HeaderRight.Toolkit,
85
88
  tintColor: Color? = null,
86
89
  headerColor: HeaderColor,
87
- vertical: Boolean = false,
90
+ layout: ToolkitLayout = ToolkitLayout.Bar,
88
91
  ) {
89
92
  val api = LocalMaxApi.current
90
93
  val context = LocalContext.current
@@ -163,8 +166,9 @@ internal fun Toolkit(
163
166
  }
164
167
  }
165
168
 
166
- if (vertical) {
167
- Column(verticalArrangement = Arrangement.spacedBy(Spacing.M)) {
169
+ if (layout != ToolkitLayout.Bar) {
170
+ val horizontal = layout == ToolkitLayout.SideBarRow
171
+ val controls = @Composable {
168
172
  if (isShowShortcut) {
169
173
  SideBarButton(
170
174
  icon = icon,
@@ -174,13 +178,14 @@ internal fun Toolkit(
174
178
  onClick = navButtonConfig.onPress,
175
179
  )
176
180
  }
177
- SideBarGroup(headerColor = headerColor, itemCount = if (useHelpCenter) 2 else 1) {
181
+ SideBarGroup(headerColor = headerColor, itemCount = if (useHelpCenter) 2 else 1, horizontal = horizontal) { item ->
178
182
  if (useHelpCenter) {
179
183
  SideBarGroupItem(
180
184
  icon = "help_center",
181
185
  headerColor = headerColor,
182
186
  automationId = "btn_navigation_help_center",
183
187
  onClick = ::onPressHelpCenter,
188
+ modifier = item,
184
189
  )
185
190
  }
186
191
  SideBarGroupItem(
@@ -188,9 +193,17 @@ internal fun Toolkit(
188
193
  headerColor = headerColor,
189
194
  automationId = "btn_navigation_close",
190
195
  onClick = ::onPressClose,
196
+ modifier = item,
191
197
  )
192
198
  }
193
199
  }
200
+ if (horizontal) {
201
+ Row(horizontalArrangement = Arrangement.spacedBy(Spacing.M), verticalAlignment = Alignment.CenterVertically) {
202
+ controls()
203
+ }
204
+ } else {
205
+ Column(verticalArrangement = Arrangement.spacedBy(Spacing.M)) { controls() }
206
+ }
194
207
  return
195
208
  }
196
209
 
@@ -6,11 +6,9 @@ import androidx.compose.foundation.layout.Arrangement
6
6
  import androidx.compose.foundation.layout.Box
7
7
  import androidx.compose.foundation.layout.BoxScope
8
8
  import androidx.compose.foundation.layout.Column
9
- import androidx.compose.foundation.layout.ColumnScope
9
+ import androidx.compose.foundation.layout.Row
10
10
  import androidx.compose.foundation.layout.Spacer
11
11
  import androidx.compose.foundation.layout.absolutePadding
12
- import androidx.compose.foundation.layout.fillMaxWidth
13
- import androidx.compose.foundation.layout.height
14
12
  import androidx.compose.foundation.layout.offset
15
13
  import androidx.compose.foundation.layout.size
16
14
  import androidx.compose.foundation.layout.width
@@ -101,7 +99,12 @@ internal fun BoxScope.HeaderSideBar(bottomInset: Dp, modifier: Modifier = Modifi
101
99
  automationId = "btn_navigation_toolbar",
102
100
  onClick = compression.expandToolbar,
103
101
  )
104
- else -> Toolkit(headerRight = toolkit, tintColor = options.tintColor, headerColor = headerColor, vertical = true)
102
+ else -> Toolkit(
103
+ headerRight = toolkit,
104
+ tintColor = options.tintColor,
105
+ headerColor = headerColor,
106
+ layout = ToolkitLayout.SideBarColumn,
107
+ )
105
108
  }
106
109
  }
107
110
  }
@@ -135,34 +138,38 @@ internal fun SideBarButton(
135
138
  }
136
139
  }
137
140
 
138
- // Capsule grouping related controls, one item per 50pt.
141
+ // Capsule grouping related controls, one item per 50pt: vertical in the side bar, horizontal on a header row.
139
142
  @Composable
140
143
  internal fun SideBarGroup(
141
144
  headerColor: HeaderColor,
142
145
  itemCount: Int,
143
- content: @Composable ColumnScope.() -> Unit,
146
+ horizontal: Boolean = false,
147
+ content: @Composable (itemModifier: Modifier) -> Unit,
144
148
  ) {
145
- val height = if (itemCount > 1) DuoMetrics.itemPitch * itemCount else DuoMetrics.controlSize
146
- Column(
147
- modifier = Modifier
148
- .width(DuoMetrics.controlSize)
149
- .height(height)
150
- .sideBarGlass(headerColor, RoundedCornerShape(Radius.XL)),
151
- content = content,
152
- )
149
+ val length = if (itemCount > 1) DuoMetrics.itemPitch * itemCount else DuoMetrics.controlSize
150
+ val item = length / itemCount.coerceAtLeast(1)
151
+ val glass = Modifier.sideBarGlass(headerColor, RoundedCornerShape(Radius.XL))
152
+ if (horizontal) {
153
+ Row(Modifier.size(width = length, height = DuoMetrics.controlSize).then(glass)) {
154
+ content(Modifier.size(width = item, height = DuoMetrics.controlSize))
155
+ }
156
+ } else {
157
+ Column(Modifier.size(width = DuoMetrics.controlSize, height = length).then(glass)) {
158
+ content(Modifier.size(width = DuoMetrics.controlSize, height = item))
159
+ }
160
+ }
153
161
  }
154
162
 
155
163
  @Composable
156
- internal fun ColumnScope.SideBarGroupItem(
164
+ internal fun SideBarGroupItem(
157
165
  icon: String,
158
166
  headerColor: HeaderColor,
159
167
  automationId: String,
160
168
  onClick: () -> Unit,
169
+ modifier: Modifier = Modifier,
161
170
  ) {
162
171
  Box(
163
- modifier = Modifier
164
- .weight(1f)
165
- .fillMaxWidth()
172
+ modifier = modifier
166
173
  .activeOpacityClickable(onClick = onClick)
167
174
  .setAutomationId(automationId),
168
175
  contentAlignment = Alignment.Center,
@@ -22,6 +22,12 @@ public struct SideBarLayout: Equatable {
22
22
  public let bottom: CGFloat
23
23
  }
24
24
 
25
+ /// Room the header row keeps from each side for the status bar clock and battery clusters.
26
+ public struct StatusBarClearance: Equatable {
27
+ public let left: CGFloat
28
+ public let right: CGFloat
29
+ }
30
+
25
31
  public struct ScreenLayout: Equatable {
26
32
  public let width: CGFloat
27
33
  public let height: CGFloat
@@ -35,6 +41,8 @@ public struct ScreenLayout: Equatable {
35
41
  public let headerStatusBarHeight: CGFloat
36
42
  /// iPhone Duo moves back and toolkit into a column in the side safe area; nil while they stay in the bar.
37
43
  public let sideBar: SideBarLayout?
44
+ /// iPhone Duo inner display in portrait: the header row shares the status bar band, between its clusters.
45
+ public let statusBarClearance: StatusBarClearance?
38
46
 
39
47
  public var headerBarHeight: CGFloat { headerHeight - headerStatusBarHeight }
40
48
  }
@@ -56,6 +64,10 @@ enum DuoMetrics {
56
64
  // Outer display in landscape has no status bar in the strip: controls keep this far from
57
65
  // both ends unless a reserved region (the camera at either end) asks for more.
58
66
  static let edgeInset: CGFloat = 24
67
+ // Inner display in portrait: the clock and connectivity cluster sits at the top right corner
68
+ // (x 550–643 of 669, clock digits centered on y 47); its reserved region is taller than the glyphs.
69
+ static let innerStatusBarCenterY: CGFloat = 47
70
+ static let innerStatusBarWidth: CGFloat = 120
59
71
  // Widest short side of any single-display iPhone.
60
72
  static let iPhoneMaxShortSide: CGFloat = 440
61
73
  }
@@ -102,10 +114,21 @@ extension ScreenLayout {
102
114
  return ScreenLayout(
103
115
  width: width, height: height, isIPhoneDuo: false, insets: insets,
104
116
  insetLeft: 0, insetRight: 0, contentWidth: width,
105
- headerHeight: headerHeight, headerStatusBarHeight: headerStatusBarHeight, sideBar: nil
117
+ headerHeight: headerHeight, headerStatusBarHeight: headerStatusBarHeight, sideBar: nil,
118
+ statusBarClearance: nil
106
119
  )
107
120
  }
108
121
 
122
+ var statusBarClearance: StatusBarClearance?
123
+ let isInnerPortrait = height > width && width >= DuoMetrics.innerMinHeight
124
+ if isInnerPortrait && insets.top >= HeaderMetrics.statusBarMinInset {
125
+ statusBarClearance = statusBarClusters(reservedRegions, windowWidth: width, topInset: insets.top)
126
+ ?? StatusBarClearance(left: 0, right: DuoMetrics.innerStatusBarWidth + Spacing.S)
127
+ // centered on the clock digits; the cluster's region center sits above them
128
+ headerHeight = DuoMetrics.innerStatusBarCenterY * 2
129
+ headerStatusBarHeight = 0
130
+ }
131
+
109
132
  var sideBar: SideBarLayout?
110
133
  let sideInset = max(insets.leading, insets.trailing)
111
134
  // narrower strips cannot host a control 12pt from the inner edge
@@ -116,7 +139,9 @@ extension ScreenLayout {
116
139
  // Activities; region frames already include the system's margin
117
140
  let occlusion = stripOcclusion(reservedRegions, stripLeft: stripLeft, stripRight: stripLeft + sideInset, windowHeight: height)
118
141
 
119
- var top = insets.top + HeaderMetrics.barHeight + Spacing.M
142
+ var top = statusBarClearance != nil
143
+ ? headerHeight + Spacing.M
144
+ : insets.top + HeaderMetrics.barHeight + Spacing.M
120
145
  if insets.top < HeaderMetrics.statusBarMinInset {
121
146
  // status bar lives in the strip: start under its cluster, as the native bar does
122
147
  if width <= height {
@@ -150,7 +175,8 @@ extension ScreenLayout {
150
175
  width: width, height: height, isIPhoneDuo: true, insets: insets,
151
176
  insetLeft: insets.leading, insetRight: insets.trailing,
152
177
  contentWidth: width - insets.leading - insets.trailing,
153
- headerHeight: headerHeight, headerStatusBarHeight: headerStatusBarHeight, sideBar: sideBar
178
+ headerHeight: headerHeight, headerStatusBarHeight: headerStatusBarHeight, sideBar: sideBar,
179
+ statusBarClearance: statusBarClearance
154
180
  )
155
181
  }
156
182
 
@@ -192,6 +218,26 @@ extension ScreenLayout {
192
218
  }
193
219
  }
194
220
 
221
+ /// How far the clock and battery clusters of a status bar on the top edge reach in from each
222
+ /// side; nil without reserved regions there.
223
+ private static func statusBarClusters(_ regions: [CGRect], windowWidth: CGFloat, topInset: CGFloat) -> StatusBarClearance? {
224
+ let midX = windowWidth / 2
225
+ let clusters = regions.filter {
226
+ $0.minY < topInset && $0.maxY <= topInset && ($0.maxX <= midX || $0.minX >= midX)
227
+ }
228
+ guard !clusters.isEmpty else { return nil }
229
+ var left: CGFloat = 0
230
+ var right: CGFloat = 0
231
+ for region in clusters {
232
+ if region.minX < midX {
233
+ left = max(left, region.maxX)
234
+ } else {
235
+ right = max(right, windowWidth - region.minX)
236
+ }
237
+ }
238
+ return StatusBarClearance(left: left, right: right)
239
+ }
240
+
195
241
  // Slab iPhones report equal side insets in landscape; iPhone Duo moves the status bar to one side.
196
242
  private static func isIPhoneDuoWindow(width: CGFloat, height: CGFloat, insets: EdgeInsets) -> Bool {
197
243
  let shortSide = min(width, height)
@@ -13,6 +13,8 @@ public let HEADER_HEIGHT: CGFloat = 52
13
13
 
14
14
  /// Room the back button and toolkit take from the bar (Compose `HEADER_ACTIONS_WIDTH`).
15
15
  let headerActionsWidth: CGFloat = 172
16
+ // Extra width the 48pt back and toolkit take on the iPhone Duo status bar row.
17
+ private let statusBarRowActionsExtra: CGFloat = 55
16
18
 
17
19
  public enum TitlePosition {
18
20
  case left, center
@@ -282,7 +284,9 @@ func headerSearchProps(_ props: InputSearchProps, scroll: CGFloat, layout: Scree
282
284
 
283
285
  /// Max title width: back button and toolkit share the bar unless the iPhone Duo side bar holds them.
284
286
  func headerTitleMaxWidth(_ layout: ScreenLayout) -> CGFloat {
285
- layout.sideBar != nil ? layout.contentWidth - Spacing.M * 2 : layout.width - headerActionsWidth
287
+ let clearance = layout.statusBarClearance.map { $0.left + $0.right } ?? 0
288
+ if layout.sideBar != nil { return layout.contentWidth - Spacing.M * 2 - clearance }
289
+ return layout.width - headerActionsWidth - (layout.statusBarClearance != nil ? statusBarRowActionsExtra : 0) - clearance
286
290
  }
287
291
 
288
292
  // MARK: - Header
@@ -333,10 +337,22 @@ public struct NavigationHeader: View {
333
337
 
334
338
  @Environment(\.screenLayout) private var layout
335
339
  @Environment(\.appTheme) private var theme
340
+ @State private var headerRightWidth: CGFloat = 0
336
341
 
337
342
  /// iPhone Duo side bar holds back and the toolkit; a custom header right stays in the bar.
338
343
  private var useSideBar: Bool { layout.sideBar != nil }
339
344
 
345
+ /// iPhone Duo inner portrait: the row sits on the status bar, between its clusters, with 48pt controls.
346
+ private var statusBarRow: Bool { layout.statusBarClearance != nil && !useSideBar }
347
+
348
+ /// Max centered title width; on the status bar row the clusters are lopsided, so both sides keep the wider one's room.
349
+ private var centerTitleMaxWidth: CGFloat {
350
+ guard let clearance = layout.statusBarClearance else { return (layout.contentWidth - Spacing.M * 2) / 2 }
351
+ let back = goBack == nil || useSideBar ? 0 : DuoMetrics.controlSize + Spacing.S
352
+ let inset = max(clearance.left + back, clearance.right + headerRightWidth) + Spacing.S
353
+ return max(layout.contentWidth - (Spacing.M + inset) * 2, 0)
354
+ }
355
+
340
356
  private var headerColor: HeaderColor {
341
357
  getHeaderColor(
342
358
  isAnimated: animatedHeader != nil,
@@ -357,7 +373,16 @@ public struct NavigationHeader: View {
357
373
  ZStack {
358
374
  HStack(spacing: 0) {
359
375
  if let goBack, !useSideBar {
360
- BackButton(color: headerColor, onBack: goBack)
376
+ if statusBarRow {
377
+ SideBarButton(
378
+ icon: "arrow-back",
379
+ headerColor: headerColor,
380
+ accessibilityId: "btn_navigation_back",
381
+ action: goBack
382
+ )
383
+ } else {
384
+ BackButton(color: headerColor, onBack: goBack)
385
+ }
361
386
  Spacer().frame(width: Spacing.S)
362
387
  }
363
388
 
@@ -369,24 +394,36 @@ public struct NavigationHeader: View {
369
394
  }
370
395
 
371
396
  if let headerRight, !(useSideBar && toolkitHeaderRight(headerRight) != nil) {
372
- AnyView(headerRight())
373
- .environment(\.headerColor, headerColor)
374
- .background(
375
- GeometryReader { geo in
376
- Color.clear.preference(key: HeaderRightWidthKey.self, value: geo.size.width)
377
- }
378
- )
397
+ Group {
398
+ if statusBarRow, let toolkit = toolkitHeaderRight(headerRight) {
399
+ Toolkit(
400
+ headerRight: toolkit.data ?? HeaderRightData(),
401
+ tintColor: toolkit.tintColor,
402
+ layout: .sideBarRow
403
+ )
404
+ } else {
405
+ AnyView(headerRight())
406
+ }
407
+ }
408
+ .environment(\.headerColor, headerColor)
409
+ .background(
410
+ GeometryReader { geo in
411
+ Color.clear.preference(key: HeaderRightWidthKey.self, value: geo.size.width)
412
+ }
413
+ )
379
414
  }
380
415
  }
381
- .padding(.horizontal, Spacing.M)
416
+ .padding(.leading, Spacing.M + (layout.statusBarClearance?.left ?? 0))
417
+ .padding(.trailing, Spacing.M + (layout.statusBarClearance?.right ?? 0))
382
418
 
383
419
  if centerTitle {
384
420
  titleArea(centered: true)
385
421
  // Compose caps a centered title at half the bar
386
- .frame(maxWidth: (layout.contentWidth - Spacing.M * 2) / 2)
422
+ .frame(maxWidth: centerTitleMaxWidth)
387
423
  .padding(.horizontal, Spacing.M)
388
424
  }
389
425
  }
426
+ .onPreferenceChange(HeaderRightWidthKey.self) { headerRightWidth = $0 }
390
427
  .frame(height: layout.headerBarHeight)
391
428
  .overlay(alignment: .bottom) { VerticalShadow(opacity: opacity) }
392
429
  // the row starts headerStatusBarHeight below the window top: 0 with the status bar off
@@ -158,11 +158,15 @@ private struct NavigationButtonConfig {
158
158
  let onPress: () -> Void
159
159
  }
160
160
 
161
+ /// `sideBarColumn`: the iPhone Duo side bar; `sideBarRow`: the Duo header row on the status bar, same 48pt controls.
162
+ enum ToolkitLayout {
163
+ case bar, sideBarColumn, sideBarRow
164
+ }
165
+
161
166
  struct Toolkit: View {
162
167
  let headerRight: HeaderRightData
163
168
  var tintColor: Color?
164
- /// iPhone Duo side bar column instead of the bar row.
165
- var vertical: Bool = false
169
+ var layout: ToolkitLayout = .bar
166
170
 
167
171
  @State private var isFavorite = false
168
172
  @State private var isLoading = false
@@ -276,44 +280,46 @@ struct Toolkit: View {
276
280
 
277
281
  var body: some View {
278
282
  Group {
279
- if vertical {
280
- verticalBody
281
- } else {
283
+ switch layout {
284
+ case .bar:
282
285
  horizontalBody
286
+ case .sideBarColumn:
287
+ VStack(spacing: Spacing.M) { sideBarControls(horizontal: false) }
288
+ case .sideBarRow:
289
+ HStack(spacing: Spacing.M) { sideBarControls(horizontal: true) }
283
290
  }
284
291
  }
285
292
  .onAppear(perform: loadFavoriteState)
286
293
  }
287
294
 
288
- private var verticalBody: some View {
295
+ @ViewBuilder
296
+ private func sideBarControls(horizontal: Bool) -> some View {
289
297
  let config = navigationButtonConfig
290
298
  let useHelpCenter = usesHelpCenter(context)
291
- return VStack(spacing: Spacing.M) {
292
- if showsShortcut(headerRight, context) {
293
- SideBarButton(
294
- icon: config.icon,
295
- headerColor: headerColor,
296
- showBadge: headerRight.showBadge,
297
- disabled: isLoading,
298
- action: config.onPress
299
- )
300
- }
301
- SideBarGroup(headerColor: headerColor, itemCount: useHelpCenter ? 2 : 1) {
302
- if useHelpCenter {
303
- SideBarGroupItem(
304
- icon: "help_center",
305
- headerColor: headerColor,
306
- accessibilityId: "btn_navigation_help_center",
307
- action: onPressHelpCenter
308
- )
309
- }
299
+ if showsShortcut(headerRight, context) {
300
+ SideBarButton(
301
+ icon: config.icon,
302
+ headerColor: headerColor,
303
+ showBadge: headerRight.showBadge,
304
+ disabled: isLoading,
305
+ action: config.onPress
306
+ )
307
+ }
308
+ SideBarGroup(headerColor: headerColor, itemCount: useHelpCenter ? 2 : 1, horizontal: horizontal) {
309
+ if useHelpCenter {
310
310
  SideBarGroupItem(
311
- icon: headerRight.closeIcon,
311
+ icon: "help_center",
312
312
  headerColor: headerColor,
313
- accessibilityId: "btn_navigation_close",
314
- action: onPressClose
313
+ accessibilityId: "btn_navigation_help_center",
314
+ action: onPressHelpCenter
315
315
  )
316
316
  }
317
+ SideBarGroupItem(
318
+ icon: headerRight.closeIcon,
319
+ headerColor: headerColor,
320
+ accessibilityId: "btn_navigation_close",
321
+ action: onPressClose
322
+ )
317
323
  }
318
324
  }
319
325
 
@@ -70,7 +70,7 @@ struct HeaderSideBar: View {
70
70
  Toolkit(
71
71
  headerRight: toolkit.data ?? HeaderRightData(),
72
72
  tintColor: toolkit.tintColor,
73
- vertical: true
73
+ layout: .sideBarColumn
74
74
  )
75
75
  .environment(\.headerColor, headerColor)
76
76
  }
@@ -142,19 +142,26 @@ struct SideBarButton: View {
142
142
  }
143
143
  }
144
144
 
145
- /// 48pt wide glass capsule grouping related controls, one item per 50pt.
145
+ /// 48pt glass capsule grouping related controls, one item per 50pt: vertical in the side bar,
146
+ /// horizontal on a header row.
146
147
  struct SideBarGroup<Content: View>: View {
147
148
  let headerColor: HeaderColor
148
149
  let itemCount: Int
150
+ var horizontal: Bool = false
149
151
  @ViewBuilder let content: () -> Content
150
152
 
151
153
  var body: some View {
152
- VStack(spacing: 0) { content() }
153
- .frame(
154
- width: DuoMetrics.controlSize,
155
- height: itemCount > 1 ? DuoMetrics.itemPitch * CGFloat(itemCount) : DuoMetrics.controlSize
156
- )
157
- .modifier(SideBarGlass(headerColor: headerColor, shape: RoundedRectangle(cornerRadius: Radius.XL)))
154
+ let length = itemCount > 1 ? DuoMetrics.itemPitch * CGFloat(itemCount) : DuoMetrics.controlSize
155
+ Group {
156
+ if horizontal {
157
+ HStack(spacing: 0) { content() }
158
+ .frame(width: length, height: DuoMetrics.controlSize)
159
+ } else {
160
+ VStack(spacing: 0) { content() }
161
+ .frame(width: DuoMetrics.controlSize, height: length)
162
+ }
163
+ }
164
+ .modifier(SideBarGlass(headerColor: headerColor, shape: RoundedRectangle(cornerRadius: Radius.XL)))
158
165
  }
159
166
  }
160
167
 
@@ -618,24 +618,25 @@ private struct SearchAnimatedHeader: View {
618
618
  let minTop = rowTop + (layout.headerBarHeight - HEADER_HEIGHT) / 2
619
619
  let maxTop = layout.headerHeight - layout.insets.top
620
620
  let minSide = Spacing.M
621
- let maxStart: CGFloat = hiddenBack || useSideBar ? Spacing.M : 48
622
- let maxEnd: CGFloat = useSideBar && hasToolkit
621
+ // collapsed on the iPhone Duo status bar row, the search stays between its clusters (48pt back there)
622
+ let clearance = layout.statusBarClearance
623
+ let backWidth: CGFloat = clearance != nil && !useSideBar ? DuoMetrics.controlSize : 28
624
+ let maxStart = (clearance?.left ?? 0) + (hiddenBack || useSideBar ? Spacing.M : backWidth + Spacing.M + Spacing.S)
625
+ let maxEnd = (clearance?.right ?? 0) + (useSideBar && hasToolkit
623
626
  ? Spacing.M
624
- : headerRightWidth + (hasHeaderRight ? Spacing.M * 2 : Spacing.M)
627
+ : headerRightWidth + (hasHeaderRight ? Spacing.M * 2 : Spacing.M))
625
628
 
626
629
  let top = min(max(maxTop - scroll, minTop), maxTop)
627
630
  let start = min(max(minSide + scroll, minSide), maxStart)
628
631
  let end = min(max(minSide + scroll, minSide), maxEnd)
629
632
 
630
- VStack(spacing: 0) {
631
- Spacer().frame(height: top)
632
- InputSearch(props: headerSearchProps(props, scroll: scroll, layout: layout, theme: theme))
633
- .frame(height: HEADER_HEIGHT)
634
- .padding(.leading, start)
635
- .padding(.trailing, end)
636
- Spacer(minLength: 0)
637
- }
638
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
633
+ // padding, not a Spacer: on the iPhone Duo status bar row the collapsed top is above the safe area
634
+ InputSearch(props: headerSearchProps(props, scroll: scroll, layout: layout, theme: theme))
635
+ .frame(height: HEADER_HEIGHT)
636
+ .padding(.leading, start)
637
+ .padding(.trailing, end)
638
+ .padding(.top, top)
639
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
639
640
  }
640
641
  }
641
642
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/native-kits",
3
- "version": "0.167.1-duo.17-debug",
3
+ "version": "0.167.1-duo.18-debug",
4
4
  "private": false,
5
5
  "dependeNavigationncies": {},
6
6
  "devDependencies": {},