react-native-rectangle-doc-scanner 3.111.0 → 3.114.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.
@@ -8,6 +8,8 @@ export interface FullDocScannerResult {
8
8
  base64?: string;
9
9
  /** Original captured document info */
10
10
  original?: CapturedDocument;
11
+ /** Rotation angle applied by user (0, 90, 180, 270) */
12
+ rotationDegrees?: number;
11
13
  }
12
14
  export interface FullDocScannerStrings {
13
15
  captureHint?: string;
@@ -18,9 +20,12 @@ export interface FullDocScannerStrings {
18
20
  retake?: string;
19
21
  confirm?: string;
20
22
  cropTitle?: string;
23
+ first?: string;
24
+ second?: string;
25
+ secondBtn?: string;
21
26
  }
22
27
  export interface FullDocScannerProps {
23
- onResult: (result: FullDocScannerResult) => void;
28
+ onResult: (results: FullDocScannerResult[]) => void;
24
29
  onClose?: () => void;
25
30
  detectionConfig?: DetectionConfig;
26
31
  overlayColor?: string;
@@ -33,5 +38,6 @@ export interface FullDocScannerProps {
33
38
  enableGallery?: boolean;
34
39
  cropWidth?: number;
35
40
  cropHeight?: number;
41
+ type?: 'business';
36
42
  }
37
43
  export declare const FullDocScanner: React.FC<FullDocScannerProps>;
@@ -52,9 +52,9 @@ catch (error) {
52
52
  }
53
53
  let expoManipulatorUnavailable = false;
54
54
  const isExpoImageManipulatorAvailable = () => !!ImageManipulator?.manipulateAsync && !expoManipulatorUnavailable;
55
- const isImageRotationSupported = () => isExpoImageManipulatorAvailable();
55
+ // 회전은 항상 지원됨 (회전 각도를 반환하고 tdb 앱에서 처리)
56
+ const isImageRotationSupported = () => true;
56
57
  const stripFileUri = (value) => value.replace(/^file:\/\//, '');
57
- const ensureFileUri = (value) => (value.startsWith('file://') ? value : `file://${value}`);
58
58
  const CROPPER_TIMEOUT_MS = 8000;
59
59
  const CROPPER_TIMEOUT_CODE = 'cropper_timeout';
60
60
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -115,7 +115,7 @@ const normalizeCapturedDocument = (document) => {
115
115
  croppedPath: document.croppedPath ? stripFileUri(document.croppedPath) : null,
116
116
  };
117
117
  };
118
- const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3170f3', gridColor, gridLineWidth, showGrid, strings, minStableFrames, onError, enableGallery = true, cropWidth = 1200, cropHeight = 1600, }) => {
118
+ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3170f3', gridColor, gridLineWidth, showGrid, strings, minStableFrames, onError, enableGallery = true, cropWidth = 1200, cropHeight = 1600, type, }) => {
119
119
  const [processing, setProcessing] = (0, react_1.useState)(false);
120
120
  const [croppedImageData, setCroppedImageData] = (0, react_1.useState)(null);
121
121
  const [isGalleryOpen, setIsGalleryOpen] = (0, react_1.useState)(false);
@@ -123,12 +123,16 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
123
123
  const [rectangleHint, setRectangleHint] = (0, react_1.useState)(false);
124
124
  const [flashEnabled, setFlashEnabled] = (0, react_1.useState)(false);
125
125
  const [rotationDegrees, setRotationDegrees] = (0, react_1.useState)(0);
126
+ const [capturedPhotos, setCapturedPhotos] = (0, react_1.useState)([]);
127
+ const [currentPhotoIndex, setCurrentPhotoIndex] = (0, react_1.useState)(0);
126
128
  const resolvedGridColor = gridColor ?? overlayColor;
127
129
  const docScannerRef = (0, react_1.useRef)(null);
128
130
  const captureModeRef = (0, react_1.useRef)(null);
129
131
  const captureInProgressRef = (0, react_1.useRef)(false);
130
132
  const rectangleCaptureTimeoutRef = (0, react_1.useRef)(null);
131
133
  const rectangleHintTimeoutRef = (0, react_1.useRef)(null);
134
+ const isBusinessMode = type === 'business';
135
+ const maxPhotos = isBusinessMode ? 2 : 1;
132
136
  const mergedStrings = (0, react_1.useMemo)(() => ({
133
137
  captureHint: strings?.captureHint,
134
138
  manualHint: strings?.manualHint,
@@ -138,6 +142,9 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
138
142
  retake: strings?.retake ?? 'Retake',
139
143
  confirm: strings?.confirm ?? 'Confirm',
140
144
  cropTitle: strings?.cropTitle ?? 'Crop Document',
145
+ first: strings?.first ?? 'Front',
146
+ second: strings?.second ?? 'Back',
147
+ secondBtn: strings?.secondBtn ?? 'Capture Back Side?',
141
148
  }), [strings]);
142
149
  const emitError = (0, react_1.useCallback)((error, fallbackMessage) => {
143
150
  console.error('[FullDocScanner] error', error);
@@ -374,95 +381,56 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
374
381
  if (!croppedImageData) {
375
382
  return;
376
383
  }
377
- // 회전이 없으면 기존 데이터 그대로 전달
378
- if (rotationDegrees === 0) {
379
- console.log('[FullDocScanner] No rotation, returning original image');
380
- onResult({
381
- path: croppedImageData.path,
382
- base64: croppedImageData.base64,
383
- });
384
+ // 회전 각도 정규화 (0, 90, 180, 270)
385
+ const rotationNormalized = ((rotationDegrees % 360) + 360) % 360;
386
+ console.log('[FullDocScanner] Confirm - rotation degrees:', rotationDegrees, 'normalized:', rotationNormalized);
387
+ // 현재 사진을 capturedPhotos에 추가
388
+ const currentPhoto = {
389
+ path: croppedImageData.path,
390
+ base64: croppedImageData.base64,
391
+ rotationDegrees: rotationNormalized,
392
+ };
393
+ const updatedPhotos = [...capturedPhotos, currentPhoto];
394
+ console.log('[FullDocScanner] Photos captured:', updatedPhotos.length, 'of', maxPhotos);
395
+ // Business 모드이고 아직 첫 번째 사진만 찍은 경우
396
+ if (isBusinessMode && updatedPhotos.length === 1) {
397
+ // 두 번째 사진 촬영 여부를 물어봄 (UI에서 버튼으로 표시)
398
+ setCapturedPhotos(updatedPhotos);
399
+ setCurrentPhotoIndex(1);
400
+ // 확인 화면을 유지하고 "뒷면 촬영" 버튼을 표시
384
401
  return;
385
402
  }
386
- try {
387
- setProcessing(true);
388
- // 회전 각도 정규화 (0, 90, 180, 270)
389
- const rotationNormalized = ((rotationDegrees % 360) + 360) % 360;
390
- console.log('[FullDocScanner] Rotation degrees:', rotationDegrees, 'normalized:', rotationNormalized);
391
- if (rotationNormalized === 0) {
392
- console.log('[FullDocScanner] Normalized to 0, returning original image');
393
- onResult({
394
- path: croppedImageData.path,
395
- base64: croppedImageData.base64,
396
- });
397
- setProcessing(false);
398
- return;
399
- }
400
- // expo-image-manipulator 시도
401
- if (isExpoImageManipulatorAvailable() && ImageManipulator?.manipulateAsync) {
402
- try {
403
- console.log('[FullDocScanner] Using expo-image-manipulator for rotation');
404
- const inputUri = ensureFileUri(croppedImageData.path);
405
- const result = await ImageManipulator.manipulateAsync(inputUri, [{ rotate: rotationNormalized }], {
406
- compress: 0.9,
407
- format: ImageManipulator.SaveFormat.JPEG,
408
- base64: true,
409
- });
410
- console.log('[FullDocScanner] Rotation complete via expo-image-manipulator:', {
411
- path: result.uri,
412
- hasBase64: !!result.base64,
413
- });
414
- onResult({
415
- path: stripFileUri(result.uri),
416
- base64: result.base64,
417
- });
418
- setProcessing(false);
419
- return;
420
- }
421
- catch (manipulatorError) {
422
- const code = manipulatorError && typeof manipulatorError === 'object' && 'code' in manipulatorError
423
- ? String(manipulatorError.code)
424
- : undefined;
425
- console.error('[FullDocScanner] expo-image-manipulator error:', manipulatorError);
426
- if (code === 'ERR_UNAVAILABLE') {
427
- expoManipulatorUnavailable = true;
428
- console.warn('[FullDocScanner] expo-image-manipulator unavailable at runtime. Trying react-native-image-rotate.');
429
- }
430
- else {
431
- throw manipulatorError;
432
- }
433
- }
434
- }
435
- // expo-image-manipulator를 사용할 수 없는 경우 에러 처리
436
- console.error('[FullDocScanner] Rotation requested but expo-image-manipulator is not available.');
437
- setProcessing(false);
438
- react_native_1.Alert.alert('회전 불가', 'expo-image-manipulator가 설치되지 않아 이미지 회전을 수행할 수 없습니다.\n\n패키지를 설치해주세요:\nnpm install expo-image-manipulator', [
439
- {
440
- text: '원본 사용',
441
- onPress: () => {
442
- onResult({
443
- path: croppedImageData.path,
444
- base64: croppedImageData.base64,
445
- });
446
- },
447
- },
448
- {
449
- text: '취소',
450
- style: 'cancel',
451
- },
452
- ]);
403
+ // 모든 사진 촬영 완료 - 결과 반환
404
+ console.log('[FullDocScanner] All photos captured, returning results');
405
+ onResult(updatedPhotos);
406
+ }, [croppedImageData, rotationDegrees, capturedPhotos, isBusinessMode, maxPhotos, onResult]);
407
+ const handleCaptureSecondPhoto = (0, react_1.useCallback)(() => {
408
+ console.log('[FullDocScanner] Capturing second photo');
409
+ // 확인 화면을 닫고 카메라로 돌아감
410
+ setCroppedImageData(null);
411
+ setRotationDegrees(0);
412
+ setProcessing(false);
413
+ setRectangleDetected(false);
414
+ setRectangleHint(false);
415
+ captureModeRef.current = null;
416
+ captureInProgressRef.current = false;
417
+ if (rectangleCaptureTimeoutRef.current) {
418
+ clearTimeout(rectangleCaptureTimeoutRef.current);
419
+ rectangleCaptureTimeoutRef.current = null;
453
420
  }
454
- catch (error) {
455
- console.error('[FullDocScanner] Image rotation error on confirm:', error);
456
- setProcessing(false);
457
- const errorMessage = error && typeof error === 'object' && 'message' in error ? error.message : String(error);
458
- react_native_1.Alert.alert('회전 실패', `이미지 회전 중 오류가 발생했습니다.\n원본 이미지를 사용합니다.\n\n오류: ${errorMessage}`);
459
- // 에러 발생 시 원본 이미지 반환
460
- onResult({
461
- path: croppedImageData.path,
462
- base64: croppedImageData.base64,
463
- });
421
+ if (rectangleHintTimeoutRef.current) {
422
+ clearTimeout(rectangleHintTimeoutRef.current);
423
+ rectangleHintTimeoutRef.current = null;
464
424
  }
465
- }, [croppedImageData, rotationDegrees, onResult]);
425
+ if (docScannerRef.current?.reset) {
426
+ docScannerRef.current.reset();
427
+ }
428
+ }, []);
429
+ const handleSkipSecondPhoto = (0, react_1.useCallback)(() => {
430
+ console.log('[FullDocScanner] Skipping second photo');
431
+ // 첫 번째 사진만 반환
432
+ onResult(capturedPhotos);
433
+ }, [capturedPhotos, onResult]);
466
434
  const handleRetake = (0, react_1.useCallback)(() => {
467
435
  console.log('[FullDocScanner] Retake - clearing cropped image and resetting scanner');
468
436
  setCroppedImageData(null);
@@ -537,6 +505,8 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
537
505
  croppedImageData ? (
538
506
  // check_DP: Show confirmation screen
539
507
  react_1.default.createElement(react_native_1.View, { style: styles.confirmationContainer },
508
+ isBusinessMode && (react_1.default.createElement(react_native_1.View, { style: styles.photoHeader },
509
+ react_1.default.createElement(react_native_1.Text, { style: styles.photoHeaderText }, currentPhotoIndex === 0 ? mergedStrings.first : mergedStrings.second))),
540
510
  isImageRotationSupported() ? (react_1.default.createElement(react_native_1.View, { style: styles.rotateButtonsCenter },
541
511
  react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.rotateButtonTop, onPress: () => handleRotateImage(-90), accessibilityLabel: "\uC67C\uCABD\uC73C\uB85C 90\uB3C4 \uD68C\uC804", accessibilityRole: "button" },
542
512
  react_1.default.createElement(react_native_1.Text, { style: styles.rotateIconText }, "\u21BA"),
@@ -551,8 +521,12 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
551
521
  react_1.default.createElement(react_native_1.View, { style: styles.confirmationButtons },
552
522
  react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.retakeButton], onPress: handleRetake, accessibilityLabel: mergedStrings.retake, accessibilityRole: "button" },
553
523
  react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.retake)),
554
- react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.confirmButtonPrimary], onPress: handleConfirm, accessibilityLabel: mergedStrings.confirm, accessibilityRole: "button" },
555
- react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.confirm))))) : (react_1.default.createElement(react_native_1.View, { style: styles.flex },
524
+ isBusinessMode && capturedPhotos.length === 1 && currentPhotoIndex === 1 ? (react_1.default.createElement(react_1.default.Fragment, null,
525
+ react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.confirmButtonPrimary], onPress: handleCaptureSecondPhoto, accessibilityLabel: mergedStrings.secondBtn, accessibilityRole: "button" },
526
+ react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.secondBtn)),
527
+ react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.skipButton], onPress: handleSkipSecondPhoto, accessibilityLabel: mergedStrings.confirm, accessibilityRole: "button" },
528
+ react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.confirm)))) : (react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.confirmButtonPrimary], onPress: handleConfirm, accessibilityLabel: mergedStrings.confirm, accessibilityRole: "button" },
529
+ react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.confirm)))))) : (react_1.default.createElement(react_native_1.View, { style: styles.flex },
556
530
  react_1.default.createElement(DocScanner_1.DocScanner, { ref: docScannerRef, autoCapture: false, overlayColor: overlayColor, showGrid: showGrid, gridColor: resolvedGridColor, gridLineWidth: gridLineWidth, minStableFrames: minStableFrames ?? 6, detectionConfig: detectionConfig, onCapture: handleCapture, onRectangleDetect: handleRectangleDetect, showManualCaptureButton: false, enableTorch: flashEnabled },
557
531
  react_1.default.createElement(react_native_1.View, { style: styles.overlayTop, pointerEvents: "box-none" },
558
532
  react_1.default.createElement(react_native_1.TouchableOpacity, { style: [
@@ -562,6 +536,8 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
562
536
  ], onPress: handleFlashToggle, disabled: processing, accessibilityLabel: "Toggle flash", accessibilityRole: "button" },
563
537
  react_1.default.createElement(react_native_1.View, { style: styles.iconContainer },
564
538
  react_1.default.createElement(react_native_1.Text, { style: styles.iconText }, "\u26A1\uFE0F"))),
539
+ isBusinessMode && (react_1.default.createElement(react_native_1.View, { style: styles.cameraHeaderContainer },
540
+ react_1.default.createElement(react_native_1.Text, { style: styles.cameraHeaderText }, currentPhotoIndex === 0 ? mergedStrings.first : mergedStrings.second))),
565
541
  react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.iconButton, onPress: handleClose, accessibilityLabel: mergedStrings.cancel, accessibilityRole: "button" },
566
542
  react_1.default.createElement(react_native_1.View, { style: styles.iconContainer },
567
543
  react_1.default.createElement(react_native_1.Text, { style: styles.closeIconText }, "\u00D7")))),
@@ -765,9 +741,44 @@ const styles = react_native_1.StyleSheet.create({
765
741
  confirmButtonPrimary: {
766
742
  backgroundColor: '#3170f3',
767
743
  },
744
+ skipButton: {
745
+ backgroundColor: 'rgba(100,100,100,0.8)',
746
+ },
768
747
  confirmButtonText: {
769
748
  color: '#fff',
770
749
  fontSize: 18,
771
750
  fontWeight: '600',
772
751
  },
752
+ photoHeader: {
753
+ position: 'absolute',
754
+ top: 20,
755
+ left: 0,
756
+ right: 0,
757
+ alignItems: 'center',
758
+ zIndex: 5,
759
+ },
760
+ photoHeaderText: {
761
+ color: '#fff',
762
+ fontSize: 20,
763
+ fontWeight: 'bold',
764
+ backgroundColor: 'rgba(0,0,0,0.6)',
765
+ paddingHorizontal: 24,
766
+ paddingVertical: 8,
767
+ borderRadius: 20,
768
+ },
769
+ cameraHeaderContainer: {
770
+ position: 'absolute',
771
+ left: 0,
772
+ right: 0,
773
+ alignItems: 'center',
774
+ },
775
+ cameraHeaderText: {
776
+ color: '#fff',
777
+ fontSize: 18,
778
+ fontWeight: 'bold',
779
+ backgroundColor: 'rgba(0,0,0,0.7)',
780
+ paddingHorizontal: 20,
781
+ paddingVertical: 6,
782
+ borderRadius: 16,
783
+ },
773
784
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "3.111.0",
3
+ "version": "3.114.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -37,10 +37,10 @@ try {
37
37
  let expoManipulatorUnavailable = false;
38
38
  const isExpoImageManipulatorAvailable = () =>
39
39
  !!ImageManipulator?.manipulateAsync && !expoManipulatorUnavailable;
40
- const isImageRotationSupported = () => isExpoImageManipulatorAvailable();
40
+ // 회전은 항상 지원됨 (회전 각도를 반환하고 tdb 앱에서 처리)
41
+ const isImageRotationSupported = () => true;
41
42
 
42
43
  const stripFileUri = (value: string) => value.replace(/^file:\/\//, '');
43
- const ensureFileUri = (value: string) => (value.startsWith('file://') ? value : `file://${value}`);
44
44
 
45
45
  const CROPPER_TIMEOUT_MS = 8000;
46
46
  const CROPPER_TIMEOUT_CODE = 'cropper_timeout';
@@ -122,6 +122,8 @@ export interface FullDocScannerResult {
122
122
  base64?: string;
123
123
  /** Original captured document info */
124
124
  original?: CapturedDocument;
125
+ /** Rotation angle applied by user (0, 90, 180, 270) */
126
+ rotationDegrees?: number;
125
127
  }
126
128
 
127
129
  export interface FullDocScannerStrings {
@@ -133,10 +135,13 @@ export interface FullDocScannerStrings {
133
135
  retake?: string;
134
136
  confirm?: string;
135
137
  cropTitle?: string;
138
+ first?: string;
139
+ second?: string;
140
+ secondBtn?: string;
136
141
  }
137
142
 
138
143
  export interface FullDocScannerProps {
139
- onResult: (result: FullDocScannerResult) => void;
144
+ onResult: (results: FullDocScannerResult[]) => void;
140
145
  onClose?: () => void;
141
146
  detectionConfig?: DetectionConfig;
142
147
  overlayColor?: string;
@@ -149,6 +154,7 @@ export interface FullDocScannerProps {
149
154
  enableGallery?: boolean;
150
155
  cropWidth?: number;
151
156
  cropHeight?: number;
157
+ type?: 'business';
152
158
  }
153
159
 
154
160
  export const FullDocScanner: React.FC<FullDocScannerProps> = ({
@@ -165,6 +171,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
165
171
  enableGallery = true,
166
172
  cropWidth = 1200,
167
173
  cropHeight = 1600,
174
+ type,
168
175
  }) => {
169
176
  const [processing, setProcessing] = useState(false);
170
177
  const [croppedImageData, setCroppedImageData] = useState<{path: string; base64?: string} | null>(null);
@@ -173,6 +180,8 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
173
180
  const [rectangleHint, setRectangleHint] = useState(false);
174
181
  const [flashEnabled, setFlashEnabled] = useState(false);
175
182
  const [rotationDegrees, setRotationDegrees] = useState(0);
183
+ const [capturedPhotos, setCapturedPhotos] = useState<FullDocScannerResult[]>([]);
184
+ const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
176
185
  const resolvedGridColor = gridColor ?? overlayColor;
177
186
  const docScannerRef = useRef<DocScannerHandle | null>(null);
178
187
  const captureModeRef = useRef<'grid' | 'no-grid' | null>(null);
@@ -180,6 +189,9 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
180
189
  const rectangleCaptureTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
181
190
  const rectangleHintTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
182
191
 
192
+ const isBusinessMode = type === 'business';
193
+ const maxPhotos = isBusinessMode ? 2 : 1;
194
+
183
195
  const mergedStrings = useMemo(
184
196
  () => ({
185
197
  captureHint: strings?.captureHint,
@@ -190,6 +202,9 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
190
202
  retake: strings?.retake ?? 'Retake',
191
203
  confirm: strings?.confirm ?? 'Confirm',
192
204
  cropTitle: strings?.cropTitle ?? 'Crop Document',
205
+ first: strings?.first ?? 'Front',
206
+ second: strings?.second ?? 'Back',
207
+ secondBtn: strings?.secondBtn ?? 'Capture Back Side?',
193
208
  }),
194
209
  [strings],
195
210
  );
@@ -500,118 +515,62 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
500
515
  return;
501
516
  }
502
517
 
503
- // 회전이 없으면 기존 데이터 그대로 전달
504
- if (rotationDegrees === 0) {
505
- console.log('[FullDocScanner] No rotation, returning original image');
506
- onResult({
507
- path: croppedImageData.path,
508
- base64: croppedImageData.base64,
509
- });
510
- return;
511
- }
512
-
513
- try {
514
- setProcessing(true);
518
+ // 회전 각도 정규화 (0, 90, 180, 270)
519
+ const rotationNormalized = ((rotationDegrees % 360) + 360) % 360;
520
+ console.log('[FullDocScanner] Confirm - rotation degrees:', rotationDegrees, 'normalized:', rotationNormalized);
515
521
 
516
- // 회전 각도 정규화 (0, 90, 180, 270)
517
- const rotationNormalized = ((rotationDegrees % 360) + 360) % 360;
518
- console.log('[FullDocScanner] Rotation degrees:', rotationDegrees, 'normalized:', rotationNormalized);
519
-
520
- if (rotationNormalized === 0) {
521
- console.log('[FullDocScanner] Normalized to 0, returning original image');
522
- onResult({
523
- path: croppedImageData.path,
524
- base64: croppedImageData.base64,
525
- });
526
- setProcessing(false);
527
- return;
528
- }
529
-
530
- // expo-image-manipulator 시도
531
- if (isExpoImageManipulatorAvailable() && ImageManipulator?.manipulateAsync) {
532
- try {
533
- console.log('[FullDocScanner] Using expo-image-manipulator for rotation');
534
- const inputUri = ensureFileUri(croppedImageData.path);
535
-
536
- const result = await ImageManipulator.manipulateAsync(
537
- inputUri,
538
- [{ rotate: rotationNormalized }],
539
- {
540
- compress: 0.9,
541
- format: ImageManipulator.SaveFormat.JPEG,
542
- base64: true,
543
- },
544
- );
545
-
546
- console.log('[FullDocScanner] Rotation complete via expo-image-manipulator:', {
547
- path: result.uri,
548
- hasBase64: !!result.base64,
549
- });
522
+ // 현재 사진을 capturedPhotos에 추가
523
+ const currentPhoto: FullDocScannerResult = {
524
+ path: croppedImageData.path,
525
+ base64: croppedImageData.base64,
526
+ rotationDegrees: rotationNormalized,
527
+ };
550
528
 
551
- onResult({
552
- path: stripFileUri(result.uri),
553
- base64: result.base64,
554
- });
555
- setProcessing(false);
556
- return;
557
- } catch (manipulatorError) {
558
- const code =
559
- manipulatorError && typeof manipulatorError === 'object' && 'code' in manipulatorError
560
- ? String((manipulatorError as any).code)
561
- : undefined;
562
-
563
- console.error('[FullDocScanner] expo-image-manipulator error:', manipulatorError);
564
-
565
- if (code === 'ERR_UNAVAILABLE') {
566
- expoManipulatorUnavailable = true;
567
- console.warn(
568
- '[FullDocScanner] expo-image-manipulator unavailable at runtime. Trying react-native-image-rotate.',
569
- );
570
- } else {
571
- throw manipulatorError;
572
- }
573
- }
574
- }
529
+ const updatedPhotos = [...capturedPhotos, currentPhoto];
530
+ console.log('[FullDocScanner] Photos captured:', updatedPhotos.length, 'of', maxPhotos);
575
531
 
576
- // expo-image-manipulator를 사용할 없는 경우 에러 처리
577
- console.error(
578
- '[FullDocScanner] Rotation requested but expo-image-manipulator is not available.',
579
- );
580
- setProcessing(false);
581
- Alert.alert(
582
- '회전 불가',
583
- 'expo-image-manipulator가 설치되지 않아 이미지 회전을 수행할 수 없습니다.\n\n패키지를 설치해주세요:\nnpm install expo-image-manipulator',
584
- [
585
- {
586
- text: '원본 사용',
587
- onPress: () => {
588
- onResult({
589
- path: croppedImageData.path,
590
- base64: croppedImageData.base64,
591
- });
592
- },
593
- },
594
- {
595
- text: '취소',
596
- style: 'cancel',
597
- },
598
- ],
599
- );
600
- } catch (error) {
601
- console.error('[FullDocScanner] Image rotation error on confirm:', error);
602
- setProcessing(false);
532
+ // Business 모드이고 아직 번째 사진만 찍은 경우
533
+ if (isBusinessMode && updatedPhotos.length === 1) {
534
+ // 번째 사진 촬영 여부를 물어봄 (UI에서 버튼으로 표시)
535
+ setCapturedPhotos(updatedPhotos);
536
+ setCurrentPhotoIndex(1);
537
+ // 확인 화면을 유지하고 "뒷면 촬영" 버튼을 표시
538
+ return;
539
+ }
603
540
 
604
- const errorMessage =
605
- error && typeof error === 'object' && 'message' in error ? (error as Error).message : String(error);
606
- Alert.alert('회전 실패', `이미지 회전 중 오류가 발생했습니다.\n원본 이미지를 사용합니다.\n\n오류: ${errorMessage}`);
541
+ // 모든 사진 촬영 완료 - 결과 반환
542
+ console.log('[FullDocScanner] All photos captured, returning results');
543
+ onResult(updatedPhotos);
544
+ }, [croppedImageData, rotationDegrees, capturedPhotos, isBusinessMode, maxPhotos, onResult]);
607
545
 
608
- // 에러 발생 원본 이미지 반환
609
- onResult({
610
- path: croppedImageData.path,
611
- base64: croppedImageData.base64,
612
- });
546
+ const handleCaptureSecondPhoto = useCallback(() => {
547
+ console.log('[FullDocScanner] Capturing second photo');
548
+ // 확인 화면을 닫고 카메라로 돌아감
549
+ setCroppedImageData(null);
550
+ setRotationDegrees(0);
551
+ setProcessing(false);
552
+ setRectangleDetected(false);
553
+ setRectangleHint(false);
554
+ captureModeRef.current = null;
555
+ captureInProgressRef.current = false;
556
+ if (rectangleCaptureTimeoutRef.current) {
557
+ clearTimeout(rectangleCaptureTimeoutRef.current);
558
+ rectangleCaptureTimeoutRef.current = null;
559
+ }
560
+ if (rectangleHintTimeoutRef.current) {
561
+ clearTimeout(rectangleHintTimeoutRef.current);
562
+ rectangleHintTimeoutRef.current = null;
613
563
  }
614
- }, [croppedImageData, rotationDegrees, onResult]);
564
+ if (docScannerRef.current?.reset) {
565
+ docScannerRef.current.reset();
566
+ }
567
+ }, []);
568
+
569
+ const handleSkipSecondPhoto = useCallback(() => {
570
+ console.log('[FullDocScanner] Skipping second photo');
571
+ // 첫 번째 사진만 반환
572
+ onResult(capturedPhotos);
573
+ }, [capturedPhotos, onResult]);
615
574
 
616
575
  const handleRetake = useCallback(() => {
617
576
  console.log('[FullDocScanner] Retake - clearing cropped image and resetting scanner');
@@ -698,6 +657,15 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
698
657
  {croppedImageData ? (
699
658
  // check_DP: Show confirmation screen
700
659
  <View style={styles.confirmationContainer}>
660
+ {/* 헤더 - 앞면/뒷면 표시 */}
661
+ {isBusinessMode && (
662
+ <View style={styles.photoHeader}>
663
+ <Text style={styles.photoHeaderText}>
664
+ {currentPhotoIndex === 0 ? mergedStrings.first : mergedStrings.second}
665
+ </Text>
666
+ </View>
667
+ )}
668
+
701
669
  {/* 회전 버튼들 - 가운데 정렬 */}
702
670
  {isImageRotationSupported() ? (
703
671
  <View style={styles.rotateButtonsCenter}>
@@ -738,14 +706,37 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
738
706
  >
739
707
  <Text style={styles.confirmButtonText}>{mergedStrings.retake}</Text>
740
708
  </TouchableOpacity>
741
- <TouchableOpacity
742
- style={[styles.confirmButton, styles.confirmButtonPrimary]}
743
- onPress={handleConfirm}
744
- accessibilityLabel={mergedStrings.confirm}
745
- accessibilityRole="button"
746
- >
747
- <Text style={styles.confirmButtonText}>{mergedStrings.confirm}</Text>
748
- </TouchableOpacity>
709
+
710
+ {/* Business 모드이고 첫 번째 사진을 찍은 후: 뒷면 촬영 버튼 또는 확인 버튼 */}
711
+ {isBusinessMode && capturedPhotos.length === 1 && currentPhotoIndex === 1 ? (
712
+ <>
713
+ <TouchableOpacity
714
+ style={[styles.confirmButton, styles.confirmButtonPrimary]}
715
+ onPress={handleCaptureSecondPhoto}
716
+ accessibilityLabel={mergedStrings.secondBtn}
717
+ accessibilityRole="button"
718
+ >
719
+ <Text style={styles.confirmButtonText}>{mergedStrings.secondBtn}</Text>
720
+ </TouchableOpacity>
721
+ <TouchableOpacity
722
+ style={[styles.confirmButton, styles.skipButton]}
723
+ onPress={handleSkipSecondPhoto}
724
+ accessibilityLabel={mergedStrings.confirm}
725
+ accessibilityRole="button"
726
+ >
727
+ <Text style={styles.confirmButtonText}>{mergedStrings.confirm}</Text>
728
+ </TouchableOpacity>
729
+ </>
730
+ ) : (
731
+ <TouchableOpacity
732
+ style={[styles.confirmButton, styles.confirmButtonPrimary]}
733
+ onPress={handleConfirm}
734
+ accessibilityLabel={mergedStrings.confirm}
735
+ accessibilityRole="button"
736
+ >
737
+ <Text style={styles.confirmButtonText}>{mergedStrings.confirm}</Text>
738
+ </TouchableOpacity>
739
+ )}
749
740
  </View>
750
741
  </View>
751
742
  ) : (
@@ -765,6 +756,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
765
756
  enableTorch={flashEnabled}
766
757
  >
767
758
  <View style={styles.overlayTop} pointerEvents="box-none">
759
+ {/* 좌측: 플래시 버튼 */}
768
760
  <TouchableOpacity
769
761
  style={[
770
762
  styles.iconButton,
@@ -780,6 +772,17 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
780
772
  <Text style={styles.iconText}>⚡️</Text>
781
773
  </View>
782
774
  </TouchableOpacity>
775
+
776
+ {/* 중앙: 앞면/뒷면 헤더 */}
777
+ {isBusinessMode && (
778
+ <View style={styles.cameraHeaderContainer}>
779
+ <Text style={styles.cameraHeaderText}>
780
+ {currentPhotoIndex === 0 ? mergedStrings.first : mergedStrings.second}
781
+ </Text>
782
+ </View>
783
+ )}
784
+
785
+ {/* 우측: 닫기 버튼 */}
783
786
  <TouchableOpacity
784
787
  style={styles.iconButton}
785
788
  onPress={handleClose}
@@ -1028,9 +1031,44 @@ const styles = StyleSheet.create({
1028
1031
  confirmButtonPrimary: {
1029
1032
  backgroundColor: '#3170f3',
1030
1033
  },
1034
+ skipButton: {
1035
+ backgroundColor: 'rgba(100,100,100,0.8)',
1036
+ },
1031
1037
  confirmButtonText: {
1032
1038
  color: '#fff',
1033
1039
  fontSize: 18,
1034
1040
  fontWeight: '600',
1035
1041
  },
1042
+ photoHeader: {
1043
+ position: 'absolute',
1044
+ top: 20,
1045
+ left: 0,
1046
+ right: 0,
1047
+ alignItems: 'center',
1048
+ zIndex: 5,
1049
+ },
1050
+ photoHeaderText: {
1051
+ color: '#fff',
1052
+ fontSize: 20,
1053
+ fontWeight: 'bold',
1054
+ backgroundColor: 'rgba(0,0,0,0.6)',
1055
+ paddingHorizontal: 24,
1056
+ paddingVertical: 8,
1057
+ borderRadius: 20,
1058
+ },
1059
+ cameraHeaderContainer: {
1060
+ position: 'absolute',
1061
+ left: 0,
1062
+ right: 0,
1063
+ alignItems: 'center',
1064
+ },
1065
+ cameraHeaderText: {
1066
+ color: '#fff',
1067
+ fontSize: 18,
1068
+ fontWeight: 'bold',
1069
+ backgroundColor: 'rgba(0,0,0,0.7)',
1070
+ paddingHorizontal: 20,
1071
+ paddingVertical: 6,
1072
+ borderRadius: 16,
1073
+ },
1036
1074
  });