@momo-kits/foundation 0.167.1-duo.2 → 0.167.1-duo.4

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.
@@ -8,14 +8,17 @@ import {
8
8
  Platform,
9
9
  Pressable,
10
10
  StyleSheet,
11
+ useWindowDimensions,
11
12
  View,
12
13
  } from 'react-native';
14
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
13
15
 
14
16
  import { ApplicationContext, MiniAppContext } from '../Context';
15
17
  import { Styles } from '../Consts';
16
18
  import Navigation from './Navigation';
17
19
  import { ModalParams } from './types';
18
20
  import BottomSheet from './BottomSheet';
21
+ import { getDuoTopInset, isIPhoneDuo } from './device';
19
22
  import { scheduleOnRN } from 'react-native-worklets';
20
23
 
21
24
  const ModalScreen: React.FC<any> = props => {
@@ -37,6 +40,8 @@ const Modal: React.FC<ModalParams> = props => {
37
40
  title,
38
41
  } = props.route.params;
39
42
  const Component = useRef(screen).current;
43
+ const insets = useSafeAreaInsets();
44
+ const windowSize = useWindowDimensions();
40
45
 
41
46
  /**
42
47
  * Fire auto_popup_displayed tracking event on mount
@@ -147,6 +152,15 @@ const Modal: React.FC<ModalParams> = props => {
147
152
  opacity,
148
153
  transform: [{ scale }],
149
154
  },
155
+ // center below the side status bar and above the home indicator
156
+ isIPhoneDuo() && {
157
+ marginTop: getDuoTopInset(
158
+ insets,
159
+ windowSize.width,
160
+ windowSize.height,
161
+ ),
162
+ marginBottom: insets.bottom,
163
+ },
150
164
  modalStyle,
151
165
  ]}
152
166
  >
@@ -31,6 +31,7 @@ import { HeaderTitle } from './Components/HeaderTitle';
31
31
  import { HeaderBackground } from './Components/HeaderBackground';
32
32
  import { type EdgeInsets } from 'react-native-safe-area-context';
33
33
  import { useScreenLayout } from '../Layout';
34
+ import { ReservedRegionsProvider } from '../Layout/ReservedRegions';
34
35
 
35
36
  const Stack = createStackNavigator();
36
37
 
@@ -76,17 +77,19 @@ const NavigationContainer: React.FC<NavigationContainerProps> = ({
76
77
  frame: { x: 0, y: 0, width: 0, height: 0 },
77
78
  }}
78
79
  >
79
- <MiniAppContext.Provider value={mergedContext}>
80
- <Navigation
81
- screen={screen}
82
- theme={theme}
83
- options={options}
84
- maxApi={maxApi}
85
- setCurrentContext={setCurrentContext}
86
- initialParams={initialParams}
87
- localize={localize}
88
- />
89
- </MiniAppContext.Provider>
80
+ <ReservedRegionsProvider>
81
+ <MiniAppContext.Provider value={mergedContext}>
82
+ <Navigation
83
+ screen={screen}
84
+ theme={theme}
85
+ options={options}
86
+ maxApi={maxApi}
87
+ setCurrentContext={setCurrentContext}
88
+ initialParams={initialParams}
89
+ localize={localize}
90
+ />
91
+ </MiniAppContext.Provider>
92
+ </ReservedRegionsProvider>
90
93
  </SafeAreaProvider>
91
94
  );
92
95
  };
@@ -0,0 +1,29 @@
1
+ import { Dimensions, Platform, ScaledSize } from 'react-native';
2
+ import type { EdgeInsets } from 'react-native-safe-area-context';
3
+
4
+ // widest short side of any single-display iPhone
5
+ const IPHONE_MAX_SHORT_SIDE = 440;
6
+
7
+ // iPhone Duo reports idiom "phone"; 'screen' stays on the outer display, so it
8
+ // still matches when Split View narrows the window
9
+ const isIPhoneDuo = () => {
10
+ if (Platform.OS !== 'ios' || Platform.isPad) {
11
+ return false;
12
+ }
13
+ const shortSide = (size: ScaledSize) => Math.min(size.width, size.height);
14
+ return (
15
+ Math.max(
16
+ shortSide(Dimensions.get('screen')),
17
+ shortSide(Dimensions.get('window')),
18
+ ) > IPHONE_MAX_SHORT_SIDE
19
+ );
20
+ };
21
+
22
+ // portrait side status bar is about twice as tall as its inset is wide
23
+ const getDuoTopInset = (insets: EdgeInsets, width: number, height: number) =>
24
+ Math.max(
25
+ insets.top,
26
+ height > width ? Math.max(insets.left, insets.right) * 2 : 0,
27
+ );
28
+
29
+ export { isIPhoneDuo, getDuoTopInset };
@@ -333,6 +333,11 @@ export type BottomTabProps = {
333
333
  blur?: (e: EventArg<'blur', false, unknown>) => void;
334
334
  };
335
335
  floatingButton?: FloatingButtonProps;
336
+ /**
337
+ * iPhone Duo: when the side bar can't hold both bars, which one starts compressed;
338
+ * tapping the compressed one swaps them. Defaults to 'toolbar'
339
+ */
340
+ compressionBehavior?: 'toolbar' | 'tabBar';
336
341
  };
337
342
 
338
343
  export type FloatingButtonProps = {
@@ -0,0 +1,47 @@
1
+ import React, { createContext, ReactNode, useState } from 'react';
2
+ import { Platform, StyleSheet } from 'react-native';
3
+
4
+ export type ReservedRegion = {
5
+ x: number;
6
+ y: number;
7
+ width: number;
8
+ height: number;
9
+ };
10
+
11
+ // native probe is optional so apps without @momo-kits/reserved-regions still bundle
12
+ let ReservedRegionsView: React.ComponentType<any> | undefined;
13
+ try {
14
+ ReservedRegionsView =
15
+ require('@momo-kits/reserved-regions').ReservedRegionsView;
16
+ } catch {
17
+ ReservedRegionsView = undefined;
18
+ }
19
+
20
+ /**
21
+ * screen regions the system currently occludes, in window coordinates
22
+ */
23
+ const ReservedRegionsContext = createContext<ReservedRegion[]>([]);
24
+
25
+ /**
26
+ * tracks the system occlusion regions (iOS 27.1+), e.g. the iPhone Duo status bar
27
+ * cluster growing for a call or a Live Activity
28
+ */
29
+ const ReservedRegionsProvider: React.FC<{ children: ReactNode }> = ({
30
+ children,
31
+ }) => {
32
+ const [regions, setRegions] = useState<ReservedRegion[]>([]);
33
+
34
+ return (
35
+ <ReservedRegionsContext.Provider value={regions}>
36
+ {children}
37
+ {Platform.OS === 'ios' && !!ReservedRegionsView && (
38
+ <ReservedRegionsView
39
+ style={StyleSheet.absoluteFill}
40
+ onRegionsChange={setRegions}
41
+ />
42
+ )}
43
+ </ReservedRegionsContext.Provider>
44
+ );
45
+ };
46
+
47
+ export { ReservedRegionsContext, ReservedRegionsProvider };
package/Layout/Screen.tsx CHANGED
@@ -9,6 +9,7 @@ import React, {
9
9
  useContext,
10
10
  useEffect,
11
11
  useImperativeHandle,
12
+ useMemo,
12
13
  useRef,
13
14
  } from 'react';
14
15
  import {
@@ -24,7 +25,11 @@ import {
24
25
  View,
25
26
  ViewProps,
26
27
  } from 'react-native';
27
- import { useSharedValue, withTiming } from 'react-native-reanimated';
28
+ import {
29
+ useAnimatedReaction,
30
+ useSharedValue,
31
+ withTiming,
32
+ } from 'react-native-reanimated';
28
33
  import {
29
34
  useSafeAreaFrame,
30
35
  useSafeAreaInsets,
@@ -52,6 +57,12 @@ import {
52
57
  HeaderTitle,
53
58
  SearchHeader,
54
59
  } from '../Application';
60
+ import {
61
+ DuoGlassBackground,
62
+ DuoScrollGradient,
63
+ } from '../Application/Components/DuoGlassBackground';
64
+ import { DuoQuickReturnHeader } from '../Application/Components/DuoQuickReturnHeader';
65
+ import { useDuoTabBarInset } from '../Application/BottomTab/DuoTabBar';
55
66
 
56
67
  export interface ScreenProps extends ViewProps {
57
68
  /**
@@ -247,13 +258,64 @@ const Screen = forwardRef(
247
258
  const currentTint = useRef<string | undefined>(undefined);
248
259
 
249
260
  const isTab = !!screen?.bottomTab;
261
+ // Duo floating tab bar overlays the tab content instead of taking layout space
262
+ const duoTabInset = useDuoTabBarInset();
250
263
 
251
264
  let handleScroll;
252
265
  let Component: any = View;
253
266
 
254
- const bottomInset = Platform.OS === 'ios' ? Math.min(insets.bottom, 21) : insets.bottom;
267
+ const bottomInset =
268
+ Platform.OS === 'ios' ? Math.min(insets.bottom, 21) : insets.bottom;
269
+ const hasSideBar = !!layout.sideBar;
270
+ // iPhone Duo: content scrolls under a glass header instead of starting below it
271
+ const useDuoGlass =
272
+ hasSideBar &&
273
+ (headerType === 'default' || headerType === 'extended') &&
274
+ !animatedHeader &&
275
+ !inputSearchProps;
276
+
277
+ // iPhone Duo: the header leaves with the content and returns on a reverse scroll
278
+ const useQuickReturn =
279
+ hasSideBar && scrollable && (useDuoGlass || !!animatedHeader);
280
+ const headerHide = useSharedValue(0);
281
+ const lastScrollY = useSharedValue(0);
282
+
283
+ useAnimatedReaction(
284
+ () => sharedValue.value,
285
+ y => {
286
+ const delta = y - lastScrollY.value;
287
+ lastScrollY.value = y;
288
+ if (!useQuickReturn || y <= 0) {
289
+ headerHide.value = 0;
290
+ return;
291
+ }
292
+ headerHide.value = Math.min(
293
+ Math.max(headerHide.value + delta, 0),
294
+ heightHeader,
295
+ );
296
+ },
297
+ [useQuickReturn, heightHeader],
298
+ );
299
+
300
+ const quickReturnOptions: StackNavigationOptions = useMemo(
301
+ () =>
302
+ useQuickReturn
303
+ ? {
304
+ header: (props: any) => (
305
+ <DuoQuickReturnHeader {...props} offset={headerHide} />
306
+ ),
307
+ }
308
+ : { header: undefined },
309
+ [useQuickReturn, headerHide],
310
+ );
311
+
255
312
  let keyboardOffset = heightHeader - bottomInset;
256
- if (headerType === 'extended' || animatedHeader || inputSearchProps) {
313
+ if (
314
+ headerType === 'extended' ||
315
+ animatedHeader ||
316
+ inputSearchProps ||
317
+ useDuoGlass
318
+ ) {
257
319
  keyboardOffset = -bottomInset;
258
320
  }
259
321
 
@@ -280,9 +342,13 @@ const Screen = forwardRef(
280
342
 
281
343
  case 'extended':
282
344
  options = {
345
+ ...quickReturnOptions,
283
346
  headerShown: true,
284
347
  headerTransparent: true,
285
- headerBackground: () => null,
348
+ headerBackground: () =>
349
+ hasSideBar ? (
350
+ <DuoGlassBackground animatedValue={sharedValue} />
351
+ ) : null,
286
352
  headerTitle: (props: any) => <HeaderTitle {...props} />,
287
353
  };
288
354
  if (inputSearchProps) {
@@ -305,6 +371,19 @@ const Screen = forwardRef(
305
371
  break;
306
372
 
307
373
  default:
374
+ if (hasSideBar && !inputSearchProps) {
375
+ options = {
376
+ ...quickReturnOptions,
377
+ headerTransparent: true,
378
+ headerTintColor: Colors.black_17,
379
+ headerShown: true,
380
+ headerBackground: () => (
381
+ <DuoGlassBackground animatedValue={sharedValue} />
382
+ ),
383
+ headerTitle: (props: any) => <HeaderTitle {...props} />,
384
+ };
385
+ break;
386
+ }
308
387
  options = {
309
388
  headerTransparent: false,
310
389
  headerTintColor: Colors.black_17,
@@ -339,7 +418,7 @@ const Screen = forwardRef(
339
418
  }
340
419
  }
341
420
 
342
- navigation?.instance?.setOptions(options);
421
+ navigation?.instance?.setOptions({ header: undefined, ...options });
343
422
  },
344
423
  [
345
424
  gradientColor,
@@ -348,6 +427,8 @@ const Screen = forwardRef(
348
427
  navigation?.instance,
349
428
  sharedValue,
350
429
  useShadowHeader,
430
+ hasSideBar,
431
+ quickReturnOptions,
351
432
  ],
352
433
  );
353
434
 
@@ -363,18 +444,26 @@ const Screen = forwardRef(
363
444
  }
364
445
 
365
446
  options = {
447
+ ...quickReturnOptions,
366
448
  headerTintColor: currentTint.current ?? Colors.black_17,
367
449
  headerTransparent: true,
368
- headerBackground: (props: any) => (
369
- <HeaderBackground
370
- {...props}
371
- animatedValue={sharedValue}
372
- useGradient={false}
373
- useShadowHeader={useShadowHeader}
374
- headerBackground={headerBackground}
375
- gradientColor={gradientColor}
376
- />
377
- ),
450
+ headerBackground: (props: any) =>
451
+ hasSideBar ? (
452
+ // glass shows once the banner has scrolled under the header
453
+ <DuoGlassBackground
454
+ animatedValue={sharedValue}
455
+ fadeStart={Math.max(210 + layoutOffset - heightHeader, 0)}
456
+ />
457
+ ) : (
458
+ <HeaderBackground
459
+ {...props}
460
+ animatedValue={sharedValue}
461
+ useGradient={false}
462
+ useShadowHeader={useShadowHeader}
463
+ headerBackground={headerBackground}
464
+ gradientColor={gradientColor}
465
+ />
466
+ ),
378
467
  headerTitle: (props: any) => {
379
468
  return <HeaderTitle {...props} />;
380
469
  },
@@ -395,6 +484,10 @@ const Screen = forwardRef(
395
484
  navigation?.instance,
396
485
  sharedValue,
397
486
  useShadowHeader,
487
+ hasSideBar,
488
+ layoutOffset,
489
+ heightHeader,
490
+ quickReturnOptions,
398
491
  ],
399
492
  );
400
493
 
@@ -418,7 +511,7 @@ const Screen = forwardRef(
418
511
  ),
419
512
  };
420
513
 
421
- navigation?.instance?.setOptions(options);
514
+ navigation?.instance?.setOptions({ header: undefined, ...options });
422
515
  }, [navigation?.instance, sharedValue]);
423
516
 
424
517
  /**
@@ -546,8 +639,36 @@ const Screen = forwardRef(
546
639
  * handle scroll end
547
640
  * @param e
548
641
  */
642
+ /**
643
+ * a half-shown quick return header settles fully in or out
644
+ */
645
+ const snapQuickReturnHeader = (offsetY: number) => {
646
+ const hide = headerHide.value;
647
+ // above heightHeader the header still sits on the content
648
+ if (
649
+ !useQuickReturn ||
650
+ offsetY < heightHeader ||
651
+ hide <= 0 ||
652
+ hide >= heightHeader
653
+ ) {
654
+ return;
655
+ }
656
+ headerHide.value = withTiming(
657
+ hide > heightHeader / 2 ? heightHeader : 0,
658
+ { duration: 200 },
659
+ );
660
+ };
661
+
662
+ const handleMomentumScrollEnd = (
663
+ e: NativeSyntheticEvent<NativeScrollEvent>,
664
+ ) => {
665
+ snapQuickReturnHeader(e.nativeEvent.contentOffset.y);
666
+ scrollViewProps?.onMomentumScrollEnd?.(e);
667
+ };
668
+
549
669
  const handleScrollEnd = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
550
670
  const offsetY = e.nativeEvent.contentOffset.y;
671
+ snapQuickReturnHeader(offsetY);
551
672
  if (inputSearchProps && offsetY < 100 && offsetY > 0) {
552
673
  Animated.timing(animatedValue.current, {
553
674
  toValue: 0,
@@ -668,21 +789,36 @@ const Screen = forwardRef(
668
789
  },
669
790
  ]}
670
791
  >
671
- <HeaderExtendHeader
672
- headerType={headerType}
673
- heightHeader={heightHeader}
674
- headerRightWidth={headerRightWidth}
675
- animatedValue={sharedValue}
676
- inputSearchProps={inputSearchProps}
677
- navigation={navigation}
678
- inputSearchRef={inputSearchRef}
679
- useShadowHeader={useShadowHeader}
680
- gradientColor={gradientColor}
681
- headerBackground={headerBackground}
682
- />
792
+ {useDuoGlass ? (
793
+ <DuoScrollGradient
794
+ animatedValue={sharedValue}
795
+ gradientColor={gradientColor}
796
+ headerBackground={headerBackground}
797
+ heightHeader={heightHeader}
798
+ extended={headerType === 'extended'}
799
+ followScroll={useQuickReturn}
800
+ />
801
+ ) : (
802
+ <HeaderExtendHeader
803
+ headerType={headerType}
804
+ heightHeader={heightHeader}
805
+ headerRightWidth={headerRightWidth}
806
+ animatedValue={sharedValue}
807
+ inputSearchProps={inputSearchProps}
808
+ navigation={navigation}
809
+ inputSearchRef={inputSearchRef}
810
+ useShadowHeader={useShadowHeader}
811
+ gradientColor={gradientColor}
812
+ headerBackground={headerBackground}
813
+ />
814
+ )}
683
815
 
684
816
  <KeyboardAvoidingView
685
- style={Styles.flex}
817
+ style={[
818
+ Styles.flex,
819
+ useDuoGlass &&
820
+ (!scrollable || !!Header) && { paddingTop: heightHeader },
821
+ ]}
686
822
  enabled={enableKeyboardAvoidingView}
687
823
  keyboardVerticalOffset={keyboardVerticalOffset ?? keyboardOffset}
688
824
  behavior={Platform.select({
@@ -698,11 +834,24 @@ const Screen = forwardRef(
698
834
  showsVerticalScrollIndicator={false}
699
835
  onScroll={handleScroll}
700
836
  onScrollEndDrag={handleScrollEnd}
837
+ onMomentumScrollEnd={handleMomentumScrollEnd}
701
838
  scrollEventThrottle={16}
702
- style={[Styles.flex, !scrollable && contentInsetStyle]}
839
+ style={[
840
+ Styles.flex,
841
+ !scrollable && contentInsetStyle,
842
+ !scrollable &&
843
+ !Footer &&
844
+ isTab &&
845
+ !!duoTabInset && { paddingBottom: duoTabInset },
846
+ ]}
703
847
  contentContainerStyle={[
704
848
  scrollable && !Footer && !isTab && { paddingBottom: bottomInset },
849
+ scrollable &&
850
+ !Footer &&
851
+ isTab &&
852
+ !!duoTabInset && { paddingBottom: duoTabInset },
705
853
  scrollable && contentInsetStyle,
854
+ useDuoGlass && !Header && { paddingTop: heightHeader },
706
855
  scrollViewProps?.contentContainerStyle,
707
856
  ]}
708
857
  >
@@ -721,7 +870,11 @@ const Screen = forwardRef(
721
870
  <FloatingButton
722
871
  {...floatingButtonProps}
723
872
  animatedValue={sharedValue}
724
- bottom={Footer || isTab ? 12 : bottomInset + Spacing.S}
873
+ bottom={
874
+ Footer || isTab
875
+ ? 12 + (Footer ? 0 : duoTabInset)
876
+ : bottomInset + Spacing.S
877
+ }
725
878
  />
726
879
  </View>
727
880
  )}
@@ -731,7 +884,9 @@ const Screen = forwardRef(
731
884
  style={[
732
885
  styles.shadow,
733
886
  {
734
- paddingBottom: bottomInset + Spacing.S,
887
+ paddingBottom:
888
+ (isTab && duoTabInset ? duoTabInset : bottomInset) +
889
+ Spacing.S,
735
890
  paddingLeft: layout.insetLeft,
736
891
  paddingRight: layout.insetRight,
737
892
  backgroundColor: theme.colors.background.surface,
package/Layout/utils.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  SafeAreaInsetsContext,
6
6
  } from 'react-native-safe-area-context';
7
7
  import { Spacing } from '../Consts';
8
+ import { ReservedRegion, ReservedRegionsContext } from './ReservedRegions';
8
9
 
9
10
  /**
10
11
  * validate children type
@@ -70,6 +71,12 @@ const HeaderMetrics = {
70
71
  * - firstItemTop: with the status bar in the strip, the first control sits under the
71
72
  * camera, clock and connectivity cluster (cluster ends near y 151 on the outer display,
72
73
  * y 98 on the inner display in landscape)
74
+ * - outerHeaderHeight / outerCameraCenterY: on the outer display in portrait the header
75
+ * is 4pt taller than iPhoneMinHeight and its title lines up with the camera punchout
76
+ * (y 24–66 at the top of the strip)
77
+ * - edgeInset: on the outer display in landscape the strip holds no status bar, so its
78
+ * controls start this far from the top and stay this far above the bottom, unless a
79
+ * reserved region (the camera at either end) asks for more
73
80
  */
74
81
  const DuoMetrics = {
75
82
  maxAspectRatio: 1.6,
@@ -78,10 +85,40 @@ const DuoMetrics = {
78
85
  itemPitch: 50,
79
86
  firstItemTop: 170,
80
87
  firstItemTopLandscape: 120,
88
+ outerHeaderHeight: 76,
89
+ outerCameraCenterY: 45,
90
+ edgeInset: 24,
81
91
  };
82
92
 
83
93
  const NO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
84
94
 
95
+ /**
96
+ * how far system regions reach into a side strip from its top and from its bottom end
97
+ * (e.g. the camera, which sits at either end depending on the rotation)
98
+ */
99
+ const getStripOcclusion = (
100
+ regions: ReservedRegion[],
101
+ stripLeft: number,
102
+ stripRight: number,
103
+ windowHeight: number,
104
+ ) =>
105
+ regions.reduce(
106
+ (occlusion, region) => {
107
+ const overlapsStrip =
108
+ region.x < stripRight && region.x + region.width > stripLeft;
109
+ if (!overlapsStrip) {
110
+ return occlusion;
111
+ }
112
+ if (region.y + region.height / 2 < windowHeight / 2) {
113
+ occlusion.top = Math.max(occlusion.top, region.y + region.height);
114
+ } else {
115
+ occlusion.bottom = Math.max(occlusion.bottom, windowHeight - region.y);
116
+ }
117
+ return occlusion;
118
+ },
119
+ { top: 0, bottom: 0 },
120
+ );
121
+
85
122
  const isIPhone = () =>
86
123
  Platform.OS === 'ios' && Platform.constants.interfaceIdiom === 'phone';
87
124
 
@@ -120,6 +157,11 @@ export type SideBarLayout = {
120
157
  * Window y of the first side bar item, below the navigation bar and the side status bar.
121
158
  */
122
159
  top: number;
160
+
161
+ /**
162
+ * Room to keep free above the window bottom, clear of a camera at the bottom end.
163
+ */
164
+ bottom: number;
123
165
  };
124
166
 
125
167
  export type ScreenLayout = {
@@ -201,8 +243,9 @@ const isIPhoneDuoLayout = (
201
243
  const useScreenLayout = (): ScreenLayout => {
202
244
  const { width, height } = useWindowDimensions();
203
245
  const insets = useContext(SafeAreaInsetsContext) ?? NO_INSETS;
246
+ const reservedRegions = useContext(ReservedRegionsContext);
204
247
 
205
- const headerLayout = getHeaderLayout(insets.top);
248
+ let headerLayout = getHeaderLayout(insets.top);
206
249
  const isIPhoneDuo = isIPhoneDuoLayout(width, height, insets);
207
250
  if (!isIPhoneDuo) {
208
251
  return {
@@ -224,17 +267,42 @@ const useScreenLayout = (): ScreenLayout => {
224
267
  let top = insets.top + HeaderMetrics.barHeight + Spacing.M;
225
268
  if (insets.top < HeaderMetrics.statusBarMinInset) {
226
269
  // status bar lives in the strip: start under its cluster, as the native bar does
227
- const isInnerLandscape =
228
- width > height && height >= DuoMetrics.innerMinHeight;
229
- top = isInnerLandscape
230
- ? DuoMetrics.firstItemTopLandscape
231
- : DuoMetrics.firstItemTop;
270
+ const isLandscape = width > height;
271
+ if (!isLandscape) {
272
+ top = DuoMetrics.firstItemTop;
273
+ } else if (height >= DuoMetrics.innerMinHeight) {
274
+ top = DuoMetrics.firstItemTopLandscape;
275
+ } else {
276
+ // outer display in landscape: no status bar in the strip
277
+ top = DuoMetrics.edgeInset;
278
+ }
279
+
280
+ if (height > width && width < DuoMetrics.innerMinHeight) {
281
+ // outer display: the row starts below the top so the title centers on the camera
282
+ headerLayout = {
283
+ headerHeight: DuoMetrics.outerHeaderHeight,
284
+ headerStatusBarHeight:
285
+ DuoMetrics.outerCameraCenterY * 2 - DuoMetrics.outerHeaderHeight,
286
+ };
287
+ }
232
288
  }
233
289
 
290
+ const edge = insets.right >= insets.left ? 'right' : 'left';
291
+ const stripLeft = edge === 'right' ? width - sideInset : 0;
292
+ // keep controls clear of the camera and of the status bar cluster, which grows for
293
+ // calls and Live Activities. Region frames already include the system's margin
294
+ const occlusion = getStripOcclusion(
295
+ reservedRegions,
296
+ stripLeft,
297
+ stripLeft + sideInset,
298
+ height,
299
+ );
300
+
234
301
  sideBar = {
235
- edge: insets.right >= insets.left ? 'right' : 'left',
302
+ edge,
236
303
  width: sideInset,
237
- top,
304
+ top: Math.max(top, occlusion.top),
305
+ bottom: Math.max(DuoMetrics.edgeInset, occlusion.bottom),
238
306
  };
239
307
  }
240
308