react-native-rectangle-doc-scanner 3.41.0 → 3.43.1

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.
@@ -1,55 +1,20 @@
1
- import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
1
+ import React, { useCallback, useMemo, useRef, useState } from 'react';
2
2
  import {
3
3
  ActivityIndicator,
4
4
  Alert,
5
- Image,
6
- NativeModules,
7
5
  StyleSheet,
8
6
  Text,
9
7
  TouchableOpacity,
10
8
  View,
11
9
  } from 'react-native';
10
+ import { launchImageLibrary } from 'react-native-image-picker';
11
+ import ImageCropPicker from 'react-native-image-crop-picker';
12
12
  import { DocScanner } from './DocScanner';
13
- import { CropEditor } from './CropEditor';
14
- import type { CapturedDocument, Point, Quad, Rectangle } from './types';
13
+ import type { CapturedDocument } from './types';
15
14
  import type { DetectionConfig, DocScannerHandle, DocScannerCapture } from './DocScanner';
16
- import { createFullImageRectangle, quadToRectangle, scaleRectangle } from './utils/coordinate';
17
-
18
- type CustomCropManagerType = {
19
- crop: (
20
- points: {
21
- topLeft: Point;
22
- topRight: Point;
23
- bottomRight: Point;
24
- bottomLeft: Point;
25
- width: number;
26
- height: number;
27
- },
28
- imageUri: string,
29
- callback: (error: unknown, result: { image: string }) => void,
30
- ) => void;
31
- };
32
15
 
33
16
  const stripFileUri = (value: string) => value.replace(/^file:\/\//, '');
34
17
 
35
- const ensureFileUri = (value: string) => (value.startsWith('file://') ? value : `file://${value}`);
36
-
37
- const resolveImageSize = (
38
- path: string,
39
- fallbackWidth: number,
40
- fallbackHeight: number,
41
- ): Promise<{ width: number; height: number }> =>
42
- new Promise((resolve) => {
43
- Image.getSize(
44
- ensureFileUri(path),
45
- (width, height) => resolve({ width, height }),
46
- () => resolve({
47
- width: fallbackWidth > 0 ? fallbackWidth : 0,
48
- height: fallbackHeight > 0 ? fallbackHeight : 0,
49
- }),
50
- );
51
- });
52
-
53
18
  const normalizeCapturedDocument = (document: DocScannerCapture): CapturedDocument => {
54
19
  const { origin: _origin, ...rest } = document;
55
20
  const normalizedPath = stripFileUri(document.initialPath ?? document.path);
@@ -62,20 +27,20 @@ const normalizeCapturedDocument = (document: DocScannerCapture): CapturedDocumen
62
27
  };
63
28
 
64
29
  export interface FullDocScannerResult {
65
- original: CapturedDocument;
66
- rectangle: Rectangle | null;
67
- /** Base64-encoded JPEG string returned by CustomCropManager */
68
- base64: string;
30
+ /** File path to the cropped image */
31
+ path: string;
32
+ /** Base64-encoded image string (optional) */
33
+ base64?: string;
34
+ /** Original captured document info */
35
+ original?: CapturedDocument;
69
36
  }
70
37
 
71
38
  export interface FullDocScannerStrings {
72
39
  captureHint?: string;
73
40
  manualHint?: string;
74
41
  cancel?: string;
75
- confirm?: string;
76
- retake?: string;
77
- cropTitle?: string;
78
42
  processing?: string;
43
+ galleryButton?: string;
79
44
  }
80
45
 
81
46
  export interface FullDocScannerProps {
@@ -86,16 +51,15 @@ export interface FullDocScannerProps {
86
51
  gridColor?: string;
87
52
  gridLineWidth?: number;
88
53
  showGrid?: boolean;
89
- overlayStrokeColor?: string;
90
- handlerColor?: string;
91
54
  strings?: FullDocScannerStrings;
92
55
  manualCapture?: boolean;
93
56
  minStableFrames?: number;
94
57
  onError?: (error: Error) => void;
58
+ enableGallery?: boolean;
59
+ cropWidth?: number;
60
+ cropHeight?: number;
95
61
  }
96
62
 
97
- type ScreenState = 'scanner' | 'crop';
98
-
99
63
  export const FullDocScanner: React.FC<FullDocScannerProps> = ({
100
64
  onResult,
101
65
  onClose,
@@ -104,97 +68,30 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
104
68
  gridColor,
105
69
  gridLineWidth,
106
70
  showGrid,
107
- overlayStrokeColor = '#3170f3',
108
- handlerColor = '#3170f3',
109
71
  strings,
110
72
  manualCapture = false,
111
73
  minStableFrames,
112
74
  onError,
75
+ enableGallery = true,
76
+ cropWidth = 1200,
77
+ cropHeight = 1600,
113
78
  }) => {
114
- const [screen, setScreen] = useState<ScreenState>('scanner');
115
- const [capturedDoc, setCapturedDoc] = useState<CapturedDocument | null>(null);
116
- const [cropRectangle, setCropRectangle] = useState<Rectangle | null>(null);
117
- const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null);
118
79
  const [processing, setProcessing] = useState(false);
119
80
  const resolvedGridColor = gridColor ?? overlayColor;
120
81
  const docScannerRef = useRef<DocScannerHandle | null>(null);
121
82
  const manualCapturePending = useRef(false);
122
- const processingCaptureRef = useRef(false);
123
- const cropInitializedRef = useRef(false);
124
83
 
125
84
  const mergedStrings = useMemo(
126
85
  () => ({
127
86
  captureHint: strings?.captureHint,
128
87
  manualHint: strings?.manualHint,
129
88
  cancel: strings?.cancel,
130
- confirm: strings?.confirm,
131
- retake: strings?.retake,
132
- cropTitle: strings?.cropTitle,
133
89
  processing: strings?.processing,
90
+ galleryButton: strings?.galleryButton,
134
91
  }),
135
92
  [strings],
136
93
  );
137
94
 
138
- useEffect(() => {
139
- if (!capturedDoc) {
140
- return;
141
- }
142
-
143
- Image.getSize(
144
- ensureFileUri(capturedDoc.path),
145
- (width, height) => setImageSize({ width, height }),
146
- () => setImageSize({ width: capturedDoc.width, height: capturedDoc.height }),
147
- );
148
- }, [capturedDoc]);
149
-
150
- useEffect(() => {
151
- if (!capturedDoc || !imageSize || cropInitializedRef.current) {
152
- return;
153
- }
154
-
155
- const baseWidth = capturedDoc.width > 0 ? capturedDoc.width : imageSize.width;
156
- const baseHeight = capturedDoc.height > 0 ? capturedDoc.height : imageSize.height;
157
-
158
- let initialRectangle: Rectangle | null = null;
159
-
160
- if (capturedDoc.rectangle) {
161
- initialRectangle = scaleRectangle(
162
- capturedDoc.rectangle,
163
- baseWidth,
164
- baseHeight,
165
- imageSize.width,
166
- imageSize.height,
167
- );
168
- } else if (capturedDoc.quad && capturedDoc.quad.length === 4) {
169
- const quadRectangle = quadToRectangle(capturedDoc.quad as Quad);
170
- if (quadRectangle) {
171
- initialRectangle = scaleRectangle(
172
- quadRectangle,
173
- baseWidth,
174
- baseHeight,
175
- imageSize.width,
176
- imageSize.height,
177
- );
178
- }
179
- }
180
-
181
- cropInitializedRef.current = true;
182
- setCropRectangle(
183
- initialRectangle ?? createFullImageRectangle(imageSize.width || 1, imageSize.height || 1),
184
- );
185
- }, [capturedDoc, imageSize]);
186
-
187
- const resetState = useCallback(() => {
188
- setScreen('scanner');
189
- setCapturedDoc(null);
190
- setCropRectangle(null);
191
- setImageSize(null);
192
- setProcessing(false);
193
- manualCapturePending.current = false;
194
- processingCaptureRef.current = false;
195
- cropInitializedRef.current = false;
196
- }, []);
197
-
198
95
  const emitError = useCallback(
199
96
  (error: Error, fallbackMessage?: string) => {
200
97
  console.error('[FullDocScanner] error', error);
@@ -206,144 +103,63 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
206
103
  [onError],
207
104
  );
208
105
 
209
- const processAutoCapture = useCallback(
210
- async (document: DocScannerCapture) => {
211
- console.log('[FullDocScanner] processAutoCapture started');
212
- manualCapturePending.current = false;
213
- const normalizedDoc = normalizeCapturedDocument(document);
214
- const cropManager = NativeModules.CustomCropManager as CustomCropManagerType | undefined;
215
-
216
- if (!cropManager?.crop) {
217
- console.error('[FullDocScanner] CustomCropManager.crop is not available');
218
- emitError(new Error('CustomCropManager.crop is not available'));
219
- return;
220
- }
221
-
222
- console.log('[FullDocScanner] Setting processing to true');
223
- setProcessing(true);
224
-
106
+ const openCropper = useCallback(
107
+ async (imagePath: string) => {
225
108
  try {
226
- const size = await resolveImageSize(
227
- normalizedDoc.path,
228
- normalizedDoc.width,
229
- normalizedDoc.height,
230
- );
231
-
232
- const targetWidthRaw = size.width > 0 ? size.width : normalizedDoc.width;
233
- const targetHeightRaw = size.height > 0 ? size.height : normalizedDoc.height;
234
- const baseWidth = normalizedDoc.width > 0 ? normalizedDoc.width : targetWidthRaw;
235
- const baseHeight = normalizedDoc.height > 0 ? normalizedDoc.height : targetHeightRaw;
236
- const targetWidth = targetWidthRaw > 0 ? targetWidthRaw : baseWidth || 1;
237
- const targetHeight = targetHeightRaw > 0 ? targetHeightRaw : baseHeight || 1;
238
-
239
- let rectangleBase: Rectangle | null = normalizedDoc.rectangle ?? null;
240
- if (!rectangleBase && normalizedDoc.quad && normalizedDoc.quad.length === 4) {
241
- rectangleBase = quadToRectangle(normalizedDoc.quad as Quad);
242
- }
243
-
244
- const scaledRectangle = rectangleBase
245
- ? scaleRectangle(
246
- rectangleBase,
247
- baseWidth || targetWidth,
248
- baseHeight || targetHeight,
249
- targetWidth,
250
- targetHeight,
251
- )
252
- : null;
253
-
254
- const rectangleToUse = scaledRectangle ?? createFullImageRectangle(targetWidth, targetHeight);
255
-
256
- console.log('[FullDocScanner] Calling CustomCropManager.crop with:', {
257
- rectangle: rectangleToUse,
258
- imageUri: ensureFileUri(normalizedDoc.path),
259
- targetSize: { width: targetWidth, height: targetHeight },
109
+ setProcessing(true);
110
+ const croppedImage = await ImageCropPicker.openCropper({
111
+ path: imagePath,
112
+ mediaType: 'photo',
113
+ width: cropWidth,
114
+ height: cropHeight,
115
+ cropping: true,
116
+ cropperToolbarTitle: 'Crop Document',
117
+ freeStyleCropEnabled: true,
118
+ includeBase64: true,
119
+ compressImageQuality: 0.9,
260
120
  });
261
121
 
262
- const base64 = await new Promise<string>((resolve, reject) => {
263
- cropManager.crop(
264
- {
265
- topLeft: rectangleToUse.topLeft,
266
- topRight: rectangleToUse.topRight,
267
- bottomRight: rectangleToUse.bottomRight,
268
- bottomLeft: rectangleToUse.bottomLeft,
269
- width: targetWidth,
270
- height: targetHeight,
271
- },
272
- ensureFileUri(normalizedDoc.path),
273
- (error: unknown, result: { image: string }) => {
274
- if (error) {
275
- console.error('[FullDocScanner] CustomCropManager.crop error:', error);
276
- reject(error instanceof Error ? error : new Error('Crop failed'));
277
- return;
278
- }
279
- console.log('[FullDocScanner] CustomCropManager.crop success, base64 length:', result.image?.length);
280
- resolve(result.image);
281
- },
282
- );
283
- });
284
-
285
- const finalDoc: CapturedDocument = {
286
- ...normalizedDoc,
287
- rectangle: rectangleToUse,
288
- };
122
+ setProcessing(false);
289
123
 
290
- console.log('[FullDocScanner] Calling onResult with base64 length:', base64?.length);
291
124
  onResult({
292
- original: finalDoc,
293
- rectangle: rectangleToUse,
294
- base64,
125
+ path: croppedImage.path,
126
+ base64: croppedImage.data ?? undefined,
295
127
  });
296
-
297
- console.log('[FullDocScanner] Resetting state');
298
- resetState();
299
128
  } catch (error) {
300
129
  setProcessing(false);
301
- emitError(error instanceof Error ? error : new Error(String(error)), 'Failed to process document.');
302
- } finally {
303
- processingCaptureRef.current = false;
130
+ if ((error as any)?.message !== 'User cancelled image selection') {
131
+ emitError(
132
+ error instanceof Error ? error : new Error(String(error)),
133
+ 'Failed to crop image.',
134
+ );
135
+ }
304
136
  }
305
137
  },
306
- [emitError, onResult, resetState],
138
+ [cropWidth, cropHeight, emitError, onResult],
307
139
  );
308
140
 
309
141
  const handleCapture = useCallback(
310
- (document: DocScannerCapture) => {
142
+ async (document: DocScannerCapture) => {
311
143
  console.log('[FullDocScanner] handleCapture called:', {
312
144
  origin: document.origin,
313
145
  path: document.path,
314
- width: document.width,
315
- height: document.height,
316
- hasQuad: !!document.quad,
317
- hasRectangle: !!document.rectangle,
318
146
  });
319
147
 
320
- if (processingCaptureRef.current) {
321
- console.log('[FullDocScanner] Already processing, skipping');
322
- return;
148
+ if (manualCapturePending.current) {
149
+ manualCapturePending.current = false;
323
150
  }
324
151
 
325
152
  const normalizedDoc = normalizeCapturedDocument(document);
326
153
 
327
- // 자동 촬영이든 수동 촬영이든 모두 crop 화면으로 이동
328
- console.log('[FullDocScanner] Moving to crop/preview screen');
329
- manualCapturePending.current = false;
330
- processingCaptureRef.current = false;
331
- cropInitializedRef.current = false;
332
- setCapturedDoc(normalizedDoc);
333
- setImageSize(null);
334
- setCropRectangle(null);
335
- setScreen('crop');
154
+ // Open cropper with the captured image
155
+ await openCropper(normalizedDoc.path);
336
156
  },
337
- [],
157
+ [openCropper],
338
158
  );
339
159
 
340
- const handleCropChange = useCallback((rectangle: Rectangle) => {
341
- setCropRectangle(rectangle);
342
- }, []);
343
-
344
160
  const triggerManualCapture = useCallback(() => {
345
161
  console.log('[FullDocScanner] triggerManualCapture called');
346
- if (processingCaptureRef.current) {
162
+ if (processing) {
347
163
  console.log('[FullDocScanner] Already processing, skipping manual capture');
348
164
  return;
349
165
  }
@@ -356,7 +172,6 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
356
172
  manualCapturePending.current = true;
357
173
 
358
174
  const capturePromise = docScannerRef.current?.capture();
359
- console.log('[FullDocScanner] capturePromise:', !!capturePromise);
360
175
  if (capturePromise && typeof capturePromise.then === 'function') {
361
176
  capturePromise
362
177
  .then(() => {
@@ -370,185 +185,108 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
370
185
  console.warn('[FullDocScanner] No capture promise returned');
371
186
  manualCapturePending.current = false;
372
187
  }
373
- }, []);
374
-
375
- const performCrop = useCallback(async (): Promise<{ base64: string; rectangle: Rectangle }> => {
376
- if (!capturedDoc) {
377
- throw new Error('No captured document to crop');
378
- }
379
-
380
- const size = imageSize ?? { width: capturedDoc.width, height: capturedDoc.height };
381
- const cropManager = NativeModules.CustomCropManager as CustomCropManagerType | undefined;
188
+ }, [processing]);
382
189
 
383
- if (!cropManager?.crop) {
384
- throw new Error('CustomCropManager.crop is not available');
190
+ const handleGalleryPick = useCallback(async () => {
191
+ console.log('[FullDocScanner] handleGalleryPick called');
192
+ if (processing) {
193
+ return;
385
194
  }
386
195
 
387
- const baseWidth = capturedDoc.width > 0 ? capturedDoc.width : size.width;
388
- const baseHeight = capturedDoc.height > 0 ? capturedDoc.height : size.height;
389
- const targetWidth = size.width > 0 ? size.width : baseWidth || 1;
390
- const targetHeight = size.height > 0 ? size.height : baseHeight || 1;
391
-
392
- let fallbackRectangle: Rectangle | null = null;
196
+ try {
197
+ const result = await launchImageLibrary({
198
+ mediaType: 'photo',
199
+ quality: 1,
200
+ selectionLimit: 1,
201
+ });
393
202
 
394
- if (capturedDoc.rectangle) {
395
- fallbackRectangle = scaleRectangle(
396
- capturedDoc.rectangle,
397
- baseWidth || targetWidth,
398
- baseHeight || targetHeight,
399
- targetWidth,
400
- targetHeight,
401
- );
402
- } else if (capturedDoc.quad && capturedDoc.quad.length === 4) {
403
- const quadRectangle = quadToRectangle(capturedDoc.quad as Quad);
404
- if (quadRectangle) {
405
- fallbackRectangle = scaleRectangle(
406
- quadRectangle,
407
- baseWidth || targetWidth,
408
- baseHeight || targetHeight,
409
- targetWidth,
410
- targetHeight,
411
- );
203
+ if (result.didCancel || !result.assets?.[0]?.uri) {
204
+ console.log('[FullDocScanner] User cancelled gallery picker');
205
+ return;
412
206
  }
413
- }
414
-
415
- const rectangleToUse = cropRectangle ?? fallbackRectangle ?? createFullImageRectangle(targetWidth, targetHeight);
416
207
 
417
- const base64 = await new Promise<string>((resolve, reject) => {
418
- cropManager.crop(
419
- {
420
- topLeft: rectangleToUse.topLeft,
421
- topRight: rectangleToUse.topRight,
422
- bottomRight: rectangleToUse.bottomRight,
423
- bottomLeft: rectangleToUse.bottomLeft,
424
- width: targetWidth,
425
- height: targetHeight,
426
- },
427
- ensureFileUri(capturedDoc.path),
428
- (error: unknown, result: { image: string }) => {
429
- if (error) {
430
- reject(error instanceof Error ? error : new Error('Crop failed'));
431
- return;
432
- }
433
-
434
- resolve(result.image);
435
- },
436
- );
437
- });
438
-
439
- return { base64, rectangle: rectangleToUse };
440
- }, [capturedDoc, cropRectangle, imageSize]);
441
-
442
- const handleConfirm = useCallback(async () => {
443
- if (!capturedDoc) {
444
- return;
445
- }
208
+ const imageUri = result.assets[0].uri;
209
+ console.log('[FullDocScanner] Gallery image selected:', imageUri);
446
210
 
447
- try {
448
- setProcessing(true);
449
- const { base64, rectangle } = await performCrop();
450
- setProcessing(false);
451
- const finalDoc: CapturedDocument = {
452
- ...capturedDoc,
453
- rectangle,
454
- };
455
- onResult({
456
- original: finalDoc,
457
- rectangle,
458
- base64,
459
- });
460
- resetState();
211
+ // Open cropper with the selected image
212
+ await openCropper(imageUri);
461
213
  } catch (error) {
462
- setProcessing(false);
463
- emitError(error instanceof Error ? error : new Error(String(error)), 'Failed to process document.');
214
+ emitError(
215
+ error instanceof Error ? error : new Error(String(error)),
216
+ 'Failed to pick image from gallery.',
217
+ );
464
218
  }
465
- }, [capturedDoc, emitError, onResult, performCrop, resetState]);
466
-
467
- const handleRetake = useCallback(() => {
468
- resetState();
469
- }, [resetState]);
219
+ }, [processing, openCropper, emitError]);
470
220
 
471
221
  const handleClose = useCallback(() => {
472
- resetState();
473
222
  onClose?.();
474
- }, [onClose, resetState]);
223
+ }, [onClose]);
475
224
 
476
225
  return (
477
226
  <View style={styles.container}>
478
- {screen === 'scanner' && (
479
- <View style={styles.flex}>
480
- <DocScanner
481
- ref={docScannerRef}
482
- autoCapture={!manualCapture}
483
- overlayColor={overlayColor}
484
- showGrid={showGrid}
485
- gridColor={resolvedGridColor}
486
- gridLineWidth={gridLineWidth}
487
- minStableFrames={minStableFrames ?? 6}
488
- detectionConfig={detectionConfig}
489
- onCapture={handleCapture}
490
- showManualCaptureButton={false}
491
- >
492
- <View style={styles.overlayTop} pointerEvents="box-none">
493
- <TouchableOpacity
494
- style={styles.closeButton}
495
- onPress={handleClose}
496
- accessibilityLabel={mergedStrings.cancel}
497
- accessibilityRole="button"
498
- >
499
- <Text style={styles.closeButtonLabel}>×</Text>
500
- </TouchableOpacity>
501
- </View>
502
- {(mergedStrings.captureHint || mergedStrings.manualHint) && (
503
- <View style={styles.instructionsContainer} pointerEvents="none">
504
- <View style={styles.instructions}>
505
- {mergedStrings.captureHint && (
506
- <Text style={styles.captureText}>{mergedStrings.captureHint}</Text>
507
- )}
508
- {mergedStrings.manualHint && (
509
- <Text style={styles.captureText}>{mergedStrings.manualHint}</Text>
510
- )}
511
- </View>
227
+ <View style={styles.flex}>
228
+ <DocScanner
229
+ ref={docScannerRef}
230
+ autoCapture={!manualCapture}
231
+ overlayColor={overlayColor}
232
+ showGrid={showGrid}
233
+ gridColor={resolvedGridColor}
234
+ gridLineWidth={gridLineWidth}
235
+ minStableFrames={minStableFrames ?? 6}
236
+ detectionConfig={detectionConfig}
237
+ onCapture={handleCapture}
238
+ showManualCaptureButton={false}
239
+ >
240
+ <View style={styles.overlayTop} pointerEvents="box-none">
241
+ <TouchableOpacity
242
+ style={styles.closeButton}
243
+ onPress={handleClose}
244
+ accessibilityLabel={mergedStrings.cancel}
245
+ accessibilityRole="button"
246
+ >
247
+ <Text style={styles.closeButtonLabel}>×</Text>
248
+ </TouchableOpacity>
249
+ </View>
250
+ {(mergedStrings.captureHint || mergedStrings.manualHint) && (
251
+ <View style={styles.instructionsContainer} pointerEvents="none">
252
+ <View style={styles.instructions}>
253
+ {mergedStrings.captureHint && (
254
+ <Text style={styles.captureText}>{mergedStrings.captureHint}</Text>
255
+ )}
256
+ {mergedStrings.manualHint && (
257
+ <Text style={styles.captureText}>{mergedStrings.manualHint}</Text>
258
+ )}
512
259
  </View>
513
- )}
514
- <View style={styles.shutterContainer} pointerEvents="box-none">
260
+ </View>
261
+ )}
262
+ <View style={styles.shutterContainer} pointerEvents="box-none">
263
+ {enableGallery && (
515
264
  <TouchableOpacity
516
- style={[styles.shutterButton, processing && styles.shutterButtonDisabled]}
517
- onPress={triggerManualCapture}
265
+ style={[styles.galleryButton, processing && styles.buttonDisabled]}
266
+ onPress={handleGalleryPick}
518
267
  disabled={processing}
519
- accessibilityLabel={mergedStrings.manualHint}
268
+ accessibilityLabel={mergedStrings.galleryButton}
520
269
  accessibilityRole="button"
521
270
  >
522
- <View style={styles.shutterInner} />
271
+ <Text style={styles.galleryButtonText}>📁</Text>
523
272
  </TouchableOpacity>
524
- </View>
525
- </DocScanner>
526
- </View>
527
- )}
528
-
529
- {screen === 'crop' && capturedDoc && (
530
- <View style={styles.flex}>
531
- <CropEditor
532
- document={capturedDoc}
533
- overlayColor="rgba(0,0,0,0.6)"
534
- overlayStrokeColor={overlayStrokeColor}
535
- handlerColor={handlerColor}
536
- onCropChange={handleCropChange}
537
- />
538
- <View style={styles.cropFooter}>
539
- <TouchableOpacity style={[styles.actionButton, styles.secondaryButton]} onPress={handleRetake}>
540
- {mergedStrings.retake && <Text style={styles.buttonText}>{mergedStrings.retake}</Text>}
541
- </TouchableOpacity>
542
- <TouchableOpacity style={[styles.actionButton, styles.primaryButton]} onPress={handleConfirm}>
543
- {mergedStrings.confirm && <Text style={styles.buttonText}>{mergedStrings.confirm}</Text>}
273
+ )}
274
+ <TouchableOpacity
275
+ style={[styles.shutterButton, processing && styles.buttonDisabled]}
276
+ onPress={triggerManualCapture}
277
+ disabled={processing}
278
+ accessibilityLabel={mergedStrings.manualHint}
279
+ accessibilityRole="button"
280
+ >
281
+ <View style={styles.shutterInner} />
544
282
  </TouchableOpacity>
545
283
  </View>
546
- </View>
547
- )}
284
+ </DocScanner>
285
+ </View>
548
286
 
549
287
  {processing && (
550
288
  <View style={styles.processingOverlay}>
551
- <ActivityIndicator size="large" color={overlayStrokeColor} />
289
+ <ActivityIndicator size="large" color={overlayColor} />
552
290
  {mergedStrings.processing && (
553
291
  <Text style={styles.processingText}>{mergedStrings.processing}</Text>
554
292
  )}
@@ -585,7 +323,10 @@ const styles = StyleSheet.create({
585
323
  bottom: 64,
586
324
  left: 0,
587
325
  right: 0,
326
+ flexDirection: 'row',
327
+ justifyContent: 'center',
588
328
  alignItems: 'center',
329
+ gap: 24,
589
330
  zIndex: 10,
590
331
  },
591
332
  closeButton: {
@@ -613,6 +354,19 @@ const styles = StyleSheet.create({
613
354
  fontSize: 15,
614
355
  textAlign: 'center',
615
356
  },
357
+ galleryButton: {
358
+ width: 60,
359
+ height: 60,
360
+ borderRadius: 30,
361
+ borderWidth: 3,
362
+ borderColor: '#fff',
363
+ justifyContent: 'center',
364
+ alignItems: 'center',
365
+ backgroundColor: 'rgba(255,255,255,0.1)',
366
+ },
367
+ galleryButtonText: {
368
+ fontSize: 28,
369
+ },
616
370
  shutterButton: {
617
371
  width: 80,
618
372
  height: 80,
@@ -623,7 +377,7 @@ const styles = StyleSheet.create({
623
377
  alignItems: 'center',
624
378
  backgroundColor: 'rgba(255,255,255,0.1)',
625
379
  },
626
- shutterButtonDisabled: {
380
+ buttonDisabled: {
627
381
  opacity: 0.4,
628
382
  },
629
383
  shutterInner: {
@@ -632,34 +386,6 @@ const styles = StyleSheet.create({
632
386
  borderRadius: 30,
633
387
  backgroundColor: '#fff',
634
388
  },
635
- cropFooter: {
636
- position: 'absolute',
637
- bottom: 40,
638
- left: 20,
639
- right: 20,
640
- flexDirection: 'row',
641
- justifyContent: 'space-between',
642
- },
643
- actionButton: {
644
- flex: 1,
645
- paddingVertical: 14,
646
- borderRadius: 12,
647
- alignItems: 'center',
648
- marginHorizontal: 6,
649
- },
650
- secondaryButton: {
651
- backgroundColor: 'rgba(255,255,255,0.2)',
652
- borderWidth: 1,
653
- borderColor: 'rgba(255,255,255,0.35)',
654
- },
655
- primaryButton: {
656
- backgroundColor: '#3170f3',
657
- },
658
- buttonText: {
659
- color: '#fff',
660
- fontSize: 16,
661
- fontWeight: '600',
662
- },
663
389
  processingOverlay: {
664
390
  ...StyleSheet.absoluteFillObject,
665
391
  backgroundColor: 'rgba(0,0,0,0.65)',