react-native-image-stitcher 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +28 -0
  3. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +3 -2
  4. package/dist/camera/ARCameraView.d.ts +10 -0
  5. package/dist/camera/ARCameraView.js +1 -0
  6. package/dist/camera/Camera.d.ts +20 -0
  7. package/dist/camera/Camera.js +175 -6
  8. package/dist/camera/OrientationDriftModal.d.ts +83 -0
  9. package/dist/camera/OrientationDriftModal.js +159 -0
  10. package/dist/camera/PanoramaBandOverlay.d.ts +13 -1
  11. package/dist/camera/PanoramaBandOverlay.js +106 -45
  12. package/dist/camera/PanoramaSettingsModal.js +15 -1
  13. package/dist/camera/ViewportCropOverlay.d.ts +35 -31
  14. package/dist/camera/ViewportCropOverlay.js +39 -30
  15. package/dist/camera/useDeviceOrientation.d.ts +18 -9
  16. package/dist/camera/useDeviceOrientation.js +18 -9
  17. package/dist/camera/useOrientationDrift.d.ts +104 -0
  18. package/dist/camera/useOrientationDrift.js +120 -0
  19. package/dist/index.d.ts +5 -0
  20. package/dist/index.js +12 -1
  21. package/dist/stitching/incremental.d.ts +5 -3
  22. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +7 -1
  23. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +4 -3
  24. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +9 -7
  25. package/ios/Sources/RNImageStitcher/RNSARSession.swift +46 -7
  26. package/package.json +1 -1
  27. package/src/camera/ARCameraView.tsx +18 -1
  28. package/src/camera/Camera.tsx +280 -13
  29. package/src/camera/OrientationDriftModal.tsx +224 -0
  30. package/src/camera/PanoramaBandOverlay.tsx +135 -49
  31. package/src/camera/PanoramaSettingsModal.tsx +14 -0
  32. package/src/camera/ViewportCropOverlay.tsx +52 -30
  33. package/src/camera/__tests__/useOrientationDrift.test.ts +169 -0
  34. package/src/camera/useDeviceOrientation.ts +18 -9
  35. package/src/camera/useOrientationDrift.ts +172 -0
  36. package/src/index.ts +13 -0
  37. package/src/stitching/incremental.ts +5 -3
@@ -52,6 +52,7 @@ import {
52
52
  StyleSheet,
53
53
  Text,
54
54
  View,
55
+ useWindowDimensions,
55
56
  type StyleProp,
56
57
  type ViewStyle,
57
58
  } from 'react-native';
@@ -82,7 +83,9 @@ import {
82
83
  } from './buildPanoramaInitialSettings';
83
84
  import { isLowMemDevice } from './lowMemDevice';
84
85
  import { useCapture } from './useCapture';
85
- import { useDeviceOrientation } from './useDeviceOrientation';
86
+ import { useDeviceOrientation, type DeviceOrientation } from './useDeviceOrientation';
87
+ import { useOrientationDrift } from './useOrientationDrift';
88
+ import { OrientationDriftModal } from './OrientationDriftModal';
86
89
  import {
87
90
  getIncrementalNativeModule,
88
91
  incrementalStitcherIsAvailable,
@@ -293,6 +296,27 @@ export interface CameraProps {
293
296
  onFramesDropped?: (info: FramesDroppedInfo) => void;
294
297
  onError?: (err: CameraError) => void;
295
298
 
299
+ /**
300
+ * v0.12.0 — fires when the SDK auto-abandons an in-progress
301
+ * capture without producing output. `reason` is a string union
302
+ * so future reasons (network loss, low memory, etc.) can be added
303
+ * without breaking the callback signature.
304
+ *
305
+ * Currently the only reason in v0.12 is `'orientation-drift'`:
306
+ * the user rotated the device between Mode A (landscape + vertical
307
+ * pan) and Mode B (portrait + horizontal pan) mid-capture. The
308
+ * engine docstring at `incremental.ts:373-403` is explicit that
309
+ * cross-mode capture is "best-effort, not supported," so the SDK
310
+ * decisively cancels the capture (`incremental.cancel()`) and
311
+ * surfaces `OrientationDriftModal` to explain what happened.
312
+ *
313
+ * Hosts use this callback to clean up their own state (e.g., reset
314
+ * a wizard step, log telemetry, surface their own retry UX in
315
+ * addition to the SDK's built-in modal). No `onCapture` will fire
316
+ * for an abandoned capture.
317
+ */
318
+ onCaptureAbandoned?: (reason: 'orientation-drift') => void;
319
+
296
320
  /**
297
321
  * Optional host-supplied vision-camera frame processor.
298
322
  *
@@ -656,11 +680,20 @@ export function Camera(props: CameraProps): React.JSX.Element {
656
680
  onLensChange,
657
681
  onFramesDropped,
658
682
  onError,
683
+ onCaptureAbandoned,
659
684
  frameProcessor: hostFrameProcessor,
660
685
  engine = 'batch-keyframe',
661
686
  } = props;
662
687
 
663
688
  const insets = useSafeAreaInsets();
689
+ // v0.12.0 — JS-layout orientation independent of device-physical.
690
+ // `useWindowDimensions().width > height` tells us if the OS
691
+ // rotated the framebuffer (only happens for non-locked hosts in
692
+ // device-landscape). Combined with `useDeviceOrientation()` to
693
+ // pick the JS edge corresponding to the home-indicator side of
694
+ // the device — see `homeIndicatorEdge` below.
695
+ const jsWindow = useWindowDimensions();
696
+ const jsLandscape = jsWindow.width > jsWindow.height;
664
697
 
665
698
  // ── State ───────────────────────────────────────────────────────
666
699
  const [arPreference, setArPreference] = useState(
@@ -857,6 +890,64 @@ export function Camera(props: CameraProps): React.JSX.Element {
857
890
  // eslint-disable-next-line react-hooks/exhaustive-deps
858
891
  useEffect(() => () => { fpDriver.stop(); }, []);
859
892
 
893
+ // ── v0.12.0 — Orientation drift detection + auto-abandon ────────
894
+ //
895
+ // The incremental engine supports both portrait (Mode B, horizontal
896
+ // pan) and landscape (Mode A, vertical pan) capture as first-class,
897
+ // but the docstring at `incremental.ts:373-403` is explicit that
898
+ // mixing them mid-capture is "best-effort, not supported" — the
899
+ // output rotation becomes ambiguous and the stitched panorama is
900
+ // malformed. v0.12 protects against this by snapshotting the
901
+ // orientation at `start()` and auto-cancelling the capture the
902
+ // instant the user rotates to a different orientation mid-flight.
903
+ //
904
+ // The modal is informational only — by the time it renders, the
905
+ // capture is already stopped. No Continue/Resume affordance per
906
+ // the engine spec.
907
+ const drift = useOrientationDrift(statusPhase === 'recording');
908
+ const [driftModalDismissed, setDriftModalDismissed] = useState(false);
909
+ // Reset the dismissed flag when a new capture starts (or any non-
910
+ // recording state) so the next drift event surfaces a fresh modal.
911
+ useEffect(() => {
912
+ if (statusPhase !== 'recording') setDriftModalDismissed(false);
913
+ }, [statusPhase]);
914
+
915
+ useEffect(() => {
916
+ if (!drift.drifted || statusPhase !== 'recording') return;
917
+ // Auto-abandon the in-flight capture. Order matches handleHoldEnd's
918
+ // "stitch" path but skips finalize:
919
+ // 1. Stop pumping frames so no new keyframes arrive mid-cancel.
920
+ // 2. Tell the native engine to drop accumulated state
921
+ // (`incremental.cancel()`).
922
+ // 3. Reset statusPhase back to idle.
923
+ // 4. Notify the host via `onCaptureAbandoned`.
924
+ //
925
+ // Wrapped in an IIFE because useEffect callbacks can't be async
926
+ // directly. Errors from `incremental.cancel()` are caught + sent
927
+ // through `onError` — abandonment must succeed even if the engine
928
+ // is in a weird state.
929
+ void (async () => {
930
+ fpDriver.stop();
931
+ try {
932
+ await incremental.cancel();
933
+ } catch (err) {
934
+ const message = err instanceof Error ? err.message : String(err);
935
+ onError?.(new CameraError(
936
+ 'PANORAMA_FINALIZE_FAILED',
937
+ `cancel after orientation drift failed: ${message}`,
938
+ err,
939
+ ));
940
+ } finally {
941
+ setStatusPhase('idle');
942
+ setRecordingStartedAt(null);
943
+ onCaptureAbandoned?.('orientation-drift');
944
+ }
945
+ })();
946
+ // Deps: re-run whenever drift latches OR recording state changes.
947
+ // Other deps are stable refs / setters.
948
+ // eslint-disable-next-line react-hooks/exhaustive-deps
949
+ }, [drift.drifted, statusPhase]);
950
+
860
951
  // v0.8.0 Phase 5 / v0.11.0 — frameProcessor prop semantics:
861
952
  //
862
953
  // - Host supplied? → use host's processor. The host's worklet
@@ -991,7 +1082,14 @@ export function Camera(props: CameraProps): React.JSX.Element {
991
1082
  // ARCameraView writes to its own tmp location; relocate to
992
1083
  // photoOutputPath via the native FileBridge so both branches
993
1084
  // return paths under the same dir.
994
- const photo = await arViewRef.current.takePhoto({ quality: 90 });
1085
+ // v0.12.0 pass deviceOrientation so the AR takePhoto's
1086
+ // native CIImage rotation matches the user's view. Pre-
1087
+ // v0.12 the native side hardcoded portrait, so landscape
1088
+ // photos came out sideways.
1089
+ const photo = await arViewRef.current.takePhoto({
1090
+ quality: 90,
1091
+ orientation: deviceOrientation,
1092
+ });
995
1093
  try {
996
1094
  await moveFile(photo.path, photoOutputPath);
997
1095
  } catch (moveErr) {
@@ -1370,29 +1468,43 @@ export function Camera(props: CameraProps): React.JSX.Element {
1370
1468
  )}
1371
1469
 
1372
1470
  {/*
1373
- Bottom area: stacks the live-frame band ABOVE the shutter row
1374
- so the band is tethered to the shutter on the viewport side
1375
- (the operator's eye is drawn from the camera preview, down
1376
- the band, into the shutter a single continuous reading
1377
- path). With the SDK's orientation lock holding the UI in
1378
- portrait, this stack works the same regardless of how the
1379
- device is physically held.
1471
+ v0.12.0 Orientation-aware bottom controls anchored to the
1472
+ physical home-indicator edge. The shutter follows the home-
1473
+ indicator regardless of host portrait-lock state:
1474
+ - locked + any device → JS-bottom (locked
1475
+ framebuffer maps device-bottom to JS-bottom always)
1476
+ - non-locked + device-portrait → JS-bottom
1477
+ - non-locked + device-landscape-L → JS-right
1478
+ - non-locked + device-landscape-R → JS-left
1479
+ Computed in `homeIndicatorEdge` which combines `jsLandscape`
1480
+ (from window dims) with `deviceOrientation` (sensor).
1380
1481
  */}
1381
1482
  <View
1382
1483
  pointerEvents="box-none"
1383
- style={[styles.bottomArea, { paddingBottom: insets.bottom + 12 }]}
1484
+ style={bottomAreaStyleForEdge(
1485
+ homeIndicatorEdge(jsLandscape, deviceOrientation),
1486
+ insets.bottom + 12,
1487
+ insets.top + 12,
1488
+ )}
1384
1489
  >
1385
- {/* Live-frame band — only visible while recording. */}
1490
+ {/* Live-frame band — only visible while recording. `vertical`
1491
+ is true when the home-indicator anchor is on a side edge
1492
+ (left or right), in which case the band is a vertical
1493
+ column. Otherwise it's a horizontal strip. */}
1386
1494
  {statusPhase === 'recording' && (
1387
1495
  <PanoramaBandOverlay
1388
1496
  state={incrementalState}
1389
1497
  frameUris={batchKeyframeThumbnails}
1390
1498
  captureOrientation={deviceOrientation}
1499
+ vertical={isSideEdge(homeIndicatorEdge(jsLandscape, deviceOrientation))}
1391
1500
  />
1392
1501
  )}
1393
1502
 
1394
- {/* Shutter row: lens chip (left), shutter (centre), AR toggle (right). */}
1395
- <View style={styles.bottomBar}>
1503
+ {/* Shutter row. Horizontal row when home-indicator is on
1504
+ top/bottom (lens left / shutter center / AR right);
1505
+ vertical column when on left/right (slots stack along
1506
+ the narrow strip). Touch targets stay axis-aligned. */}
1507
+ <View style={bottomBarStyleForEdge(homeIndicatorEdge(jsLandscape, deviceOrientation))}>
1396
1508
  <View style={styles.bottomBarLeft} />
1397
1509
  <View style={styles.bottomBarCenter}>
1398
1510
  <LensChip
@@ -1425,6 +1537,19 @@ export function Camera(props: CameraProps): React.JSX.Element {
1425
1537
  onChange={setSettings}
1426
1538
  onClose={() => setSettingsModalVisible(false)}
1427
1539
  />
1540
+
1541
+ {/* v0.12.0 — Orientation drift modal. Shows AFTER the SDK has
1542
+ auto-abandoned the capture (the useEffect above stops the
1543
+ engine + transitions to idle + fires onCaptureAbandoned).
1544
+ Modal exists purely to explain WHY the capture was
1545
+ cancelled. Single OK button (no Continue) per the engine
1546
+ spec on cross-mode capture being best-effort, not supported. */}
1547
+ <OrientationDriftModal
1548
+ visible={drift.drifted && !driftModalDismissed}
1549
+ captureOrientation={drift.captureOrientation}
1550
+ currentOrientation={drift.currentOrientation}
1551
+ onAcknowledge={() => setDriftModalDismissed(true)}
1552
+ />
1428
1553
  </View>
1429
1554
  );
1430
1555
  }
@@ -1435,6 +1560,148 @@ function noop(): void {
1435
1560
  }
1436
1561
 
1437
1562
 
1563
+ /**
1564
+ * v0.12.0 — JS edge corresponding to the physical home-indicator
1565
+ * side of the device. This is where the shutter + controls anchor
1566
+ * to so they're always within thumb reach of the user's grip
1567
+ * (matching iOS Camera's behaviour).
1568
+ *
1569
+ * Combines two signals:
1570
+ * - `jsLandscape`: whether the OS rotated the framebuffer. True
1571
+ * only for non-locked hosts in device-landscape.
1572
+ * - `deviceOrient`: physical device orientation from the sensor.
1573
+ *
1574
+ * Truth table:
1575
+ * | jsLandscape | deviceOrient | edge |
1576
+ * |--- |--- |--- |
1577
+ * | false | any | bottom | (portrait JS coords —
1578
+ * | | | | device-bottom = JS-bottom
1579
+ * | | | | in both locked and
1580
+ * | | | | non-locked-portrait)
1581
+ * | true | landscape-left | right | (screen rotated, home
1582
+ * | | | | indicator on user-right)
1583
+ * | true | landscape-right | left | (mirror)
1584
+ *
1585
+ * Caveats:
1586
+ * - Non-locked + upside-down doesn't surface JS-top here because
1587
+ * upside-down doesn't change window dimensions; we can't
1588
+ * distinguish locked-portrait-with-device-flipped from
1589
+ * non-locked-portrait-with-screen-flipped-180°. Defaults to
1590
+ * JS-bottom which matches the more common locked case. Add
1591
+ * handling here when a host needs upside-down support.
1592
+ * - jsLandscape=true with non-landscape device shouldn't happen
1593
+ * in steady state — only during a transition mid-rotation.
1594
+ * Falls through to 'right' as a defensive default.
1595
+ */
1596
+ type HomeIndicatorEdge = 'bottom' | 'top' | 'left' | 'right';
1597
+
1598
+ function homeIndicatorEdge(
1599
+ jsLandscape: boolean,
1600
+ deviceOrient: DeviceOrientation,
1601
+ ): HomeIndicatorEdge {
1602
+ if (!jsLandscape) return 'bottom';
1603
+ if (deviceOrient === 'landscape-left') return 'right';
1604
+ if (deviceOrient === 'landscape-right') return 'left';
1605
+ return 'right';
1606
+ }
1607
+
1608
+
1609
+ /**
1610
+ * v0.12.0 — true when the anchor edge is on a side (left/right), so
1611
+ * the band + shutter row need to be vertical strips. Top/bottom
1612
+ * anchors yield horizontal strips.
1613
+ */
1614
+ function isSideEdge(edge: HomeIndicatorEdge): boolean {
1615
+ return edge === 'left' || edge === 'right';
1616
+ }
1617
+
1618
+
1619
+ /**
1620
+ * v0.12.0 — bottom-controls outer container positioning. Anchors
1621
+ * to the home-indicator JS edge with the appropriate flex direction
1622
+ * so the band sits on the viewport side of the shutter (toward the
1623
+ * camera preview centre).
1624
+ */
1625
+ function bottomAreaStyleForEdge(
1626
+ edge: HomeIndicatorEdge,
1627
+ bottomInsetPx: number,
1628
+ topInsetPx: number,
1629
+ ): ViewStyle {
1630
+ switch (edge) {
1631
+ case 'bottom':
1632
+ // Band above shutter row, both at JS-bottom. JSX order
1633
+ // [band, shutter] + flexDirection 'column' = band at top of
1634
+ // stack (closer to screen centre), shutter at JS-bottom.
1635
+ return {
1636
+ position: 'absolute',
1637
+ left: 0,
1638
+ right: 0,
1639
+ bottom: 0,
1640
+ flexDirection: 'column',
1641
+ alignItems: 'stretch',
1642
+ paddingBottom: bottomInsetPx,
1643
+ };
1644
+ case 'top':
1645
+ // Mirror of bottom. column-reverse so JSX [band, shutter]
1646
+ // renders [shutter, band] in JS, shutter at JS-top, band
1647
+ // below it (toward screen centre).
1648
+ return {
1649
+ position: 'absolute',
1650
+ left: 0,
1651
+ right: 0,
1652
+ top: 0,
1653
+ flexDirection: 'column-reverse',
1654
+ alignItems: 'stretch',
1655
+ paddingTop: topInsetPx,
1656
+ };
1657
+ case 'right':
1658
+ // Band to the left of shutter column, both at JS-right.
1659
+ // flexDirection 'row' + JSX [band, shutter] = band at JS-left
1660
+ // of container (screen centre side), shutter at JS-right.
1661
+ return {
1662
+ position: 'absolute',
1663
+ top: 0,
1664
+ bottom: 0,
1665
+ right: 0,
1666
+ flexDirection: 'row',
1667
+ alignItems: 'stretch',
1668
+ paddingRight: 12,
1669
+ };
1670
+ case 'left':
1671
+ // Mirror of right. row-reverse so JSX [band, shutter] gives
1672
+ // band at JS-right (screen centre side), shutter at JS-left.
1673
+ return {
1674
+ position: 'absolute',
1675
+ top: 0,
1676
+ bottom: 0,
1677
+ left: 0,
1678
+ flexDirection: 'row-reverse',
1679
+ alignItems: 'stretch',
1680
+ paddingLeft: 12,
1681
+ };
1682
+ }
1683
+ }
1684
+
1685
+
1686
+ /**
1687
+ * v0.12.0 — inner shutter-row flex direction. Horizontal row for
1688
+ * top/bottom anchors; vertical column for left/right anchors so
1689
+ * the three slots (lens / shutter / AR) stack along the narrow
1690
+ * side strip. Buttons don't rotate — touch targets and text
1691
+ * orient correctly via either (a) un-rotated framebuffer under
1692
+ * portrait-lock or (b) OS-rotated framebuffer under non-locked.
1693
+ */
1694
+ function bottomBarStyleForEdge(edge: HomeIndicatorEdge): ViewStyle {
1695
+ const vertical = isSideEdge(edge);
1696
+ return {
1697
+ flexDirection: vertical ? 'column' : 'row',
1698
+ paddingHorizontal: vertical ? 0 : 18,
1699
+ paddingVertical: vertical ? 18 : 0,
1700
+ alignItems: 'center',
1701
+ };
1702
+ }
1703
+
1704
+
1438
1705
  const styles = StyleSheet.create({
1439
1706
  container: {
1440
1707
  flex: 1,
@@ -0,0 +1,224 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * OrientationDriftModal — informational popup shown when the SDK
4
+ * auto-abandons an in-progress capture because the device rotated
5
+ * between Mode A (landscape + vertical pan) and Mode B (portrait
6
+ * + horizontal pan) mid-flight.
7
+ *
8
+ * ## When this modal appears
9
+ *
10
+ * In the v0.12 `<Camera>` integration, the modal is rendered while
11
+ * `useOrientationDrift(active).drifted === true`. By the time the
12
+ * modal renders, the capture has ALREADY been stopped (the
13
+ * `<Camera>` component's drift effect calls the engine's `stop()`
14
+ * the same render). The modal exists solely to explain to the
15
+ * user what happened — no "Continue" / "Resume" affordance because
16
+ * the engine docstring at `incremental.ts:373-403` is explicit
17
+ * that cross-mode capture is "best-effort, not supported" and
18
+ * continuing past drift produces malformed output.
19
+ *
20
+ * ## Layer-2 host usage
21
+ *
22
+ * Hosts using `CameraView` directly (rather than the flagship
23
+ * `<Camera>`) can compose this modal with `useOrientationDrift`
24
+ * for the same auto-abandon UX:
25
+ *
26
+ * const drift = useOrientationDrift(captureActive);
27
+ * useEffect(() => {
28
+ * if (drift.drifted) {
29
+ * // host abandons capture (engine stop + state cleanup)
30
+ * stopCapture();
31
+ * }
32
+ * }, [drift.drifted]);
33
+ *
34
+ * return <>
35
+ * <CameraView ... />
36
+ * <OrientationDriftModal
37
+ * visible={drift.drifted}
38
+ * captureOrientation={drift.captureOrientation}
39
+ * currentOrientation={drift.currentOrientation}
40
+ * onAcknowledge={dismissDriftModal}
41
+ * />
42
+ * </>;
43
+ *
44
+ * ## Accessibility
45
+ *
46
+ * Modal `role` defaults to RN's native dialog handling. The OK
47
+ * button carries an `accessibilityRole='button'` + label. Body
48
+ * text uses `accessibilityRole='text'` so the orientation summary
49
+ * is read by VoiceOver / TalkBack.
50
+ */
51
+
52
+ import React from 'react';
53
+ import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
54
+
55
+ import { type DeviceOrientation } from './useDeviceOrientation';
56
+
57
+
58
+ export interface OrientationDriftModalProps {
59
+ /**
60
+ * Show / hide. In the `<Camera>` integration this is driven by
61
+ * the latched `drifted` flag from `useOrientationDrift`.
62
+ */
63
+ visible: boolean;
64
+
65
+ /**
66
+ * Orientation the capture started in. Shown in the body copy
67
+ * ("Capture started in PORTRAIT") so the user understands the
68
+ * baseline. `undefined` is tolerated (the modal hides the line);
69
+ * the prop is optional only to mirror `useOrientationDrift`'s
70
+ * return shape (which has `undefined` when inactive). When the
71
+ * modal is `visible`, drift detection means this was non-
72
+ * undefined at the moment the flag latched — so undefined here
73
+ * is unlikely in practice.
74
+ */
75
+ captureOrientation: DeviceOrientation | undefined;
76
+
77
+ /**
78
+ * Current device orientation. Shown in the body copy ("now
79
+ * LANDSCAPE-LEFT") so the user understands what changed.
80
+ */
81
+ currentOrientation: DeviceOrientation;
82
+
83
+ /**
84
+ * Tapped when the user dismisses with OK. By the time the
85
+ * modal renders the capture is already stopped; this callback
86
+ * exists only to clear the latched drift state so the next
87
+ * capture can start fresh.
88
+ */
89
+ onAcknowledge: () => void;
90
+ }
91
+
92
+
93
+ /**
94
+ * Pretty-print a `DeviceOrientation` for body copy. Returns the
95
+ * uppercase form because the modal copy reads as "Capture started
96
+ * in PORTRAIT, now LANDSCAPE-LEFT" — uppercase orientations stand
97
+ * out from the surrounding lowercase sentence.
98
+ */
99
+ function formatOrientation(o: DeviceOrientation): string {
100
+ switch (o) {
101
+ case 'portrait':
102
+ return 'PORTRAIT';
103
+ case 'portrait-upside-down':
104
+ return 'PORTRAIT-UPSIDE-DOWN';
105
+ case 'landscape-left':
106
+ return 'LANDSCAPE-LEFT';
107
+ case 'landscape-right':
108
+ return 'LANDSCAPE-RIGHT';
109
+ }
110
+ }
111
+
112
+
113
+ export function OrientationDriftModal(
114
+ props: OrientationDriftModalProps,
115
+ ): React.JSX.Element {
116
+ const { visible, captureOrientation, currentOrientation, onAcknowledge } = props;
117
+
118
+ return (
119
+ <Modal
120
+ visible={visible}
121
+ transparent
122
+ animationType="fade"
123
+ onRequestClose={onAcknowledge}
124
+ accessibilityLabel="Capture cancelled — orientation drift"
125
+ // v0.12.0 — see PanoramaSettingsModal for the same prop's
126
+ // rationale. Declaring all orientations prevents iOS from
127
+ // force-rotating the window to portrait when this modal opens
128
+ // mid-rotation, which would otherwise leave the underlying
129
+ // <Camera>'s ARSession in a stale-orientation state on dismiss.
130
+ supportedOrientations={[
131
+ 'portrait',
132
+ 'portrait-upside-down',
133
+ 'landscape-left',
134
+ 'landscape-right',
135
+ ]}
136
+ >
137
+ <View style={styles.backdrop}>
138
+ <View style={styles.card}>
139
+ <Text style={styles.title} accessibilityRole="header">
140
+ Capture cancelled
141
+ </Text>
142
+
143
+ <Text style={styles.body} accessibilityRole="text">
144
+ Rotation detected mid-capture. Please hold the device
145
+ steady and try again.
146
+ </Text>
147
+
148
+ {captureOrientation !== undefined && (
149
+ <Text style={styles.subBody} accessibilityRole="text">
150
+ Capture started in {formatOrientation(captureOrientation)},
151
+ now {formatOrientation(currentOrientation)}.
152
+ </Text>
153
+ )}
154
+
155
+ <Pressable
156
+ style={({ pressed }) => [
157
+ styles.button,
158
+ pressed && styles.buttonPressed,
159
+ ]}
160
+ onPress={onAcknowledge}
161
+ accessibilityRole="button"
162
+ accessibilityLabel="OK"
163
+ >
164
+ <Text style={styles.buttonLabel}>OK</Text>
165
+ </Pressable>
166
+ </View>
167
+ </View>
168
+ </Modal>
169
+ );
170
+ }
171
+
172
+
173
+ const styles = StyleSheet.create({
174
+ backdrop: {
175
+ flex: 1,
176
+ backgroundColor: 'rgba(0, 0, 0, 0.6)',
177
+ alignItems: 'center',
178
+ justifyContent: 'center',
179
+ paddingHorizontal: 32,
180
+ },
181
+ card: {
182
+ backgroundColor: '#1c1c1e',
183
+ borderRadius: 14,
184
+ paddingHorizontal: 20,
185
+ paddingVertical: 24,
186
+ width: '100%',
187
+ maxWidth: 340,
188
+ },
189
+ title: {
190
+ color: '#fff',
191
+ fontSize: 18,
192
+ fontWeight: '600',
193
+ marginBottom: 12,
194
+ textAlign: 'center',
195
+ },
196
+ body: {
197
+ color: '#e5e5ea',
198
+ fontSize: 15,
199
+ lineHeight: 21,
200
+ textAlign: 'center',
201
+ marginBottom: 12,
202
+ },
203
+ subBody: {
204
+ color: '#8e8e93',
205
+ fontSize: 13,
206
+ lineHeight: 18,
207
+ textAlign: 'center',
208
+ marginBottom: 20,
209
+ },
210
+ button: {
211
+ backgroundColor: '#0a84ff',
212
+ borderRadius: 10,
213
+ paddingVertical: 12,
214
+ alignItems: 'center',
215
+ },
216
+ buttonPressed: {
217
+ backgroundColor: '#0860c0',
218
+ },
219
+ buttonLabel: {
220
+ color: '#fff',
221
+ fontSize: 17,
222
+ fontWeight: '600',
223
+ },
224
+ });