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.
Files changed (151) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +21 -0
  4. package/README.md +189 -0
  5. package/RNImageStitcher.podspec +76 -0
  6. package/android/build.gradle +224 -0
  7. package/android/src/main/AndroidManifest.xml +3 -0
  8. package/android/src/main/cpp/CMakeLists.txt +124 -0
  9. package/android/src/main/cpp/image_stitcher_jni.cpp +145 -0
  10. package/android/src/main/cpp/keyframe_gate_jni.cpp +204 -0
  11. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +426 -0
  12. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +960 -0
  13. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +2371 -0
  14. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +256 -0
  15. package/android/src/main/java/io/imagestitcher/rn/QualityChecker.kt +167 -0
  16. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +39 -0
  17. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +558 -0
  18. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +35 -0
  19. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +784 -0
  20. package/android/src/main/java/io/imagestitcher/rn/ar/BackgroundRenderer.kt +176 -0
  21. package/android/src/main/java/io/imagestitcher/rn/ar/ShaderUtil.kt +67 -0
  22. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +201 -0
  23. package/cpp/ar_frame_pose.h +63 -0
  24. package/cpp/keyframe_gate.cpp +927 -0
  25. package/cpp/keyframe_gate.hpp +240 -0
  26. package/cpp/stitcher.cpp +2207 -0
  27. package/cpp/stitcher.hpp +275 -0
  28. package/dist/ar/useARSession.d.ts +102 -0
  29. package/dist/ar/useARSession.js +133 -0
  30. package/dist/camera/ARCameraView.d.ts +93 -0
  31. package/dist/camera/ARCameraView.js +170 -0
  32. package/dist/camera/Camera.d.ts +134 -0
  33. package/dist/camera/Camera.js +688 -0
  34. package/dist/camera/CameraShutter.d.ts +80 -0
  35. package/dist/camera/CameraShutter.js +237 -0
  36. package/dist/camera/CameraView.d.ts +65 -0
  37. package/dist/camera/CameraView.js +117 -0
  38. package/dist/camera/CaptureControlsBar.d.ts +87 -0
  39. package/dist/camera/CaptureControlsBar.js +82 -0
  40. package/dist/camera/CaptureHeader.d.ts +62 -0
  41. package/dist/camera/CaptureHeader.js +81 -0
  42. package/dist/camera/CapturePreview.d.ts +70 -0
  43. package/dist/camera/CapturePreview.js +188 -0
  44. package/dist/camera/CaptureStatusOverlay.d.ts +75 -0
  45. package/dist/camera/CaptureStatusOverlay.js +326 -0
  46. package/dist/camera/CaptureThumbnailStrip.d.ts +87 -0
  47. package/dist/camera/CaptureThumbnailStrip.js +177 -0
  48. package/dist/camera/IncrementalPanGuide.d.ts +83 -0
  49. package/dist/camera/IncrementalPanGuide.js +267 -0
  50. package/dist/camera/PanoramaBandOverlay.d.ts +107 -0
  51. package/dist/camera/PanoramaBandOverlay.js +399 -0
  52. package/dist/camera/PanoramaConfirmModal.d.ts +57 -0
  53. package/dist/camera/PanoramaConfirmModal.js +128 -0
  54. package/dist/camera/PanoramaGuidance.d.ts +79 -0
  55. package/dist/camera/PanoramaGuidance.js +246 -0
  56. package/dist/camera/PanoramaSettingsModal.d.ts +311 -0
  57. package/dist/camera/PanoramaSettingsModal.js +611 -0
  58. package/dist/camera/ViewportCropOverlay.d.ts +46 -0
  59. package/dist/camera/ViewportCropOverlay.js +67 -0
  60. package/dist/camera/useCapture.d.ts +111 -0
  61. package/dist/camera/useCapture.js +160 -0
  62. package/dist/camera/useDeviceOrientation.d.ts +48 -0
  63. package/dist/camera/useDeviceOrientation.js +131 -0
  64. package/dist/camera/useVideoCapture.d.ts +79 -0
  65. package/dist/camera/useVideoCapture.js +151 -0
  66. package/dist/index.d.ts +26 -0
  67. package/dist/index.js +39 -0
  68. package/dist/quality/normaliseOrientation.d.ts +36 -0
  69. package/dist/quality/normaliseOrientation.js +62 -0
  70. package/dist/quality/runQualityCheck.d.ts +41 -0
  71. package/dist/quality/runQualityCheck.js +98 -0
  72. package/dist/sensors/useIMUTranslationGate.d.ts +70 -0
  73. package/dist/sensors/useIMUTranslationGate.js +235 -0
  74. package/dist/stitching/IncrementalStitcherView.d.ts +41 -0
  75. package/dist/stitching/IncrementalStitcherView.js +157 -0
  76. package/dist/stitching/incremental.d.ts +930 -0
  77. package/dist/stitching/incremental.js +133 -0
  78. package/dist/stitching/stitchFrames.d.ts +55 -0
  79. package/dist/stitching/stitchFrames.js +56 -0
  80. package/dist/stitching/stitchVideo.d.ts +119 -0
  81. package/dist/stitching/stitchVideo.js +57 -0
  82. package/dist/stitching/useIncrementalJSDriver.d.ts +74 -0
  83. package/dist/stitching/useIncrementalJSDriver.js +199 -0
  84. package/dist/stitching/useIncrementalStitcher.d.ts +58 -0
  85. package/dist/stitching/useIncrementalStitcher.js +172 -0
  86. package/dist/types.d.ts +58 -0
  87. package/dist/types.js +15 -0
  88. package/ios/Package.swift +72 -0
  89. package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +33 -0
  90. package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +40 -0
  91. package/ios/Sources/RNImageStitcher/ARSessionBridge.m +55 -0
  92. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +149 -0
  93. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +2727 -0
  94. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +85 -0
  95. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +625 -0
  96. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +328 -0
  97. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +141 -0
  98. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +278 -0
  99. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +473 -0
  100. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +1326 -0
  101. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +97 -0
  102. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +296 -0
  103. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +103 -0
  104. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +3285 -0
  105. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +238 -0
  106. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +1880 -0
  107. package/ios/Sources/RNImageStitcher/QualityChecker.swift +252 -0
  108. package/ios/Sources/RNImageStitcher/QualityCheckerBridge.m +26 -0
  109. package/ios/Sources/RNImageStitcher/QualityCheckerBridge.swift +72 -0
  110. package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +114 -0
  111. package/ios/Sources/RNImageStitcher/RNSARSession.swift +1111 -0
  112. package/ios/Sources/RNImageStitcher/Stitcher.swift +243 -0
  113. package/ios/Sources/RNImageStitcher/StitcherBridge.m +28 -0
  114. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +246 -0
  115. package/package.json +73 -0
  116. package/react-native.config.js +34 -0
  117. package/scripts/opencv-version.txt +1 -0
  118. package/scripts/postinstall-fetch-binaries.js +286 -0
  119. package/src/ar/useARSession.ts +210 -0
  120. package/src/camera/.gitkeep +0 -0
  121. package/src/camera/ARCameraView.tsx +256 -0
  122. package/src/camera/Camera.tsx +1053 -0
  123. package/src/camera/CameraShutter.tsx +292 -0
  124. package/src/camera/CameraView.tsx +157 -0
  125. package/src/camera/CaptureControlsBar.tsx +204 -0
  126. package/src/camera/CaptureHeader.tsx +184 -0
  127. package/src/camera/CapturePreview.tsx +318 -0
  128. package/src/camera/CaptureStatusOverlay.tsx +391 -0
  129. package/src/camera/CaptureThumbnailStrip.tsx +277 -0
  130. package/src/camera/IncrementalPanGuide.tsx +328 -0
  131. package/src/camera/PanoramaBandOverlay.tsx +498 -0
  132. package/src/camera/PanoramaConfirmModal.tsx +206 -0
  133. package/src/camera/PanoramaGuidance.tsx +327 -0
  134. package/src/camera/PanoramaSettingsModal.tsx +1357 -0
  135. package/src/camera/ViewportCropOverlay.tsx +81 -0
  136. package/src/camera/useCapture.ts +279 -0
  137. package/src/camera/useDeviceOrientation.ts +140 -0
  138. package/src/camera/useVideoCapture.ts +236 -0
  139. package/src/index.ts +53 -0
  140. package/src/quality/.gitkeep +0 -0
  141. package/src/quality/normaliseOrientation.ts +79 -0
  142. package/src/quality/runQualityCheck.ts +131 -0
  143. package/src/sensors/useIMUTranslationGate.ts +347 -0
  144. package/src/stitching/.gitkeep +0 -0
  145. package/src/stitching/IncrementalStitcherView.tsx +198 -0
  146. package/src/stitching/incremental.ts +1021 -0
  147. package/src/stitching/stitchFrames.ts +88 -0
  148. package/src/stitching/stitchVideo.ts +153 -0
  149. package/src/stitching/useIncrementalJSDriver.ts +273 -0
  150. package/src/stitching/useIncrementalStitcher.ts +252 -0
  151. package/src/types.ts +78 -0
@@ -0,0 +1,391 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * CaptureStatusOverlay — screen-level visual feedback for the
4
+ * panorama capture lifecycle.
5
+ *
6
+ * Lives in the SDK because the existing shutter-button colour change
7
+ * is hidden by the user's finger during a hold. An overlay above
8
+ * the preview is the only reliable channel for "you ARE recording
9
+ * right now" feedback.
10
+ *
11
+ * ┌──────────────────────────────────────────────────┐
12
+ * │ ● REC Hold steady, pan slowly… │ ← banner
13
+ * ├──────────────────────────────────────────────────┤
14
+ * │ ┌──────────────────────────────────────────────┐ │
15
+ * │ │ │ │
16
+ * │ │ ⬛ red glow border │ │
17
+ * │ │ around the preview │ │
18
+ * │ │ │ │
19
+ * │ └──────────────────────────────────────────────┘ │
20
+ * └──────────────────────────────────────────────────┘
21
+ *
22
+ * The component is intentionally pure-presentational: it takes a
23
+ * `phase` prop and renders the matching UI. Recording vs stitching
24
+ * vs idle is the host's source of truth — typically derived from
25
+ * `useVideoCapture().state` and a local "isStitching" boolean.
26
+ *
27
+ * The overlay renders nothing in `idle` so the host can render it
28
+ * unconditionally without conditional layout shifts.
29
+ */
30
+
31
+ import React, { useEffect, useRef } from 'react';
32
+ import {
33
+ Animated,
34
+ Easing,
35
+ StyleSheet,
36
+ Text,
37
+ View,
38
+ type StyleProp,
39
+ type ViewStyle,
40
+ } from 'react-native';
41
+
42
+ import { useDeviceOrientation, type DeviceOrientation } from './useDeviceOrientation';
43
+
44
+
45
+ export type CaptureStatusPhase = 'idle' | 'recording' | 'stitching';
46
+
47
+
48
+ export interface CaptureStatusOverlayProps {
49
+ /**
50
+ * Current phase. `idle` renders nothing. `recording` shows the
51
+ * REC banner + red glowing border. `stitching` swaps to a neutral
52
+ * "Stitching..." banner with no border (recording is over; UI
53
+ * cue should de-escalate).
54
+ */
55
+ phase: CaptureStatusPhase;
56
+ /**
57
+ * Optional override for the recording-phase message. Defaults to
58
+ * "Hold steady — pan slowly". Useful if the host wants direction
59
+ * hints (e.g. "Pan down across the rack") for a specific audit
60
+ * type.
61
+ */
62
+ recordingMessage?: string;
63
+ /**
64
+ * Optional override for the stitching-phase message. Defaults to
65
+ * "Stitching panorama…".
66
+ */
67
+ stitchingMessage?: string;
68
+ /**
69
+ * If set, the recording-phase banner shows a live countdown
70
+ * ("REC 4s left") computed against this value. Set to the
71
+ * shutter's `maxHoldMs` so the user can see how long they have
72
+ * left before the auto-stop fires. Pair with a fresh value
73
+ * each time recording starts so the timer resets per capture.
74
+ *
75
+ * `recordingStartedAt` is the timestamp (Date.now()) when the
76
+ * recording phase began — required for the countdown math.
77
+ */
78
+ countdownMs?: number;
79
+ recordingStartedAt?: number;
80
+ /**
81
+ * Top inset to offset the banner below the status bar / notch.
82
+ * Defaults to 0 — host apps using `react-native-safe-area-context`
83
+ * should pass `insets.top` here so the banner doesn't disappear
84
+ * behind the notch.
85
+ */
86
+ topInset?: number;
87
+ /** Outer style passthrough. */
88
+ style?: StyleProp<ViewStyle>;
89
+ }
90
+
91
+
92
+ export function CaptureStatusOverlay({
93
+ phase,
94
+ recordingMessage = 'Hold steady — pan slowly',
95
+ stitchingMessage = 'Stitching panorama…',
96
+ countdownMs,
97
+ recordingStartedAt,
98
+ topInset = 0,
99
+ style,
100
+ }: CaptureStatusOverlayProps): React.JSX.Element | null {
101
+ // Countdown ticker — re-renders every 250 ms while recording so
102
+ // the "REC 4s left" text stays current without flooding render
103
+ // calls. Disabled (no interval) when not in recording phase or
104
+ // when countdown isn't configured.
105
+ const [, setTick] = React.useState(0);
106
+ React.useEffect(() => {
107
+ if (phase !== 'recording' || !countdownMs || !recordingStartedAt) return;
108
+ const id = setInterval(() => setTick((t) => t + 1), 250);
109
+ return () => clearInterval(id);
110
+ }, [phase, countdownMs, recordingStartedAt]);
111
+ // Pulse animation for the REC dot. Driven by a single Animated
112
+ // value that loops 0→1→0. Cheap (no listeners, runs on the
113
+ // native driver) and only spins up while recording.
114
+ const pulse = useRef(new Animated.Value(0)).current;
115
+
116
+ useEffect(() => {
117
+ if (phase !== 'recording') {
118
+ pulse.setValue(0);
119
+ return;
120
+ }
121
+ const loop = Animated.loop(
122
+ Animated.sequence([
123
+ Animated.timing(pulse, {
124
+ toValue: 1,
125
+ duration: 600,
126
+ easing: Easing.inOut(Easing.ease),
127
+ useNativeDriver: true,
128
+ }),
129
+ Animated.timing(pulse, {
130
+ toValue: 0,
131
+ duration: 600,
132
+ easing: Easing.inOut(Easing.ease),
133
+ useNativeDriver: true,
134
+ }),
135
+ ]),
136
+ );
137
+ loop.start();
138
+ return () => loop.stop();
139
+ }, [phase, pulse]);
140
+
141
+ // Always call the hook — even when phase is 'idle' — so React's
142
+ // hook-order rule isn't violated. The accelerometer subscription
143
+ // is cheap and stays alive for the screen's lifetime.
144
+ const deviceOrientation = useDeviceOrientation();
145
+
146
+ if (phase === 'idle') return null;
147
+
148
+ // Interpolate pulse → opacity & scale so the dot breathes 0.6→1.0
149
+ // opacity and 1.0→1.3 scale. Subtle; not distracting.
150
+ const dotOpacity = pulse.interpolate({
151
+ inputRange: [0, 1],
152
+ outputRange: [0.5, 1],
153
+ });
154
+ const dotScale = pulse.interpolate({
155
+ inputRange: [0, 1],
156
+ outputRange: [1, 1.3],
157
+ });
158
+
159
+ // The red border appears only during recording — once the user
160
+ // releases and we move to stitching the recording is over and a
161
+ // bright red border would be misleading.
162
+ const showBorder = phase === 'recording';
163
+
164
+ // Compute remaining seconds for the countdown. Re-rendered
165
+ // every 250 ms by the tick interval above. If countdownMs or
166
+ // recordingStartedAt are missing we just render the base
167
+ // message without a "Xs left" suffix.
168
+ let baseMessage =
169
+ phase === 'recording' ? recordingMessage : stitchingMessage;
170
+ if (
171
+ phase === 'recording'
172
+ && countdownMs
173
+ && recordingStartedAt
174
+ ) {
175
+ const elapsedMs = Date.now() - recordingStartedAt;
176
+ const remainingMs = Math.max(0, countdownMs - elapsedMs);
177
+ const remainingSec = Math.ceil(remainingMs / 1000);
178
+ baseMessage = `${recordingMessage} · ${remainingSec}s left`;
179
+ }
180
+ const message = baseMessage;
181
+
182
+ // Orientation-aware banner placement via DIRECT absolute positioning
183
+ // + percentage transforms.
184
+ //
185
+ // Why this instead of a rotated-wrapper approach: the previous
186
+ // wrapper approach (sized to user-view dims, positioned to align
187
+ // center, rotated) is geometrically correct on paper but rendered
188
+ // off-center on device (probably a RN flex+rotation interaction).
189
+ // Direct absolute positioning of the banner with translateX/Y('-50%')
190
+ // for self-centering is simpler, doesn't depend on useWindowDimensions,
191
+ // and uses only well-trodden RN style features.
192
+ //
193
+ // Border is rendered separately because it hugs the physical camera
194
+ // preview (in layout coords) — it must not rotate with the banner.
195
+ const bannerOrientationStyle = bannerStyleForOrientation(
196
+ deviceOrientation,
197
+ topInset,
198
+ );
199
+
200
+ return (
201
+ <View
202
+ // pointerEvents=box-none so the overlay never steals taps from
203
+ // the underlying camera / shutter / preview. The banner and
204
+ // border are read-only.
205
+ pointerEvents="box-none"
206
+ style={[StyleSheet.absoluteFill, style]}
207
+ accessibilityLiveRegion="polite"
208
+ >
209
+ {showBorder ? (
210
+ <View pointerEvents="none" style={styles.recordBorder} />
211
+ ) : null}
212
+
213
+ <View
214
+ pointerEvents="none"
215
+ style={[
216
+ styles.banner,
217
+ phase === 'recording' ? styles.bannerRecording : styles.bannerStitching,
218
+ bannerOrientationStyle,
219
+ ]}
220
+ >
221
+ {phase === 'recording' ? (
222
+ <Animated.View
223
+ style={[
224
+ styles.recDot,
225
+ { opacity: dotOpacity, transform: [{ scale: dotScale }] },
226
+ ]}
227
+ />
228
+ ) : (
229
+ <View style={styles.stitchSpinner} />
230
+ )}
231
+ <Text style={styles.bannerText} numberOfLines={1}>
232
+ {phase === 'recording' ? 'REC' : '•••'}{' '}{message}
233
+ </Text>
234
+ </View>
235
+ </View>
236
+ );
237
+ }
238
+
239
+
240
+ /**
241
+ * Compute the style placing the banner at user-perceived top-center
242
+ * with text reading in the user's view direction.
243
+ *
244
+ * Approach: direct absolute positioning + percentage-translate self-
245
+ * centering (works on the banner's own measured dimensions, so the
246
+ * banner's text content can be any width).
247
+ *
248
+ * For each orientation, anchor the banner to the layout edge that
249
+ * corresponds to user-perceived top:
250
+ *
251
+ * portrait → layout-top + horizontally centered + 0°
252
+ * portrait-upside-down → layout-bottom + horizontally centered + 180°
253
+ * landscape-left → layout-right + vertically centered + 90°
254
+ * landscape-right → layout-left + vertically centered + -90°
255
+ *
256
+ * In landscape, the banner is rotated around its center so its text
257
+ * (originally horizontal in layout) reads horizontally in the user's
258
+ * view. The translateY('-50%') aligns the banner's center with the
259
+ * layout's vertical center, which maps to user-horizontal-center
260
+ * post-rotation.
261
+ *
262
+ * RN supports `'50%'` for absolute positions and percentage values in
263
+ * translateX/Y since 0.70 — the percentage in a translate is relative
264
+ * to the element's OWN dimensions, which is exactly what self-
265
+ * centering an unknown-width element needs.
266
+ */
267
+ function bannerStyleForOrientation(
268
+ orientation: DeviceOrientation,
269
+ topInset: number,
270
+ ): ViewStyle {
271
+ switch (orientation) {
272
+ case 'landscape-left':
273
+ // 2026-05-18 round 2 — pre-rotation `right: 8` put the
274
+ // banner's right edge at layout right minus 8; after 90° CW
275
+ // rotation around banner CENTER, the banner's bbox center
276
+ // ended up at layout-x = W - 8 - banner_width/2. The
277
+ // banner's user-top edge (post-rotation max layout-x) then
278
+ // landed at user_y = (banner_width - banner_height) / 2 + 8
279
+ // ≈ 130 px from user-top — way too far down, hence the
280
+ // "appearing in center vertically" complaint.
281
+ //
282
+ // Fix: shift banner's center to layout-x = W - 34 (= 8 +
283
+ // estimated banner_height/2 ≈ 26). Achieved by anchoring
284
+ // at `right: 34` (banner right edge = W-34) and then
285
+ // `translateX('50%')` (shifts center right by banner_width/
286
+ // 2 = back to W-34). Post-rotation max layout-x = W - 34 +
287
+ // banner_height/2 = W - 8 → user_y = 8 px from user-top.
288
+ // Works regardless of banner_width because the translateX
289
+ // percentage cancels the W_b/2 offset.
290
+ //
291
+ // Landscape user-top has no notch (the Dynamic Island sits
292
+ // on user-LEFT in landscape-left, user-RIGHT in landscape-
293
+ // right), so we don't add topInset here — 8 px is the tight
294
+ // visual minimum the user asked for.
295
+ return {
296
+ position: 'absolute',
297
+ right: 34,
298
+ top: '50%',
299
+ transform: [
300
+ { translateY: '-50%' },
301
+ { translateX: '50%' },
302
+ { rotate: '90deg' },
303
+ ],
304
+ };
305
+ case 'landscape-right':
306
+ return {
307
+ position: 'absolute',
308
+ left: 34,
309
+ top: '50%',
310
+ transform: [
311
+ { translateY: '-50%' },
312
+ { translateX: '-50%' },
313
+ { rotate: '-90deg' },
314
+ ],
315
+ };
316
+ case 'portrait-upside-down':
317
+ return {
318
+ position: 'absolute',
319
+ bottom: topInset + 8,
320
+ left: '50%',
321
+ transform: [
322
+ { translateX: '-50%' },
323
+ { rotate: '180deg' },
324
+ ],
325
+ };
326
+ case 'portrait':
327
+ default:
328
+ return {
329
+ position: 'absolute',
330
+ top: topInset + 8,
331
+ left: '50%',
332
+ transform: [
333
+ { translateX: '-50%' },
334
+ ],
335
+ };
336
+ }
337
+ }
338
+
339
+
340
+ const styles = StyleSheet.create({
341
+ banner: {
342
+ // position: 'absolute' is added back so the orientation-specific
343
+ // style (returned by bannerStyleForOrientation) can position the
344
+ // banner at the layout edge for that orientation using top/right/
345
+ // bottom/left. The transform array on the same style does the
346
+ // self-centering via translateX/Y('-50%') and applies rotation.
347
+ position: 'absolute',
348
+ flexDirection: 'row',
349
+ alignItems: 'center',
350
+ paddingHorizontal: 14,
351
+ paddingVertical: 8,
352
+ borderRadius: 22,
353
+ minHeight: 36,
354
+ },
355
+ bannerRecording: {
356
+ backgroundColor: 'rgba(255,59,48,0.92)',
357
+ },
358
+ bannerStitching: {
359
+ // Neutral grey while we wait for the stitcher; communicates
360
+ // "still working" without the alarming red of active recording.
361
+ backgroundColor: 'rgba(0,0,0,0.78)',
362
+ },
363
+ bannerText: {
364
+ color: '#ffffff',
365
+ fontSize: 13,
366
+ fontWeight: '600',
367
+ marginLeft: 8,
368
+ },
369
+ recDot: {
370
+ width: 10,
371
+ height: 10,
372
+ borderRadius: 5,
373
+ backgroundColor: '#ffffff',
374
+ },
375
+ stitchSpinner: {
376
+ // Static dot for now — RN's ActivityIndicator is fine here too,
377
+ // but a calm static dot keeps visual noise low when the user
378
+ // already gets the spinner via the shutter-button "processing"
379
+ // state below.
380
+ width: 8,
381
+ height: 8,
382
+ borderRadius: 4,
383
+ backgroundColor: '#ffffff',
384
+ opacity: 0.7,
385
+ },
386
+ recordBorder: {
387
+ ...StyleSheet.absoluteFillObject,
388
+ borderWidth: 4,
389
+ borderColor: 'rgba(255,59,48,0.9)',
390
+ },
391
+ });
@@ -0,0 +1,277 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * CaptureThumbnailStrip — horizontal thumbnail strip with built-in
4
+ * tap-to-preview modal, designed for the audit capture surface.
5
+ *
6
+ * ┌─────────────────────────────────────────────────────────┐
7
+ * │ [thumb 4:3] [thumb 9:16] [thumb pano] … │
8
+ * │ 3 / 5 min · 10 max │
9
+ * └─────────────────────────────────────────────────────────┘
10
+ *
11
+ * Two reasons this lives in the SDK rather than the host:
12
+ * 1. The thumbnail UX is camera-shaped — tightly coupled to the
13
+ * `CaptureResult` dimension fields the SDK introduced for
14
+ * aspect-ratio rendering. Any host that uses `useCapture`
15
+ * benefits from the same display logic, so the SDK is the
16
+ * right home.
17
+ * 2. The preview modal is a non-trivial chunk of UI (full-screen
18
+ * Image with close affordance + safe-area handling). Hosts
19
+ * were inevitably going to re-implement it with subtly
20
+ * different gesture handling; centralising avoids drift.
21
+ *
22
+ * The strip is intentionally headless about persistence: it knows
23
+ * nothing about WatermelonDB, the host's DB schema, or sync state.
24
+ * Callers pass an array of plain objects with `id`, `uri`, and
25
+ * optional `width`/`height` and the strip handles the rest.
26
+ *
27
+ * Aspect-ratio rendering:
28
+ * - Thumbnails are anchored at a fixed height (default 60 px)
29
+ * and width is computed from `width / height` * height.
30
+ * - Width is clamped to [40, 180] so a tall portrait doesn't
31
+ * squish to a sliver and a 5000 px panorama doesn't push
32
+ * siblings off-screen.
33
+ * - Items missing dimensions (legacy captures saved before the
34
+ * SDK exposed them) fall back to square — matches prior
35
+ * behaviour and avoids visual jumps when scrolling mixed
36
+ * histories.
37
+ */
38
+
39
+ import React, { useCallback, useMemo, useState } from 'react';
40
+ import {
41
+ FlatList,
42
+ Image,
43
+ Pressable,
44
+ StyleSheet,
45
+ Text,
46
+ View,
47
+ type StyleProp,
48
+ type ViewStyle,
49
+ } from 'react-native';
50
+
51
+ import { CapturePreview } from './CapturePreview';
52
+
53
+
54
+ export interface CaptureThumbnailItem {
55
+ /** Stable id for FlatList keying. */
56
+ id: string;
57
+ /** `file://` or remote URI of the captured image. */
58
+ uri: string;
59
+ /** Image width in pixels. Optional; falls back to square. */
60
+ width?: number;
61
+ /** Image height in pixels. Optional; falls back to square. */
62
+ height?: number;
63
+ }
64
+
65
+
66
+ export interface CaptureThumbnailStripProps {
67
+ /** Captures to render, in the order they should appear. */
68
+ items: CaptureThumbnailItem[];
69
+ /**
70
+ * Optional minimum-photos hint for the count line. When `count >=
71
+ * minPhotos` the count text uses the success colour, otherwise it
72
+ * uses the warning colour. Pass undefined to suppress the hint.
73
+ */
74
+ minPhotos?: number;
75
+ /** Optional maximum-photos hint for the count line. */
76
+ maxPhotos?: number;
77
+ /** Strip background colour (defaults to translucent black). */
78
+ backgroundColor?: string;
79
+ /** Text colour applied to the count line and "No photos" placeholder. */
80
+ textColor?: string;
81
+ /** Colour used when count meets `minPhotos`. */
82
+ successColor?: string;
83
+ /** Colour used when count is below `minPhotos`. */
84
+ warningColor?: string;
85
+ /**
86
+ * Disable tap-to-preview. When true, thumbnails are still rendered
87
+ * but tapping them is a no-op. Default false (preview enabled).
88
+ */
89
+ disablePreview?: boolean;
90
+ /**
91
+ * Custom tap handler. When provided, tapping a thumbnail calls
92
+ * this instead of opening the strip's built-in preview modal.
93
+ * Use this when the host wants to show its own preview UI (e.g.
94
+ * with delete / recapture buttons gated on capture sync state).
95
+ */
96
+ onItemPress?: (item: CaptureThumbnailItem) => void;
97
+ /**
98
+ * Optional outer style. Layout-related props (height, padding)
99
+ * stay under the strip's control to keep the count line consistent.
100
+ */
101
+ style?: StyleProp<ViewStyle>;
102
+ }
103
+
104
+
105
+ /// Fixed thumbnail height — width varies with aspect ratio.
106
+ const THUMB_HEIGHT = 60;
107
+ /// Width clamps protect the strip from extreme aspect ratios (very
108
+ /// tall portraits squishing to a sliver, very wide panoramas pushing
109
+ /// siblings off-screen).
110
+ const THUMB_MIN_WIDTH = 40;
111
+ const THUMB_MAX_WIDTH = 180;
112
+
113
+
114
+ function thumbWidth(item: CaptureThumbnailItem): number {
115
+ const { width, height } = item;
116
+ if (!width || !height || width <= 0 || height <= 0) {
117
+ // Legacy capture without dimensions — fall back to square.
118
+ return THUMB_HEIGHT;
119
+ }
120
+ const ratio = width / height;
121
+ const computed = Math.round(THUMB_HEIGHT * ratio);
122
+ return Math.max(THUMB_MIN_WIDTH, Math.min(THUMB_MAX_WIDTH, computed));
123
+ }
124
+
125
+
126
+ export function CaptureThumbnailStrip({
127
+ items,
128
+ minPhotos,
129
+ maxPhotos,
130
+ backgroundColor = 'rgba(0,0,0,0.85)',
131
+ textColor = '#ffffff',
132
+ successColor = '#34C759',
133
+ warningColor = '#FF9F0A',
134
+ disablePreview = false,
135
+ onItemPress,
136
+ style,
137
+ }: CaptureThumbnailStripProps): React.JSX.Element {
138
+ // Built-in preview state — only used when the host hasn't
139
+ // provided its own onItemPress handler. Letting the host pass a
140
+ // handler is how the AuditCaptureScreen unifies thumbnail
141
+ // preview with post-stitch confirmation.
142
+ const [previewItem, setPreviewItem] =
143
+ useState<CaptureThumbnailItem | null>(null);
144
+
145
+ // Memoise so FlatList doesn't see a fresh callback identity every
146
+ // render and re-render every row.
147
+ const handleItemPress = useCallback(
148
+ (item: CaptureThumbnailItem) => {
149
+ if (disablePreview) return;
150
+ if (onItemPress) {
151
+ onItemPress(item);
152
+ return;
153
+ }
154
+ setPreviewItem(item);
155
+ },
156
+ [disablePreview, onItemPress],
157
+ );
158
+
159
+ const closePreview = useCallback(() => setPreviewItem(null), []);
160
+
161
+ const countLine = useMemo(() => {
162
+ if (minPhotos === undefined && maxPhotos === undefined) return null;
163
+ const meetsMin =
164
+ minPhotos === undefined ? true : items.length >= minPhotos;
165
+ const text =
166
+ minPhotos !== undefined && maxPhotos !== undefined
167
+ ? `${items.length} / ${minPhotos} min · ${maxPhotos} max`
168
+ : minPhotos !== undefined
169
+ ? `${items.length} / ${minPhotos} min`
170
+ : `${items.length} / ${maxPhotos} max`;
171
+ return (
172
+ <Text
173
+ style={[
174
+ styles.count,
175
+ { color: meetsMin ? successColor : warningColor },
176
+ ]}
177
+ accessibilityLabel={`Captured ${items.length} photos`}
178
+ >
179
+ {text}
180
+ </Text>
181
+ );
182
+ }, [items.length, minPhotos, maxPhotos, successColor, warningColor]);
183
+
184
+ return (
185
+ <View style={[styles.root, { backgroundColor }, style]}>
186
+ <FlatList
187
+ data={items}
188
+ horizontal
189
+ showsHorizontalScrollIndicator={false}
190
+ keyExtractor={(item) => item.id}
191
+ contentContainerStyle={styles.listContent}
192
+ ListEmptyComponent={
193
+ <View
194
+ style={[styles.placeholder, { borderColor: textColor }]}
195
+ accessibilityLabel="No photos captured"
196
+ >
197
+ <Text style={[styles.placeholderText, { color: textColor }]}>
198
+ No photos
199
+ </Text>
200
+ </View>
201
+ }
202
+ renderItem={({ item }) => (
203
+ <Pressable
204
+ onPress={() => handleItemPress(item)}
205
+ disabled={disablePreview}
206
+ accessibilityRole="imagebutton"
207
+ accessibilityLabel="Open preview"
208
+ // Resolve the width per-item — done at render rather than
209
+ // inside renderItem's style prop so the function isn't
210
+ // re-created on every parent render.
211
+ style={[
212
+ styles.thumbWrapper,
213
+ { width: thumbWidth(item), height: THUMB_HEIGHT },
214
+ ]}
215
+ >
216
+ <Image
217
+ source={{ uri: item.uri }}
218
+ style={styles.thumbImage}
219
+ resizeMode="cover"
220
+ />
221
+ </Pressable>
222
+ )}
223
+ />
224
+ {countLine}
225
+
226
+ {/* Built-in preview — only shown when host didn't pass
227
+ * onItemPress. When the host owns the preview, the strip's
228
+ * job is just to surface taps via the callback. */}
229
+ <CapturePreview
230
+ visible={previewItem !== null}
231
+ imageUri={previewItem?.uri ?? ''}
232
+ imageWidth={previewItem?.width}
233
+ imageHeight={previewItem?.height}
234
+ onClose={closePreview}
235
+ />
236
+ </View>
237
+ );
238
+ }
239
+
240
+
241
+ const styles = StyleSheet.create({
242
+ root: {
243
+ paddingVertical: 8,
244
+ },
245
+ listContent: {
246
+ paddingHorizontal: 12,
247
+ alignItems: 'center',
248
+ },
249
+ thumbWrapper: {
250
+ marginRight: 8,
251
+ borderRadius: 4,
252
+ overflow: 'hidden',
253
+ backgroundColor: '#222',
254
+ },
255
+ thumbImage: {
256
+ width: '100%',
257
+ height: '100%',
258
+ },
259
+ placeholder: {
260
+ height: THUMB_HEIGHT,
261
+ paddingHorizontal: 16,
262
+ borderRadius: 4,
263
+ borderWidth: 1,
264
+ borderStyle: 'dashed',
265
+ alignItems: 'center',
266
+ justifyContent: 'center',
267
+ },
268
+ placeholderText: {
269
+ fontSize: 12,
270
+ opacity: 0.6,
271
+ },
272
+ count: {
273
+ fontSize: 12,
274
+ paddingHorizontal: 16,
275
+ paddingTop: 4,
276
+ },
277
+ });