react-native-image-stitcher 0.1.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.
- package/CHANGELOG.md +96 -0
- package/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +189 -0
- package/RNImageStitcher.podspec +76 -0
- package/android/build.gradle +224 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/cpp/CMakeLists.txt +124 -0
- package/android/src/main/cpp/image_stitcher_jni.cpp +145 -0
- package/android/src/main/cpp/keyframe_gate_jni.cpp +204 -0
- package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +426 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +960 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +2371 -0
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +256 -0
- package/android/src/main/java/io/imagestitcher/rn/QualityChecker.kt +167 -0
- package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +39 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +558 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +35 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +784 -0
- package/android/src/main/java/io/imagestitcher/rn/ar/BackgroundRenderer.kt +176 -0
- package/android/src/main/java/io/imagestitcher/rn/ar/ShaderUtil.kt +67 -0
- package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +201 -0
- package/cpp/ar_frame_pose.h +63 -0
- package/cpp/keyframe_gate.cpp +927 -0
- package/cpp/keyframe_gate.hpp +240 -0
- package/cpp/stitcher.cpp +2207 -0
- package/cpp/stitcher.hpp +275 -0
- package/dist/ar/useARSession.d.ts +102 -0
- package/dist/ar/useARSession.js +133 -0
- package/dist/camera/ARCameraView.d.ts +93 -0
- package/dist/camera/ARCameraView.js +170 -0
- package/dist/camera/Camera.d.ts +134 -0
- package/dist/camera/Camera.js +688 -0
- package/dist/camera/CameraShutter.d.ts +80 -0
- package/dist/camera/CameraShutter.js +237 -0
- package/dist/camera/CameraView.d.ts +65 -0
- package/dist/camera/CameraView.js +117 -0
- package/dist/camera/CaptureControlsBar.d.ts +87 -0
- package/dist/camera/CaptureControlsBar.js +82 -0
- package/dist/camera/CaptureHeader.d.ts +62 -0
- package/dist/camera/CaptureHeader.js +81 -0
- package/dist/camera/CapturePreview.d.ts +70 -0
- package/dist/camera/CapturePreview.js +188 -0
- package/dist/camera/CaptureStatusOverlay.d.ts +75 -0
- package/dist/camera/CaptureStatusOverlay.js +326 -0
- package/dist/camera/CaptureThumbnailStrip.d.ts +87 -0
- package/dist/camera/CaptureThumbnailStrip.js +177 -0
- package/dist/camera/IncrementalPanGuide.d.ts +83 -0
- package/dist/camera/IncrementalPanGuide.js +267 -0
- package/dist/camera/PanoramaBandOverlay.d.ts +107 -0
- package/dist/camera/PanoramaBandOverlay.js +399 -0
- package/dist/camera/PanoramaConfirmModal.d.ts +57 -0
- package/dist/camera/PanoramaConfirmModal.js +128 -0
- package/dist/camera/PanoramaGuidance.d.ts +79 -0
- package/dist/camera/PanoramaGuidance.js +246 -0
- package/dist/camera/PanoramaSettingsModal.d.ts +311 -0
- package/dist/camera/PanoramaSettingsModal.js +611 -0
- package/dist/camera/ViewportCropOverlay.d.ts +46 -0
- package/dist/camera/ViewportCropOverlay.js +67 -0
- package/dist/camera/useCapture.d.ts +111 -0
- package/dist/camera/useCapture.js +160 -0
- package/dist/camera/useDeviceOrientation.d.ts +48 -0
- package/dist/camera/useDeviceOrientation.js +131 -0
- package/dist/camera/useVideoCapture.d.ts +79 -0
- package/dist/camera/useVideoCapture.js +151 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +39 -0
- package/dist/quality/normaliseOrientation.d.ts +36 -0
- package/dist/quality/normaliseOrientation.js +62 -0
- package/dist/quality/runQualityCheck.d.ts +41 -0
- package/dist/quality/runQualityCheck.js +98 -0
- package/dist/sensors/useIMUTranslationGate.d.ts +70 -0
- package/dist/sensors/useIMUTranslationGate.js +235 -0
- package/dist/stitching/IncrementalStitcherView.d.ts +41 -0
- package/dist/stitching/IncrementalStitcherView.js +157 -0
- package/dist/stitching/incremental.d.ts +930 -0
- package/dist/stitching/incremental.js +133 -0
- package/dist/stitching/stitchFrames.d.ts +55 -0
- package/dist/stitching/stitchFrames.js +56 -0
- package/dist/stitching/stitchVideo.d.ts +119 -0
- package/dist/stitching/stitchVideo.js +57 -0
- package/dist/stitching/useIncrementalJSDriver.d.ts +74 -0
- package/dist/stitching/useIncrementalJSDriver.js +199 -0
- package/dist/stitching/useIncrementalStitcher.d.ts +58 -0
- package/dist/stitching/useIncrementalStitcher.js +172 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.js +15 -0
- package/ios/Package.swift +72 -0
- package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +33 -0
- package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +40 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.m +55 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +149 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +2727 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +85 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +625 -0
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +328 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +141 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +278 -0
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +473 -0
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +1326 -0
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +97 -0
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +296 -0
- package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +103 -0
- package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +3285 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +238 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +1880 -0
- package/ios/Sources/RNImageStitcher/QualityChecker.swift +252 -0
- package/ios/Sources/RNImageStitcher/QualityCheckerBridge.m +26 -0
- package/ios/Sources/RNImageStitcher/QualityCheckerBridge.swift +72 -0
- package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +114 -0
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +1111 -0
- package/ios/Sources/RNImageStitcher/Stitcher.swift +243 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.m +28 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.swift +246 -0
- package/package.json +73 -0
- package/react-native.config.js +34 -0
- package/scripts/opencv-version.txt +1 -0
- package/scripts/postinstall-fetch-binaries.js +286 -0
- package/src/ar/useARSession.ts +210 -0
- package/src/camera/.gitkeep +0 -0
- package/src/camera/ARCameraView.tsx +256 -0
- package/src/camera/Camera.tsx +1053 -0
- package/src/camera/CameraShutter.tsx +292 -0
- package/src/camera/CameraView.tsx +157 -0
- package/src/camera/CaptureControlsBar.tsx +204 -0
- package/src/camera/CaptureHeader.tsx +184 -0
- package/src/camera/CapturePreview.tsx +318 -0
- package/src/camera/CaptureStatusOverlay.tsx +391 -0
- package/src/camera/CaptureThumbnailStrip.tsx +277 -0
- package/src/camera/IncrementalPanGuide.tsx +328 -0
- package/src/camera/PanoramaBandOverlay.tsx +498 -0
- package/src/camera/PanoramaConfirmModal.tsx +206 -0
- package/src/camera/PanoramaGuidance.tsx +327 -0
- package/src/camera/PanoramaSettingsModal.tsx +1357 -0
- package/src/camera/ViewportCropOverlay.tsx +81 -0
- package/src/camera/useCapture.ts +279 -0
- package/src/camera/useDeviceOrientation.ts +140 -0
- package/src/camera/useVideoCapture.ts +236 -0
- package/src/index.ts +53 -0
- package/src/quality/.gitkeep +0 -0
- package/src/quality/normaliseOrientation.ts +79 -0
- package/src/quality/runQualityCheck.ts +131 -0
- package/src/sensors/useIMUTranslationGate.ts +347 -0
- package/src/stitching/.gitkeep +0 -0
- package/src/stitching/IncrementalStitcherView.tsx +198 -0
- package/src/stitching/incremental.ts +1021 -0
- package/src/stitching/stitchFrames.ts +88 -0
- package/src/stitching/stitchVideo.ts +153 -0
- package/src/stitching/useIncrementalJSDriver.ts +273 -0
- package/src/stitching/useIncrementalStitcher.ts +252 -0
- package/src/types.ts +78 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* PanoramaBandOverlay — V16 Phase 2 (merged band + strip).
|
|
4
|
+
*
|
|
5
|
+
* SINGLE source of truth for the live "progress strip" that sits on
|
|
6
|
+
* top of the camera preview during a panorama hold. Replaces what
|
|
7
|
+
* was previously TWO components rendered side-by-side:
|
|
8
|
+
*
|
|
9
|
+
* 1. live per-keyframe thumbnail strip — fed by accepted-frame URIs
|
|
10
|
+
* (batch-keyframe engine) OR by
|
|
11
|
+
* periodic vision-camera snapshots.
|
|
12
|
+
* 2. <PanoramaBandOverlay /> — a single cumulative-panorama
|
|
13
|
+
* thumbnail with a "fill ratio"
|
|
14
|
+
* bar growing with the pan.
|
|
15
|
+
*
|
|
16
|
+
* The split made the UI visually noisy AND made it differ between
|
|
17
|
+
* platforms when one side emitted keyframe events and the other
|
|
18
|
+
* didn't. V16 Phase 2 collapses them into ONE component that:
|
|
19
|
+
*
|
|
20
|
+
* • Renders a horizontally-scrolling list of per-keyframe
|
|
21
|
+
* thumbnails when `frameUris` is non-empty (batch-keyframe
|
|
22
|
+
* mode). Each frame the KeyframeGate accepts shows up as a
|
|
23
|
+
* mini-thumb.
|
|
24
|
+
*
|
|
25
|
+
* • Falls back to a SINGLE cumulative-panorama thumbnail (the
|
|
26
|
+
* V12.14.9 fill-ratio behaviour) when `frameUris` is empty —
|
|
27
|
+
* i.e. the live-stitching engines that don't surface
|
|
28
|
+
* per-keyframe paths. This preserves the existing visual for
|
|
29
|
+
* hybrid / firstwins / firstwins-rectilinear engines.
|
|
30
|
+
*
|
|
31
|
+
* • Edge-pinned to the BOTTOM of the camera area in portrait, and
|
|
32
|
+
* to the user's RIGHT in landscape (which corresponds to
|
|
33
|
+
* JS-bottom under the app's portrait-lock). Both anchors keep
|
|
34
|
+
* the band out of the centre of the scene the operator is
|
|
35
|
+
* framing.
|
|
36
|
+
*
|
|
37
|
+
* • Trailing arrow points along the pan axis (→ in portrait, ← in
|
|
38
|
+
* landscape-left's user perception). Arrow always sits at the
|
|
39
|
+
* pan-END side, so the LATEST keyframe abuts the arrow.
|
|
40
|
+
*
|
|
41
|
+
* • Auto-scrolls a `<ScrollView>` so the latest keyframe stays
|
|
42
|
+
* visible regardless of how many frames have been accumulated.
|
|
43
|
+
*
|
|
44
|
+
* Empty-state intentional non-design:
|
|
45
|
+
* The KeyframeGate force-accepts the FIRST frame of every capture
|
|
46
|
+
* (see C++ `AcceptFirstAnchoredOnPlane` / `AcceptFirstNoPlane` in
|
|
47
|
+
* keyframe_gate.cpp). By the time the operator's perceived "the
|
|
48
|
+
* band appeared", we already have at least one thumb/snapshot in
|
|
49
|
+
* flight. We therefore don't render any "no frames yet"
|
|
50
|
+
* placeholder — the empty period is sub-perceptual.
|
|
51
|
+
*
|
|
52
|
+
* Why this component is in react-native-image-stitcher (not host):
|
|
53
|
+
* It's the same JSX shipped to iOS and Android. Differences in
|
|
54
|
+
* what shows up come only from native-emitted data
|
|
55
|
+
* (`state.batchKeyframeThumbnailPath` / `state.panoramaPath`),
|
|
56
|
+
* not from per-platform component code. That's exactly the parity
|
|
57
|
+
* property the user wants: "the UI should not differ between iOS
|
|
58
|
+
* and Android — it's the same UI reused".
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
import React, { useCallback, useMemo, useRef } from 'react';
|
|
62
|
+
import {
|
|
63
|
+
Image,
|
|
64
|
+
ScrollView,
|
|
65
|
+
StyleSheet,
|
|
66
|
+
Text,
|
|
67
|
+
View,
|
|
68
|
+
type ViewStyle,
|
|
69
|
+
} from 'react-native';
|
|
70
|
+
import type { IncrementalState } from '../stitching/incremental';
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 2026-05-18 (Issue #3 fix) — 4-way capture orientation classifier.
|
|
75
|
+
* Replaces the 2-way `state.isLandscape` boolean which couldn't
|
|
76
|
+
* distinguish landscape-LEFT (home button on user's right) from
|
|
77
|
+
* landscape-RIGHT (home button on user's left). Required because
|
|
78
|
+
* the JS-coordinate mapping to user-perceived directions inverts
|
|
79
|
+
* between the two landscape rotations — `flexDirection: 'row'`
|
|
80
|
+
* gives oldest-at-user-top in landscape-LEFT but oldest-at-user-
|
|
81
|
+
* bottom in landscape-RIGHT, so we need to branch the layout.
|
|
82
|
+
*/
|
|
83
|
+
export type BandCaptureOrientation =
|
|
84
|
+
| 'portrait'
|
|
85
|
+
| 'portrait-upside-down'
|
|
86
|
+
| 'landscape-left'
|
|
87
|
+
| 'landscape-right';
|
|
88
|
+
|
|
89
|
+
export interface PanoramaBandOverlayProps {
|
|
90
|
+
/**
|
|
91
|
+
* Latest engine state. Pass `useIncrementalStitcher().state`.
|
|
92
|
+
* Used for single-thumb fallback URI and fill-ratio when no
|
|
93
|
+
* per-keyframe URIs are provided. `state.isLandscape` is now
|
|
94
|
+
* superseded by `captureOrientation` below for layout selection.
|
|
95
|
+
*/
|
|
96
|
+
state: IncrementalState | null;
|
|
97
|
+
/**
|
|
98
|
+
* Optional list of per-keyframe thumbnail URIs accumulated by the
|
|
99
|
+
* host as the native batch-keyframe engine emits
|
|
100
|
+
* `batchKeyframeThumbnailPath` events. When non-empty, the band
|
|
101
|
+
* renders these as a scrolling mini-thumb strip. When empty or
|
|
102
|
+
* undefined, the band falls back to the single cumulative-panorama
|
|
103
|
+
* thumbnail (legacy live-engine visual).
|
|
104
|
+
*
|
|
105
|
+
* Caller should cap the list length itself if needed (e.g. the
|
|
106
|
+
* AuditCaptureScreen already trims at 24 entries). This component
|
|
107
|
+
* applies an internal hard cap as a safety net so a runaway
|
|
108
|
+
* emission doesn't blow up the scroll view.
|
|
109
|
+
*/
|
|
110
|
+
frameUris?: string[];
|
|
111
|
+
/**
|
|
112
|
+
* 2026-05-18 (Issue #3) — capture orientation passed from the host.
|
|
113
|
+
* Drives a 4-way layout switch so the band reads correctly in
|
|
114
|
+
* either landscape rotation (the 2-way `state.isLandscape` boolean
|
|
115
|
+
* collapses landscape-LEFT and landscape-RIGHT to the same render
|
|
116
|
+
* path, which inverts the user's perceived "oldest-top, grows
|
|
117
|
+
* down" intent in one of them). Pass
|
|
118
|
+
* `panoramaSettings.captureOrientation` from the host. Defaults
|
|
119
|
+
* to `'portrait'` when omitted (back-compat).
|
|
120
|
+
*/
|
|
121
|
+
captureOrientation?: BandCaptureOrientation;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
// ── Layout constants — tuned to read clearly at arm's length ────────
|
|
126
|
+
const BAND_PADDING = 6;
|
|
127
|
+
const BAND_THICKNESS = 64;
|
|
128
|
+
const ARROW_TRACK_LEN = 44; // fixed slot for the arrow glyph
|
|
129
|
+
const SINGLE_THUMB_INNER = BAND_THICKNESS - BAND_PADDING * 2;
|
|
130
|
+
const SINGLE_THUMB_MAX_PAN_LEN = 240;
|
|
131
|
+
const MULTI_THUMB_LEN = 48;
|
|
132
|
+
const MULTI_THUMB_GAP = 4;
|
|
133
|
+
const MULTI_THUMB_HARD_CAP = 32; // safety net; host typically caps at 24
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
type LayoutKind = 'portrait' | 'landscape';
|
|
137
|
+
interface Layout {
|
|
138
|
+
kind: LayoutKind;
|
|
139
|
+
/** Outer container style — positioning + flexDirection. */
|
|
140
|
+
band: ViewStyle;
|
|
141
|
+
/** Direction used by both the outer band AND the scroll content. */
|
|
142
|
+
flexDirection: 'row' | 'row-reverse';
|
|
143
|
+
/** Unicode arrow pointing along the user-perceived pan axis. */
|
|
144
|
+
arrowGlyph: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolve band layout from capture orientation. 2026-05-18 (Issue #3)
|
|
150
|
+
* — uses the 4-way `BandCaptureOrientation` instead of the 2-way
|
|
151
|
+
* `state.isLandscape` so we can pick the right flex direction +
|
|
152
|
+
* arrow glyph in EACH landscape rotation.
|
|
153
|
+
*
|
|
154
|
+
* The two landscape rotations require different JS-coordinate setups
|
|
155
|
+
* because the phone tilts the JS coordinate system relative to the
|
|
156
|
+
* user differently:
|
|
157
|
+
*
|
|
158
|
+
* LANDSCAPE-LEFT (Apple: home indicator on user's RIGHT; phone
|
|
159
|
+
* rotated 90° CCW from portrait).
|
|
160
|
+
* JS-left = user-top
|
|
161
|
+
* JS-right = user-bottom
|
|
162
|
+
* Band at JS-bottom edge appears on user's RIGHT edge.
|
|
163
|
+
* For "oldest at user-top, newest at user-bottom":
|
|
164
|
+
* flexDirection = 'row' (array[0] at JS-left = user-top).
|
|
165
|
+
* For arrow appearing as user-DOWN-arrow:
|
|
166
|
+
* glyph `←` (rotated 90° CCW = points user-down).
|
|
167
|
+
*
|
|
168
|
+
* LANDSCAPE-RIGHT (Apple: home indicator on user's LEFT; phone
|
|
169
|
+
* rotated 90° CW from portrait).
|
|
170
|
+
* JS-left = user-bottom
|
|
171
|
+
* JS-right = user-top
|
|
172
|
+
* Band at JS-TOP edge appears on user's RIGHT edge (so we move
|
|
173
|
+
* the band to JS-top here, not JS-bottom).
|
|
174
|
+
* For "oldest at user-top, newest at user-bottom":
|
|
175
|
+
* flexDirection = 'row-reverse' (array[0] at JS-right = user-top).
|
|
176
|
+
* For arrow appearing as user-DOWN-arrow:
|
|
177
|
+
* glyph `→` (rotated 90° CW = points user-down).
|
|
178
|
+
*
|
|
179
|
+
* PORTRAIT (and portrait-upside-down — collapsed because the band's
|
|
180
|
+
* bottom-anchored position remains sensible either way):
|
|
181
|
+
* Band at JS-bottom = user-bottom. Row left-to-right. Arrow `→`
|
|
182
|
+
* reads as user-right-arrow (pointing along the horizontal pan
|
|
183
|
+
* direction).
|
|
184
|
+
*/
|
|
185
|
+
function layoutFor(orientation: BandCaptureOrientation): Layout {
|
|
186
|
+
const commonInner: ViewStyle = {
|
|
187
|
+
alignItems: 'center',
|
|
188
|
+
paddingHorizontal: BAND_PADDING,
|
|
189
|
+
paddingVertical: BAND_PADDING,
|
|
190
|
+
backgroundColor: 'rgba(0, 0, 0, 0.55)',
|
|
191
|
+
};
|
|
192
|
+
// 2026-05-19 — repositioned tethered to the shutter (no longer
|
|
193
|
+
// edge-pinned via absolute positioning). The parent stack in
|
|
194
|
+
// Camera.tsx now puts this band in a vertical column immediately
|
|
195
|
+
// above the shutter row. The SDK's orientation lock holds the UI
|
|
196
|
+
// in portrait regardless of physical device rotation, so the band
|
|
197
|
+
// is ALWAYS a horizontal strip in JS coordinates. In landscape
|
|
198
|
+
// (physically held), the rendered strip visually appears as a
|
|
199
|
+
// vertical column on the viewport-side of the shutter.
|
|
200
|
+
//
|
|
201
|
+
// What still varies by physical orientation: the order in which
|
|
202
|
+
// thumbnails should appear so newest is at the user-perceived
|
|
203
|
+
// "leading edge" of the pan. That's the flexDirection (row vs
|
|
204
|
+
// row-reverse) and the arrow glyph.
|
|
205
|
+
if (orientation === 'landscape-left') {
|
|
206
|
+
// Phone rotated 90° CCW from portrait (home indicator on the
|
|
207
|
+
// user's RIGHT). With UI orientation-locked to portrait:
|
|
208
|
+
// JS-left (band horizontal start) = user-BOTTOM
|
|
209
|
+
// JS-right (band horizontal end) = user-TOP
|
|
210
|
+
// For the canonical "oldest at user-TOP, growth toward user-
|
|
211
|
+
// BOTTOM" reading direction the monorepo established, we want:
|
|
212
|
+
// array[0] (oldest) at user-TOP = JS-rightmost
|
|
213
|
+
// newest at user-BOTTOM = JS-leftmost
|
|
214
|
+
// → flexDirection: 'row-reverse' (array[0] at JS-rightmost)
|
|
215
|
+
return {
|
|
216
|
+
kind: 'landscape',
|
|
217
|
+
band: {
|
|
218
|
+
marginHorizontal: 16,
|
|
219
|
+
marginVertical: 8,
|
|
220
|
+
height: BAND_THICKNESS,
|
|
221
|
+
flexDirection: 'row-reverse',
|
|
222
|
+
...commonInner,
|
|
223
|
+
},
|
|
224
|
+
flexDirection: 'row-reverse',
|
|
225
|
+
arrowGlyph: '←',
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
if (orientation === 'landscape-right') {
|
|
229
|
+
// Phone rotated 90° CW from portrait (home indicator on the
|
|
230
|
+
// user's LEFT). Mirror of landscape-left:
|
|
231
|
+
// JS-left = user-TOP
|
|
232
|
+
// JS-right = user-BOTTOM
|
|
233
|
+
// For "oldest at user-TOP, newest at user-BOTTOM":
|
|
234
|
+
// array[0] (oldest) at user-TOP = JS-leftmost
|
|
235
|
+
// → flexDirection: 'row' (array[0] at JS-leftmost)
|
|
236
|
+
return {
|
|
237
|
+
kind: 'landscape',
|
|
238
|
+
band: {
|
|
239
|
+
marginHorizontal: 16,
|
|
240
|
+
marginVertical: 8,
|
|
241
|
+
height: BAND_THICKNESS,
|
|
242
|
+
flexDirection: 'row',
|
|
243
|
+
...commonInner,
|
|
244
|
+
},
|
|
245
|
+
flexDirection: 'row',
|
|
246
|
+
arrowGlyph: '→',
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
// portrait / portrait-upside-down / default. Held portrait, pan
|
|
250
|
+
// is horizontal left→right (or right→left for left-handed scans;
|
|
251
|
+
// the band doesn't enforce a direction). newest at JS-rightmost.
|
|
252
|
+
return {
|
|
253
|
+
kind: 'portrait',
|
|
254
|
+
band: {
|
|
255
|
+
marginHorizontal: 16,
|
|
256
|
+
marginVertical: 8,
|
|
257
|
+
height: BAND_THICKNESS,
|
|
258
|
+
flexDirection: 'row',
|
|
259
|
+
...commonInner,
|
|
260
|
+
},
|
|
261
|
+
flexDirection: 'row',
|
|
262
|
+
arrowGlyph: '→',
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
export function PanoramaBandOverlay({
|
|
268
|
+
state,
|
|
269
|
+
frameUris,
|
|
270
|
+
captureOrientation,
|
|
271
|
+
}: PanoramaBandOverlayProps): React.JSX.Element | null {
|
|
272
|
+
// 2026-05-18 (Issue #3 fix) — orientation source priority:
|
|
273
|
+
// 1. `captureOrientation` prop from the host (4-way; correct
|
|
274
|
+
// for landscape-left vs landscape-right disambiguation).
|
|
275
|
+
// 2. Fallback to `state.isLandscape` (2-way; collapses both
|
|
276
|
+
// landscape rotations to landscape-left semantics).
|
|
277
|
+
// 3. Default `portrait` (the band's bottom-anchor still reads
|
|
278
|
+
// sensibly before any orientation info is available).
|
|
279
|
+
const resolvedOrientation: BandCaptureOrientation =
|
|
280
|
+
captureOrientation
|
|
281
|
+
?? (state?.isLandscape ? 'landscape-left' : 'portrait');
|
|
282
|
+
const layout = useMemo(
|
|
283
|
+
() => layoutFor(resolvedOrientation),
|
|
284
|
+
[resolvedOrientation],
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const scrollRef = useRef<ScrollView | null>(null);
|
|
288
|
+
|
|
289
|
+
// Trim incoming URIs to a hard cap. The host already caps at 24
|
|
290
|
+
// (AuditCaptureScreen) but defence-in-depth keeps the ScrollView
|
|
291
|
+
// bounded if a different host forgets to. Slice from the END so
|
|
292
|
+
// we keep the MOST RECENT N — older frames slide off the start.
|
|
293
|
+
const cappedFrameUris = useMemo(() => {
|
|
294
|
+
if (!frameUris || frameUris.length === 0) return [];
|
|
295
|
+
return frameUris.length > MULTI_THUMB_HARD_CAP
|
|
296
|
+
? frameUris.slice(frameUris.length - MULTI_THUMB_HARD_CAP)
|
|
297
|
+
: frameUris;
|
|
298
|
+
}, [frameUris]);
|
|
299
|
+
|
|
300
|
+
const hasMultiThumb = cappedFrameUris.length > 0;
|
|
301
|
+
|
|
302
|
+
// Auto-scroll on content-size change.
|
|
303
|
+
//
|
|
304
|
+
// 2026-05-18 (Issue #4 fix-b): the direction depends on flex
|
|
305
|
+
// direction. In `row` (portrait, landscape-right) the LATEST
|
|
306
|
+
// item is at JS-rightmost → scrollToEnd shows it. In
|
|
307
|
+
// `row-reverse` (landscape-left) the latest is at JS-leftmost →
|
|
308
|
+
// scrollTo({x: 0}) shows it. The earlier always-scrollToEnd
|
|
309
|
+
// behaviour scrolled to OLDEST in row-reverse, which hid the
|
|
310
|
+
// just-captured frame off-screen at user-bottom.
|
|
311
|
+
const onContentSizeChange = useCallback(() => {
|
|
312
|
+
const sv = scrollRef.current;
|
|
313
|
+
if (!sv) return;
|
|
314
|
+
if (layout.flexDirection === 'row-reverse') {
|
|
315
|
+
sv.scrollTo({ x: 0, y: 0, animated: false });
|
|
316
|
+
} else {
|
|
317
|
+
sv.scrollToEnd({ animated: false });
|
|
318
|
+
}
|
|
319
|
+
}, [layout.flexDirection]);
|
|
320
|
+
|
|
321
|
+
// ── Single cumulative thumbnail (live-engine fallback) ──────────
|
|
322
|
+
//
|
|
323
|
+
// Same fill-ratio math as V12.14.9. Kept so live-stitching engines
|
|
324
|
+
// (hybrid / firstwins / firstwins-rectilinear / firstwins-zoomed)
|
|
325
|
+
// that don't emit per-keyframe URIs still get a useful
|
|
326
|
+
// progress-thumbnail UX — the thumb widens proportionally as the
|
|
327
|
+
// operator pans further.
|
|
328
|
+
const cumulativeUri = useMemo(() => {
|
|
329
|
+
if (!state?.panoramaPath) return null;
|
|
330
|
+
return `file://${state.panoramaPath}?v=${state.acceptedCount}`;
|
|
331
|
+
}, [state?.panoramaPath, state?.acceptedCount]);
|
|
332
|
+
|
|
333
|
+
const fillRatio = useMemo(() => {
|
|
334
|
+
if (!state?.paintedExtent || !state?.panExtent) return 0;
|
|
335
|
+
return Math.max(0, Math.min(1, state.paintedExtent / state.panExtent));
|
|
336
|
+
}, [state?.paintedExtent, state?.panExtent]);
|
|
337
|
+
|
|
338
|
+
const singleThumbPanLen = useMemo(() => {
|
|
339
|
+
return Math.max(SINGLE_THUMB_INNER, SINGLE_THUMB_MAX_PAN_LEN * fillRatio);
|
|
340
|
+
}, [fillRatio]);
|
|
341
|
+
|
|
342
|
+
// V12.14.9 — rotate the panorama image 90° in landscape mode so
|
|
343
|
+
// the captured scene reads UPRIGHT to the user in landscape head-up
|
|
344
|
+
// view. See original comment in the pre-V16 PanoramaBandOverlay for
|
|
345
|
+
// the full reasoning. Portrait+horizontal-pan mode (the other
|
|
346
|
+
// supported mode) doesn't need rotation.
|
|
347
|
+
//
|
|
348
|
+
// 2026-05-18 (Issue #3) — derive from `resolvedOrientation` instead
|
|
349
|
+
// of the deprecated 2-way `isLandscape`. In landscape-RIGHT we
|
|
350
|
+
// rotate −90° so the captured scene still reads upright (the
|
|
351
|
+
// opposite sense from landscape-LEFT).
|
|
352
|
+
const singleImageStyle = useMemo(
|
|
353
|
+
() => {
|
|
354
|
+
if (resolvedOrientation === 'landscape-left') {
|
|
355
|
+
return [StyleSheet.absoluteFill, { transform: [{ rotate: '90deg' }] }];
|
|
356
|
+
}
|
|
357
|
+
if (resolvedOrientation === 'landscape-right') {
|
|
358
|
+
return [StyleSheet.absoluteFill, { transform: [{ rotate: '-90deg' }] }];
|
|
359
|
+
}
|
|
360
|
+
return StyleSheet.absoluteFill;
|
|
361
|
+
},
|
|
362
|
+
[resolvedOrientation],
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
return (
|
|
366
|
+
<View pointerEvents="none" style={[styles.bandBase, layout.band]}>
|
|
367
|
+
{hasMultiThumb ? (
|
|
368
|
+
// Multi-thumb path: one image per accepted keyframe, scrolling
|
|
369
|
+
// horizontally (in JS-coords) within the band. Content
|
|
370
|
+
// flex-direction matches the outer band so OLDEST is at the
|
|
371
|
+
// pan-start side and LATEST sits next to the arrow.
|
|
372
|
+
//
|
|
373
|
+
// 2026-05-18 (Issue A — arrow placement) — the arrow is the
|
|
374
|
+
// LAST child of contentContainer (after the thumbnail map)
|
|
375
|
+
// so it flows with the scroll content and always sits
|
|
376
|
+
// adjacent to the newest thumbnail. Previously it was a
|
|
377
|
+
// sibling of the ScrollView at the band's far end, which
|
|
378
|
+
// looked detached when there were only a few thumbnails.
|
|
379
|
+
<ScrollView
|
|
380
|
+
ref={scrollRef}
|
|
381
|
+
horizontal
|
|
382
|
+
showsHorizontalScrollIndicator={false}
|
|
383
|
+
showsVerticalScrollIndicator={false}
|
|
384
|
+
style={styles.thumbScroll}
|
|
385
|
+
contentContainerStyle={[
|
|
386
|
+
styles.thumbScrollContent,
|
|
387
|
+
{ flexDirection: layout.flexDirection },
|
|
388
|
+
]}
|
|
389
|
+
onContentSizeChange={onContentSizeChange}
|
|
390
|
+
>
|
|
391
|
+
{cappedFrameUris.map((uri, idx) => (
|
|
392
|
+
<Image
|
|
393
|
+
// Composite key: idx prevents collisions if the same path
|
|
394
|
+
// ever gets re-emitted (shouldn't happen but cheap to be
|
|
395
|
+
// defensive). URI segment helps RN's image cache key.
|
|
396
|
+
key={`${idx}-${uri}`}
|
|
397
|
+
source={{ uri }}
|
|
398
|
+
style={styles.multiThumb}
|
|
399
|
+
resizeMode="cover"
|
|
400
|
+
fadeDuration={0}
|
|
401
|
+
/>
|
|
402
|
+
))}
|
|
403
|
+
<View style={styles.arrowTrack}>
|
|
404
|
+
<Text style={styles.arrowGlyph}>{layout.arrowGlyph}</Text>
|
|
405
|
+
</View>
|
|
406
|
+
</ScrollView>
|
|
407
|
+
) : (
|
|
408
|
+
<>
|
|
409
|
+
{/* Single-thumb path: cumulative panorama image, width
|
|
410
|
+
* grows with the pan extent. Visually identical to
|
|
411
|
+
* pre-V16 PanoramaBandOverlay so live-engine UX is
|
|
412
|
+
* unchanged. Arrow stays a sibling here so it sits at
|
|
413
|
+
* the band's end (the single-thumb View is fixed-width
|
|
414
|
+
* so the layout is naturally "thumb + arrow"). */}
|
|
415
|
+
<View
|
|
416
|
+
style={[
|
|
417
|
+
styles.thumbBox,
|
|
418
|
+
{ width: singleThumbPanLen, height: SINGLE_THUMB_INNER },
|
|
419
|
+
]}
|
|
420
|
+
>
|
|
421
|
+
{cumulativeUri ? (
|
|
422
|
+
<Image
|
|
423
|
+
key={state?.acceptedCount ?? 0}
|
|
424
|
+
source={{ uri: cumulativeUri }}
|
|
425
|
+
style={singleImageStyle}
|
|
426
|
+
resizeMode="cover"
|
|
427
|
+
fadeDuration={0}
|
|
428
|
+
/>
|
|
429
|
+
) : null}
|
|
430
|
+
</View>
|
|
431
|
+
<View style={styles.arrowTrack}>
|
|
432
|
+
<Text style={styles.arrowGlyph}>{layout.arrowGlyph}</Text>
|
|
433
|
+
</View>
|
|
434
|
+
</>
|
|
435
|
+
)}
|
|
436
|
+
</View>
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
const styles = StyleSheet.create({
|
|
442
|
+
// Properties common to every layout — uniform border-radius so the
|
|
443
|
+
// band reads as a single capsule regardless of which edge it's
|
|
444
|
+
// anchored to. Orientation-specific values (position, flexDirection,
|
|
445
|
+
// sizing) come from `layoutFor()`.
|
|
446
|
+
bandBase: {
|
|
447
|
+
borderRadius: 12,
|
|
448
|
+
},
|
|
449
|
+
thumbScroll: {
|
|
450
|
+
flex: 1,
|
|
451
|
+
},
|
|
452
|
+
thumbScrollContent: {
|
|
453
|
+
alignItems: 'center',
|
|
454
|
+
paddingHorizontal: BAND_PADDING,
|
|
455
|
+
// 2026-05-18 (Issue #4 fix-a): contentContainer must FILL the
|
|
456
|
+
// ScrollView width so flexDirection aligns items at the correct
|
|
457
|
+
// end of the viewport. Without flexGrow, contentContainer
|
|
458
|
+
// takes the natural width of its items (e.g. 150 px for 3
|
|
459
|
+
// thumbs) and anchors at JS-leftmost of the ScrollView, leaving
|
|
460
|
+
// a big empty gap on JS-right. In landscape-left that gap is
|
|
461
|
+
// on user-TOP — exactly what the operator reports as "thumbs
|
|
462
|
+
// clump at the bottom". flexGrow:1 makes the contentContainer
|
|
463
|
+
// span the viewport so items align at the END of the row-
|
|
464
|
+
// direction (JS-right for `row`, JS-left for `row-reverse`).
|
|
465
|
+
flexGrow: 1,
|
|
466
|
+
},
|
|
467
|
+
multiThumb: {
|
|
468
|
+
width: MULTI_THUMB_LEN,
|
|
469
|
+
height: MULTI_THUMB_LEN,
|
|
470
|
+
borderRadius: 4,
|
|
471
|
+
// marginHorizontal so the gap applies in both `row` and
|
|
472
|
+
// `row-reverse` directions identically; flex layout collapses
|
|
473
|
+
// adjacent margins, giving us a single inter-thumb gap.
|
|
474
|
+
marginHorizontal: MULTI_THUMB_GAP / 2,
|
|
475
|
+
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
|
476
|
+
borderWidth: 1,
|
|
477
|
+
borderColor: 'rgba(255, 255, 255, 0.55)',
|
|
478
|
+
},
|
|
479
|
+
thumbBox: {
|
|
480
|
+
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
|
481
|
+
borderWidth: 1,
|
|
482
|
+
borderColor: 'rgba(255, 255, 255, 0.55)',
|
|
483
|
+
borderRadius: 4,
|
|
484
|
+
overflow: 'hidden',
|
|
485
|
+
},
|
|
486
|
+
arrowTrack: {
|
|
487
|
+
width: ARROW_TRACK_LEN,
|
|
488
|
+
alignItems: 'center',
|
|
489
|
+
justifyContent: 'center',
|
|
490
|
+
paddingHorizontal: BAND_PADDING,
|
|
491
|
+
},
|
|
492
|
+
arrowGlyph: {
|
|
493
|
+
color: 'rgba(255, 255, 255, 0.9)',
|
|
494
|
+
fontSize: 28,
|
|
495
|
+
lineHeight: 28,
|
|
496
|
+
fontWeight: '600',
|
|
497
|
+
},
|
|
498
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* PanoramaConfirmModal — post-stitch review screen.
|
|
4
|
+
*
|
|
5
|
+
* ┌──────────────────────────────────────────────────────────┐
|
|
6
|
+
* │ │
|
|
7
|
+
* │ ┌──────────────────────────────┐ │
|
|
8
|
+
* │ │ stitched panorama │ │
|
|
9
|
+
* │ │ (resizeMode=contain) │ │
|
|
10
|
+
* │ └──────────────────────────────┘ │
|
|
11
|
+
* │ │
|
|
12
|
+
* │ [✕ Discard] [↺ Retry] [✓ Save] │
|
|
13
|
+
* └──────────────────────────────────────────────────────────┘
|
|
14
|
+
*
|
|
15
|
+
* Why this exists
|
|
16
|
+
* Without it, a panorama lands directly into the audit's
|
|
17
|
+
* thumbnail strip — operator only finds out it's bad once they
|
|
18
|
+
* tap the thumbnail, or worse, never. The confirm step is the
|
|
19
|
+
* safety net iOS' native panorama UX gives by default.
|
|
20
|
+
*
|
|
21
|
+
* Three actions, three callbacks
|
|
22
|
+
* - Save: host persists the panorama (writes Capture row, etc).
|
|
23
|
+
* - Retry: host throws away the panorama and re-enters the
|
|
24
|
+
* capture flow. Good UX is to keep the camera
|
|
25
|
+
* ready so the operator can immediately re-pan.
|
|
26
|
+
* - Discard: host throws away the panorama and returns to the
|
|
27
|
+
* capture flow without re-entering. Same as Retry
|
|
28
|
+
* minus the "ready to record" hint.
|
|
29
|
+
*
|
|
30
|
+
* The modal is purely presentational — it doesn't know about
|
|
31
|
+
* WatermelonDB, file paths, or any host-domain concept beyond the
|
|
32
|
+
* panorama URI + dimensions to display.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import React from 'react';
|
|
36
|
+
import {
|
|
37
|
+
Image,
|
|
38
|
+
Modal,
|
|
39
|
+
Pressable,
|
|
40
|
+
StyleSheet,
|
|
41
|
+
Text,
|
|
42
|
+
View,
|
|
43
|
+
} from 'react-native';
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
export interface PanoramaConfirmModalProps {
|
|
47
|
+
/**
|
|
48
|
+
* Modal visibility. When true, the modal animates in over the
|
|
49
|
+
* current screen. Drive this from the host's "stitch result is
|
|
50
|
+
* pending review" state.
|
|
51
|
+
*/
|
|
52
|
+
visible: boolean;
|
|
53
|
+
/** file:// URI of the stitched panorama to preview. */
|
|
54
|
+
panoramaUri: string;
|
|
55
|
+
/** Pixel width of the panorama (for the preview's aspect ratio). */
|
|
56
|
+
width: number;
|
|
57
|
+
/** Pixel height of the panorama. */
|
|
58
|
+
height: number;
|
|
59
|
+
/** User confirmed — host should persist the panorama. */
|
|
60
|
+
onSave: () => void;
|
|
61
|
+
/** User wants to re-record — host should drop and reopen camera. */
|
|
62
|
+
onRetry: () => void;
|
|
63
|
+
/** User wants to discard without re-recording. */
|
|
64
|
+
onDiscard: () => void;
|
|
65
|
+
/** Optional override for the title (defaults to "Review panorama"). */
|
|
66
|
+
title?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
export function PanoramaConfirmModal({
|
|
71
|
+
visible,
|
|
72
|
+
panoramaUri,
|
|
73
|
+
width,
|
|
74
|
+
height,
|
|
75
|
+
onSave,
|
|
76
|
+
onRetry,
|
|
77
|
+
onDiscard,
|
|
78
|
+
title = 'Review panorama',
|
|
79
|
+
}: PanoramaConfirmModalProps): React.JSX.Element {
|
|
80
|
+
// The aspect-ratio-locked image lets `<Image>` size itself
|
|
81
|
+
// correctly inside a flexible container without us having to
|
|
82
|
+
// measure the modal's available area on every layout change.
|
|
83
|
+
const aspectRatio = width > 0 && height > 0 ? width / height : 16 / 9;
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Modal
|
|
87
|
+
visible={visible}
|
|
88
|
+
animationType="fade"
|
|
89
|
+
transparent
|
|
90
|
+
statusBarTranslucent
|
|
91
|
+
onRequestClose={onDiscard}
|
|
92
|
+
>
|
|
93
|
+
<View style={styles.backdrop}>
|
|
94
|
+
<Text style={styles.title} accessibilityRole="header">
|
|
95
|
+
{title}
|
|
96
|
+
</Text>
|
|
97
|
+
|
|
98
|
+
<View style={styles.imageWrapper}>
|
|
99
|
+
<Image
|
|
100
|
+
source={{ uri: panoramaUri }}
|
|
101
|
+
style={[styles.image, { aspectRatio }]}
|
|
102
|
+
resizeMode="contain"
|
|
103
|
+
accessibilityIgnoresInvertColors
|
|
104
|
+
/>
|
|
105
|
+
</View>
|
|
106
|
+
|
|
107
|
+
<View style={styles.buttonRow}>
|
|
108
|
+
<Pressable
|
|
109
|
+
onPress={onDiscard}
|
|
110
|
+
style={[styles.button, styles.buttonGhost]}
|
|
111
|
+
accessibilityRole="button"
|
|
112
|
+
accessibilityLabel="Discard panorama"
|
|
113
|
+
>
|
|
114
|
+
<Text style={styles.buttonGhostText}>✕ Discard</Text>
|
|
115
|
+
</Pressable>
|
|
116
|
+
<Pressable
|
|
117
|
+
onPress={onRetry}
|
|
118
|
+
style={[styles.button, styles.buttonNeutral]}
|
|
119
|
+
accessibilityRole="button"
|
|
120
|
+
accessibilityLabel="Retry panorama"
|
|
121
|
+
>
|
|
122
|
+
<Text style={styles.buttonNeutralText}>↺ Retry</Text>
|
|
123
|
+
</Pressable>
|
|
124
|
+
<Pressable
|
|
125
|
+
onPress={onSave}
|
|
126
|
+
style={[styles.button, styles.buttonPrimary]}
|
|
127
|
+
accessibilityRole="button"
|
|
128
|
+
accessibilityLabel="Save panorama"
|
|
129
|
+
>
|
|
130
|
+
<Text style={styles.buttonPrimaryText}>✓ Save</Text>
|
|
131
|
+
</Pressable>
|
|
132
|
+
</View>
|
|
133
|
+
</View>
|
|
134
|
+
</Modal>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
const styles = StyleSheet.create({
|
|
140
|
+
backdrop: {
|
|
141
|
+
flex: 1,
|
|
142
|
+
backgroundColor: 'rgba(0,0,0,0.96)',
|
|
143
|
+
paddingTop: 64,
|
|
144
|
+
paddingBottom: 32,
|
|
145
|
+
paddingHorizontal: 16,
|
|
146
|
+
},
|
|
147
|
+
title: {
|
|
148
|
+
color: '#ffffff',
|
|
149
|
+
fontSize: 16,
|
|
150
|
+
fontWeight: '600',
|
|
151
|
+
textAlign: 'center',
|
|
152
|
+
marginBottom: 12,
|
|
153
|
+
},
|
|
154
|
+
imageWrapper: {
|
|
155
|
+
flex: 1,
|
|
156
|
+
alignItems: 'center',
|
|
157
|
+
justifyContent: 'center',
|
|
158
|
+
},
|
|
159
|
+
image: {
|
|
160
|
+
width: '100%',
|
|
161
|
+
maxHeight: '100%',
|
|
162
|
+
backgroundColor: '#111',
|
|
163
|
+
borderRadius: 4,
|
|
164
|
+
},
|
|
165
|
+
buttonRow: {
|
|
166
|
+
flexDirection: 'row',
|
|
167
|
+
justifyContent: 'space-between',
|
|
168
|
+
alignItems: 'center',
|
|
169
|
+
marginTop: 16,
|
|
170
|
+
gap: 12,
|
|
171
|
+
},
|
|
172
|
+
button: {
|
|
173
|
+
flex: 1,
|
|
174
|
+
paddingVertical: 14,
|
|
175
|
+
borderRadius: 10,
|
|
176
|
+
alignItems: 'center',
|
|
177
|
+
justifyContent: 'center',
|
|
178
|
+
},
|
|
179
|
+
buttonGhost: {
|
|
180
|
+
backgroundColor: 'transparent',
|
|
181
|
+
borderWidth: 1,
|
|
182
|
+
borderColor: 'rgba(255,255,255,0.3)',
|
|
183
|
+
},
|
|
184
|
+
buttonGhostText: {
|
|
185
|
+
color: '#ffffff',
|
|
186
|
+
fontSize: 14,
|
|
187
|
+
fontWeight: '500',
|
|
188
|
+
opacity: 0.9,
|
|
189
|
+
},
|
|
190
|
+
buttonNeutral: {
|
|
191
|
+
backgroundColor: 'rgba(255,255,255,0.12)',
|
|
192
|
+
},
|
|
193
|
+
buttonNeutralText: {
|
|
194
|
+
color: '#ffffff',
|
|
195
|
+
fontSize: 14,
|
|
196
|
+
fontWeight: '600',
|
|
197
|
+
},
|
|
198
|
+
buttonPrimary: {
|
|
199
|
+
backgroundColor: '#34C759',
|
|
200
|
+
},
|
|
201
|
+
buttonPrimaryText: {
|
|
202
|
+
color: '#ffffff',
|
|
203
|
+
fontSize: 14,
|
|
204
|
+
fontWeight: '700',
|
|
205
|
+
},
|
|
206
|
+
});
|