react-native-expo-cropper 1.2.53 → 1.3.53

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,6 +1,6 @@
1
1
  import styles from './ImageCropperStyles';
2
2
  import React, { useState, useRef, useEffect } from 'react';
3
- import { Modal, View, Image, TouchableOpacity, Animated, Text, Platform, SafeAreaView, PixelRatio, StyleSheet, ActivityIndicator, useWindowDimensions } from 'react-native';
3
+ import { Modal, View, Image, TouchableOpacity, Text, Platform, PixelRatio, StyleSheet, ActivityIndicator, useWindowDimensions } from 'react-native';
4
4
  import Svg, { Path, Circle } from 'react-native-svg';
5
5
  import CustomCamera from './CustomCamera';
6
6
  import * as ImageManipulator from 'expo-image-manipulator';
@@ -8,7 +8,8 @@ import { Ionicons } from '@expo/vector-icons';
8
8
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
9
9
  import { applyMaskToImage, MaskView } from './ImageMaskProcessor';
10
10
 
11
- const PRIMARY_GREEN = '#198754';
11
+ const PRIMARY_GREEN = '#689F38';
12
+ const ACCENT_GREEN = '#689F38';
12
13
 
13
14
  // Base dimension for responsive scaling (e.g. ~375pt design width)
14
15
  const RESPONSIVE_BASE = 375;
@@ -20,8 +21,10 @@ const ImageCropper = ({
20
21
  addheight,
21
22
  rotationLabel,
22
23
  onCancel,
23
- confirmText = 'Confirm',
24
- resetText = 'Reset',
24
+ cancelText = 'Cancel',
25
+ confirmText = 'Use Scan',
26
+ resetText = 'Enhance',
27
+ rotateText = 'Rotate',
25
28
  cameraInstructionText = 'Place the tray inside the green box',
26
29
  }) => {
27
30
  const { width: windowWidth, height: windowHeight } = useWindowDimensions();
@@ -559,425 +562,96 @@ const ImageCropper = ({
559
562
  };
560
563
 
561
564
  const handleTap = (e) => {
562
- if (!image || showResult) return;
565
+ if (!image || showResult || points.length === 0) return;
563
566
  const now = Date.now();
564
567
  const { locationX: tapX, locationY: tapY } = e.nativeEvent;
565
-
566
- // RÉFÉRENTIEL UNIQUE : Utiliser imageDisplayRect (zone réelle de l'image dans le wrapper)
567
- // Les coordonnées du tap sont relatives au wrapper commun
568
- let imageRect = imageDisplayRect.current;
569
- const wrapper = commonWrapperLayout.current;
570
-
571
- // Recalculate if not available
572
- if (imageRect.width === 0 || imageRect.height === 0) {
573
- if (wrapper.width > 0 && wrapper.height > 0 && originalImageDimensions.current.width > 0) {
574
- updateImageDisplayRect(wrapper.width, wrapper.height);
575
- imageRect = imageDisplayRect.current;
576
- }
577
- // If still not available, use full wrapper as fallback (wrapper-relative coords)
578
- if (imageRect.width === 0 || imageRect.height === 0) {
579
- if (wrapper.width > 0 && wrapper.height > 0) {
580
- imageRect = {
581
- x: 0,
582
- y: 0,
583
- width: wrapper.width,
584
- height: wrapper.height,
585
- };
586
- } else {
587
- console.warn("⚠️ Cannot handle tap: wrapper or imageDisplayRect not available");
588
- return;
589
- }
590
- }
591
- }
592
-
593
- // ✅ Clamp to real displayed image content (imageDisplayRect dans le wrapper)
594
- // Les coordonnées tapX/tapY et imageRect sont RELATIVES au wrapper commun (origine 0,0)
595
- const { x: cx, y: cy, width: cw, height: ch } = {
596
- x: imageRect.x,
597
- y: imageRect.y,
598
- width: imageRect.width,
599
- height: imageRect.height,
600
- };
601
- // ✅ Responsive select radius: scales with screen so touch target matches handle size
602
- const selectRadius = Math.max(28, Math.round(50 * responsiveScale));
603
-
604
- // ✅ CRITICAL: Check for existing point selection FIRST (using raw tap coordinates)
605
- // Don't clamp tapX/Y for point selection - points can be anywhere in wrapper now
606
- const index = points.findIndex(p => Math.abs(p.x - tapX) < selectRadius && Math.abs(p.y - tapY) < selectRadius);
607
-
568
+ const selectRadius = Math.max(18, Math.round(26 * responsiveScale));
569
+ const index = points.findIndex((p) => Math.abs(p.x - tapX) < selectRadius && Math.abs(p.y - tapY) < selectRadius);
608
570
  if (index !== -1) {
609
- // ✅ Point found - select it for dragging
610
571
  selectedPointIndex.current = index;
611
-
612
- // Store initial positions
613
572
  initialTouchPosition.current = { x: tapX, y: tapY };
614
573
  lastTouchPosition.current = { x: tapX, y: tapY };
615
- initialPointPosition.current = { ...points[index] };
616
- lastValidPosition.current = { ...points[index] };
617
-
618
- // Calculate offset between point and touch at drag start
619
- touchOffset.current = {
620
- x: points[index].x - tapX,
621
- y: points[index].y - tapY
622
- };
623
-
624
- console.log("🎯 DRAG START - Offset calculated:", {
625
- pointX: points[index].x.toFixed(2),
626
- pointY: points[index].y.toFixed(2),
627
- touchX: tapX.toFixed(2),
628
- touchY: tapY.toFixed(2),
629
- offsetX: touchOffset.current.x.toFixed(2),
630
- offsetY: touchOffset.current.y.toFixed(2)
631
- });
632
-
633
- // Disable parent ScrollView scrolling when dragging
634
- try {
635
- const findScrollView = (node) => {
636
- if (!node) return null;
637
- if (node._component && node._component.setNativeProps) {
638
- node._component.setNativeProps({ scrollEnabled: false });
639
- }
640
- return findScrollView(node._owner || node._parent);
641
- };
642
- } catch (e) {
643
- // Ignore errors
644
- }
645
- } else {
646
- // ✅ No point found - check if double-tap on a line to create new point
647
- const isDoubleTap = lastTap.current && now - lastTap.current < 300;
648
-
649
- if (isDoubleTap && points.length >= 2) {
650
- // Find closest point on frame lines
651
- const lineResult = findClosestPointOnFrame(tapX, tapY, 30); // 30px tolerance
652
-
653
- if (lineResult) {
654
- const { point, insertIndex } = lineResult;
655
-
656
- // Check if a point already exists very close to this position
657
- const exists = points.some(p => Math.abs(p.x - point.x) < selectRadius && Math.abs(p.y - point.y) < selectRadius);
658
-
659
- if (!exists) {
660
- // Insert new point at the correct position in the polygon
661
- const newPoints = [...points];
662
- newPoints.splice(insertIndex, 0, point);
663
- setPoints(newPoints);
664
-
665
- console.log("✅ New point created on frame line:", {
666
- tap: { x: tapX.toFixed(2), y: tapY.toFixed(2) },
667
- newPoint: { x: point.x.toFixed(2), y: point.y.toFixed(2) },
668
- insertIndex,
669
- totalPoints: newPoints.length
670
- });
671
-
672
- lastTap.current = null; // Reset to prevent triple-tap
673
- return;
674
- }
574
+ lastTap.current = now;
575
+ return;
576
+ }
577
+
578
+ // Restore UX: double tap near an edge inserts a new draggable point.
579
+ const isDoubleTap = lastTap.current && now - lastTap.current < 300;
580
+ if (isDoubleTap && points.length >= 2) {
581
+ const lineResult = findClosestPointOnFrame(tapX, tapY, 30);
582
+ if (lineResult) {
583
+ const { point, insertIndex } = lineResult;
584
+ const exists = points.some((p) => Math.abs(p.x - point.x) < selectRadius && Math.abs(p.y - point.y) < selectRadius);
585
+ if (!exists) {
586
+ const newPoints = [...points];
587
+ newPoints.splice(insertIndex, 0, point);
588
+ setPoints(newPoints);
589
+ lastTap.current = null;
590
+ return;
675
591
  }
676
592
  }
677
593
  }
678
-
594
+
679
595
  lastTap.current = now;
680
596
  };
681
597
 
682
598
  const handleMove = (e) => {
683
599
  if (showResult || selectedPointIndex.current === null) return;
684
-
685
- // ✅ FREE DRAG: Use delta-based movement for smooth, unconstrained dragging
686
-
600
+
687
601
  const nativeEvent = e.nativeEvent;
688
602
  const currentX = nativeEvent.locationX;
689
603
  const currentY = nativeEvent.locationY;
690
-
691
- // ✅ Validate coordinates
692
- if (currentX === undefined || currentY === undefined || isNaN(currentX) || isNaN(currentY)) {
693
- console.warn("⚠️ Cannot get touch coordinates", {
694
- locationX: nativeEvent.locationX,
695
- locationY: nativeEvent.locationY
696
- });
697
- return;
698
- }
699
-
700
- // This is more reliable when ScrollView affects coordinate updates
701
- let deltaX, deltaY;
604
+ if (currentX === undefined || currentY === undefined || isNaN(currentX) || isNaN(currentY)) return;
605
+
606
+ let deltaX = 0;
607
+ let deltaY = 0;
702
608
  if (lastTouchPosition.current) {
703
- // Calculate incremental delta from last touch position
704
609
  deltaX = currentX - lastTouchPosition.current.x;
705
610
  deltaY = currentY - lastTouchPosition.current.y;
706
611
  } else if (initialTouchPosition.current) {
707
- // Fallback to absolute delta if lastTouchPosition not set
708
612
  deltaX = currentX - initialTouchPosition.current.x;
709
613
  deltaY = currentY - initialTouchPosition.current.y;
710
- } else {
711
- console.warn("⚠️ No touch position reference available");
712
- return;
713
614
  }
714
615
 
715
- // Les coordonnées de mouvement sont relatives au wrapper commun
716
616
  let imageRect = imageDisplayRect.current;
717
617
  const wrapper = commonWrapperLayout.current;
718
-
719
- // Recalculate if not available
720
618
  if (imageRect.width === 0 || imageRect.height === 0) {
721
619
  if (wrapper.width > 0 && wrapper.height > 0 && originalImageDimensions.current.width > 0) {
722
620
  updateImageDisplayRect(wrapper.width, wrapper.height);
723
621
  imageRect = imageDisplayRect.current;
724
622
  }
725
- // If still not available, use full wrapper as fallback (wrapper-relative coords)
726
623
  if (imageRect.width === 0 || imageRect.height === 0) {
727
- if (wrapper.width > 0 && wrapper.height > 0) {
728
- imageRect = {
729
- x: 0,
730
- y: 0,
731
- width: wrapper.width,
732
- height: wrapper.height,
733
- };
734
- } else {
735
- console.warn("⚠️ Cannot move point: wrapper or imageDisplayRect not available");
736
- return;
737
- }
624
+ imageRect = { x: 0, y: 0, width: wrapper.width || 0, height: wrapper.height || 0 };
738
625
  }
739
626
  }
740
-
741
- // ✅ Bounds in wrapper-relative coordinates (touch/points use wrapper as origin)
742
- // Full picture = entire wrapper; visible image = imageDisplayRect
743
- const contentRect = {
744
- x: imageRect.x,
745
- y: imageRect.y,
746
- width: imageRect.width,
747
- height: imageRect.height
748
- };
749
-
750
- // ✅ FREE DRAG: Ensure initial positions are set
751
- if (!initialPointPosition.current) {
752
- const currentPoint = points[selectedPointIndex.current];
753
- if (currentPoint && typeof currentPoint.x === 'number' && typeof currentPoint.y === 'number') {
754
- initialPointPosition.current = { ...currentPoint };
755
- } else {
756
- console.warn("⚠️ No point found for selected index or invalid point data");
757
- return;
758
- }
759
- }
760
-
761
- // ✅ NEW APPROACH: Use touchOffset to map touch position directly to point position
762
- // This eliminates delta accumulation and "dead zone" issues completely
763
- if (!touchOffset.current) {
764
- console.warn("⚠️ touchOffset not initialized, cannot move point");
765
- return;
766
- }
767
-
768
- // ✅ DIRECT MAPPING: newPosition = touchPosition + offset
769
- // No delta accumulation, no zone morte
770
- const newX = currentX + touchOffset.current.x;
771
- const newY = currentY + touchOffset.current.y;
772
-
773
- // ✅ STRICT BOUNDS: visible image area (wrapper-relative) for lastValidPosition
774
- const strictMinX = contentRect.x;
775
- const strictMaxX = contentRect.x + contentRect.width;
776
- // Tiny safety margin at bottom (2px) so point can almost reach bottom edge
777
- const bottomSafePadding = 2;
778
- const strictMinY = contentRect.y;
779
- const strictMaxY = contentRect.y + contentRect.height - bottomSafePadding;
780
-
781
- // ✅ DRAG BOUNDS: keep points strictly on the visible picture (no going outside image)
782
- const overshootMinX = strictMinX;
783
- const overshootMaxX = strictMaxX;
784
- const overshootMinY = strictMinY;
785
- const overshootMaxY = strictMaxY;
786
-
787
- // ✅ Clamp to visible image so points always stay on the picture
788
- const dragX = Math.max(overshootMinX, Math.min(newX, overshootMaxX));
789
- let dragY = Math.max(overshootMinY, Math.min(newY, overshootMaxY));
790
-
791
- // ✅ Bottom-band "stickiness": if the point is already in the last few pixels
792
- // near the bottom, ignore any move that would suddenly pull it back up.
793
- const bottomBandThreshold = strictMaxY - 3; // last ~3px of allowed area
794
- const prevPoint = points[selectedPointIndex.current];
795
- if (prevPoint && prevPoint.y >= bottomBandThreshold && dragY < prevPoint.y) {
796
- dragY = prevPoint.y;
797
- }
798
-
799
- // ✅ UPDATE POINT: Use (possibly adjusted) dragY
800
- const updatedPoint = { x: dragX, y: dragY };
801
-
802
- // ✅ CRITICAL: Detect if point is AT overshoot boundary (not just clamped)
803
- // Check if point is exactly at overshootMin/Max (within 1px tolerance)
804
- const isAtOvershootMinX = Math.abs(dragX - overshootMinX) < 1;
805
- const isAtOvershootMaxX = Math.abs(dragX - overshootMaxX) < 1;
806
- const isAtOvershootMinY = Math.abs(dragY - overshootMinY) < 1;
807
- const isAtOvershootMaxY = Math.abs(dragY - overshootMaxY) < 1;
808
-
809
- const isAtBoundaryX = isAtOvershootMinX || isAtOvershootMaxX;
810
- const isAtBoundaryY = isAtOvershootMinY || isAtOvershootMaxY;
811
-
812
- // Only recalculate offset when FIRST hitting boundary (transition free → boundary)
813
- const justHitBoundaryX = isAtBoundaryX && !wasClampedLastFrame.current.x;
814
- const justHitBoundaryY = isAtBoundaryY && !wasClampedLastFrame.current.y;
815
-
816
- if (justHitBoundaryX || justHitBoundaryY) {
817
- // Point JUST hit overshoot boundary - recalculate offset once
818
- const newOffsetX = justHitBoundaryX ? (dragX - currentX) : touchOffset.current.x;
819
- const newOffsetY = justHitBoundaryY ? (dragY - currentY) : touchOffset.current.y;
820
-
821
- touchOffset.current = {
822
- x: newOffsetX,
823
- y: newOffsetY
824
- };
825
-
826
- console.log("✅ OFFSET RECALCULATED (hit boundary):", {
827
- axis: justHitBoundaryX ? 'X' : 'Y',
828
- touchY: currentY.toFixed(2),
829
- dragY: dragY.toFixed(2),
830
- newOffsetY: touchOffset.current.y.toFixed(2),
831
- note: "First contact with boundary - offset locked"
832
- });
833
- }
834
-
835
- // Update boundary state for next frame
836
- wasClampedLastFrame.current = { x: isAtBoundaryX, y: isAtBoundaryY };
837
-
838
- // ✅ DEBUG: Log when in overshoot zone (only when not at boundary)
839
- const isInOvershootY = dragY < strictMinY || dragY > strictMaxY;
840
- if (isInOvershootY && !isAtBoundaryY) {
841
- console.log("🎯 IN OVERSHOOT ZONE:", {
842
- touchY: currentY.toFixed(2),
843
- appliedY: dragY.toFixed(2),
844
- overshootRange: `${overshootMinY.toFixed(2)} - ${overshootMaxY.toFixed(2)}`,
845
- strictRange: `${strictMinY.toFixed(2)} - ${strictMaxY.toFixed(2)}`
846
- });
847
- }
848
-
849
- // ✅ Update lastValidPosition ONLY if point is within strictBounds
850
- const isStrictlyValid =
851
- dragX >= strictMinX && dragX <= strictMaxX &&
852
- dragY >= strictMinY && dragY <= strictMaxY;
853
-
854
- if (isStrictlyValid) {
855
- lastValidPosition.current = updatedPoint;
856
- }
857
-
858
- // ✅ Update lastTouchPosition for next frame (simple tracking)
627
+
859
628
  lastTouchPosition.current = { x: currentX, y: currentY };
860
-
861
- // DEBUG: Log the point update before setPoints
862
- console.log("📍 UPDATING POINT:", {
863
- index: selectedPointIndex.current,
864
- newX: updatedPoint.x.toFixed(2),
865
- newY: updatedPoint.y.toFixed(2),
866
- touchX: currentX.toFixed(2),
867
- touchY: currentY.toFixed(2),
868
- offsetX: touchOffset.current.x.toFixed(2),
869
- offsetY: touchOffset.current.y.toFixed(2)
870
- });
871
-
872
- setPoints(prev => {
873
- // ✅ SAFETY: Ensure prev is a valid array
874
- if (!Array.isArray(prev) || prev.length === 0) {
875
- return prev;
876
- }
877
-
629
+
630
+ setPoints((prev) => {
631
+ if (!Array.isArray(prev) || prev.length === 0) return prev;
878
632
  const pointIndex = selectedPointIndex.current;
879
- // SAFETY: Validate pointIndex
880
- if (pointIndex === null || pointIndex === undefined || pointIndex < 0 || pointIndex >= prev.length) {
881
- return prev;
882
- }
883
-
884
- // ✅ SAFETY: Filter out any invalid points and update the selected one
885
- const newPoints = prev.map((p, i) => {
886
- if (i === pointIndex) {
887
- return updatedPoint;
888
- }
889
- // ✅ SAFETY: Ensure existing points are valid
890
- if (p && typeof p.x === 'number' && typeof p.y === 'number') {
891
- return p;
892
- }
893
- // If point is invalid, return a default point (shouldn't happen, but safety first)
894
- return { x: 0, y: 0 };
895
- });
896
-
897
- // ✅ DEBUG: Log the state update
898
- console.log("✅ STATE UPDATED:", {
899
- index: pointIndex,
900
- oldY: prev[pointIndex]?.y.toFixed(2),
901
- newY: newPoints[pointIndex]?.y.toFixed(2),
902
- changed: Math.abs(prev[pointIndex]?.y - newPoints[pointIndex]?.y) > 0.01
633
+ if (pointIndex === null || pointIndex < 0 || pointIndex >= prev.length) return prev;
634
+
635
+ return prev.map((p, i) => {
636
+ if (i !== pointIndex) return p;
637
+ return {
638
+ x: Math.max(imageRect.x, Math.min(p.x + deltaX, imageRect.x + imageRect.width)),
639
+ y: Math.max(imageRect.y, Math.min(p.y + deltaY, imageRect.y + imageRect.height)),
640
+ };
903
641
  });
904
-
905
- return newPoints;
906
642
  });
907
643
  };
908
644
 
909
645
  const handleRelease = () => {
910
646
  const wasDragging = selectedPointIndex.current !== null;
911
-
912
- // ✅ CRITICAL: Reset drag state when drag ends
913
- touchOffset.current = null;
914
- wasClampedLastFrame.current = { x: false, y: false };
915
-
916
- // ✅ VISUAL OVERSHOOT: Clamp points back to imageDisplayRect when drag ends
917
- // This ensures final crop is always within valid image bounds
918
- if (wasDragging && selectedPointIndex.current !== null) {
919
- const wrapper = commonWrapperLayout.current;
920
- let imageRect = imageDisplayRect.current;
921
-
922
- // Recalculate imageDisplayRect if needed
923
- if (imageRect.width === 0 || imageRect.height === 0) {
924
- if (wrapper.width > 0 && wrapper.height > 0 && originalImageDimensions.current.width > 0) {
925
- updateImageDisplayRect(wrapper.width, wrapper.height);
926
- imageRect = imageDisplayRect.current;
927
- }
928
- }
929
-
930
- if (imageRect.width > 0 && imageRect.height > 0) {
931
- // Bounds in wrapper-relative coords (same as point coords)
932
- const minX = imageRect.x;
933
- const minY = imageRect.y;
934
- const maxX = imageRect.x + imageRect.width;
935
- const maxY = imageRect.y + imageRect.height;
936
-
937
- // Clamp the dragged point back to visible image area (full picture in wrapper-relative coords)
938
- setPoints(prev => {
939
- // ✅ SAFETY: Ensure prev is a valid array
940
- if (!Array.isArray(prev) || prev.length === 0) {
941
- return prev;
942
- }
943
-
944
- const pointIndex = selectedPointIndex.current;
945
- // ✅ SAFETY: Validate pointIndex and ensure point exists
946
- if (pointIndex === null || pointIndex === undefined || pointIndex < 0 || pointIndex >= prev.length) {
947
- return prev;
948
- }
949
-
950
- const point = prev[pointIndex];
951
- // ✅ SAFETY: Ensure point exists and has valid x/y properties
952
- if (!point || typeof point.x !== 'number' || typeof point.y !== 'number') {
953
- return prev;
954
- }
955
-
956
- const clampedPoint = {
957
- x: Math.max(minX, Math.min(point.x, maxX)),
958
- y: Math.max(minY, Math.min(point.y, maxY))
959
- };
960
-
961
- // Only update if point was outside bounds
962
- if (point.x !== clampedPoint.x || point.y !== clampedPoint.y) {
963
- console.log("🔒 Clamping point back to image bounds on release:", {
964
- before: { x: point.x.toFixed(2), y: point.y.toFixed(2) },
965
- after: { x: clampedPoint.x.toFixed(2), y: clampedPoint.y.toFixed(2) },
966
- bounds: { minX: minX.toFixed(2), maxX: maxX.toFixed(2), minY: minY.toFixed(2), maxY: maxY.toFixed(2) }
967
- });
968
-
969
- return prev.map((p, i) => i === pointIndex ? clampedPoint : p);
970
- }
971
-
972
- return prev;
973
- });
974
- }
975
- }
976
-
647
+
977
648
  // ✅ FREE DRAG: Clear initial positions when drag ends
978
649
  initialTouchPosition.current = null;
979
650
  initialPointPosition.current = null;
651
+ lastTouchPosition.current = null;
980
652
  lastValidPosition.current = null;
653
+ touchOffset.current = null;
654
+ wasClampedLastFrame.current = { x: false, y: false };
981
655
  selectedPointIndex.current = null;
982
656
 
983
657
  // ✅ CRITICAL: Re-enable parent ScrollView scrolling when drag ends
@@ -1058,6 +732,18 @@ const rotatePreviewImage = async (degrees) => {
1058
732
  });
1059
733
  };
1060
734
 
735
+ const getFrameBounds = () => {
736
+ if (!points || points.length < 4) return null;
737
+ const xs = points.map((p) => p.x);
738
+ const ys = points.map((p) => p.y);
739
+ return {
740
+ minX: Math.min(...xs),
741
+ maxX: Math.max(...xs),
742
+ minY: Math.min(...ys),
743
+ maxY: Math.max(...ys),
744
+ };
745
+ };
746
+
1061
747
 
1062
748
  return (
1063
749
  <View style={styles.container}>
@@ -1181,7 +867,7 @@ const rotatePreviewImage = async (degrees) => {
1181
867
  const sizeScale = Math.max(0.6, Math.min(1.4, sizeScaleRaw)); // clamp
1182
868
 
1183
869
  const stroke = Math.max(0.75, 2 * responsiveScale * sizeScale);
1184
- const handleRadius = Math.max(4, 10 * responsiveScale * sizeScale);
870
+ const handleRadius = Math.max(5, 9 * responsiveScale * sizeScale);
1185
871
 
1186
872
  return (
1187
873
  <>
@@ -1191,15 +877,31 @@ const rotatePreviewImage = async (degrees) => {
1191
877
  fillRule="evenodd"
1192
878
  />
1193
879
  {!showResult && points.length > 0 && (
1194
- <Path d={createPath()} fill="transparent" stroke="white" strokeWidth={stroke} />
880
+ <Path d={createPath()} fill="transparent" stroke="rgba(255,255,255,0.95)" strokeWidth={stroke} />
1195
881
  )}
1196
882
  {!showResult && points.map((point, index) => (
1197
- <Circle key={index} cx={point.x} cy={point.y} r={handleRadius} fill="white" />
883
+ <Circle key={`dot-${index}`} cx={point.x} cy={point.y} r={handleRadius} fill="#E7FFD9" stroke={ACCENT_GREEN} strokeWidth={1.5} />
1198
884
  ))}
1199
885
  </>
1200
886
  );
1201
887
  })()}
1202
888
  </Svg>
889
+ {!showResult && image && (
890
+ <>
891
+ {/* <View style={[styles.topBar, { paddingTop: Math.max(insets.top + 8, 18) }]}>
892
+ <TouchableOpacity style={styles.topIconButton} activeOpacity={0.8}>
893
+ <Ionicons name="flash" size={18} color={ACCENT_GREEN} />
894
+ </TouchableOpacity>
895
+ <Text style={[styles.topBarTitle, { fontSize: Math.max(22, Math.round(30 * responsiveScale)) }]}>Precision Scan</Text>
896
+ <TouchableOpacity style={styles.topIconButton} onPress={handleReset} activeOpacity={0.8}>
897
+ <Ionicons name="settings-outline" size={20} color="white" />
898
+ </TouchableOpacity>
899
+ </View> */}
900
+ {/* <View style={styles.detectBadge}>
901
+ <Text style={styles.detectBadgeText}>A4 Document Detected</Text>
902
+ </View> */}
903
+ </>
904
+ )}
1203
905
  </View>
1204
906
  {isRotating && (
1205
907
  <View style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', alignItems: 'center' }}>
@@ -1217,90 +919,91 @@ const rotatePreviewImage = async (degrees) => {
1217
919
  style={[
1218
920
  styles.buttonContainerBelow,
1219
921
  {
1220
- paddingBottom: Math.max(insets.bottom, Math.round(16 * responsiveScale)),
1221
- paddingTop: Math.round(12 * responsiveScale),
922
+ // Keep controls clearly above home indicator / bottom gesture bar.
923
+ paddingBottom: Math.max(insets.bottom + Math.round(12 * responsiveScale), Math.round(20 * responsiveScale)),
924
+ paddingTop: Math.round(10 * responsiveScale),
1222
925
  paddingHorizontal: Math.round(12 * responsiveScale),
1223
- gap: Math.round(8 * responsiveScale),
1224
926
  },
1225
927
  ]}
1226
928
  >
1227
- {/* Close button: always visible, closes cropper */}
1228
- <TouchableOpacity
1229
- style={[
1230
- styles.rotationButton,
1231
- {
1232
- backgroundColor: 'transparent',
1233
- borderWidth: 1,
1234
- borderColor: 'white',
1235
- width: Math.round(40 * responsiveScale),
1236
- height: Math.round(40 * responsiveScale),
1237
- borderRadius: Math.round(20 * responsiveScale),
1238
- marginRight: Math.round(6 * responsiveScale),
1239
- },
1240
- ]}
1241
- onPress={() => {
1242
- if (onCancel) {
1243
- onCancel();
1244
- }
1245
- }}
1246
- >
1247
- <Ionicons name="close" size={Math.round(20 * responsiveScale)} color="white" />
1248
- </TouchableOpacity>
1249
-
1250
- {Platform.OS === 'android' && (
1251
- <TouchableOpacity
929
+ <View style={styles.controlsRow}>
930
+ <TouchableOpacity
931
+ style={[
932
+ styles.roundControl,
933
+ {
934
+ width: 'auto',
935
+ minWidth: Math.max(
936
+ Math.round(84 * responsiveScale),
937
+ Math.round(((cancelText ? String(cancelText).length : 6) * 7 + 32) * responsiveScale)
938
+ ),
939
+ paddingHorizontal: Math.round(12 * responsiveScale),
940
+ },
941
+ ]}
942
+ onPress={() => {
943
+ if (onCancel) {
944
+ onCancel();
945
+ }
946
+ }}
947
+ activeOpacity={0.85}
948
+ >
949
+ <Text
950
+ style={[
951
+ styles.buttonText,
952
+ {
953
+ fontSize: Math.max(10, Math.round(11 * responsiveScale)),
954
+ letterSpacing: 0.5,
955
+ },
956
+ ]}
957
+ >
958
+ {cancelText}
959
+ </Text>
960
+ </TouchableOpacity>
961
+ <TouchableOpacity
962
+ style={styles.enhanceButton}
963
+ onPress={handleReset}
964
+ activeOpacity={0.85}
965
+ >
966
+ <Ionicons name="sparkles-outline" size={Math.round(18 * responsiveScale)} color="rgba(255,255,255,0.95)" />
967
+ <Text style={styles.buttonText} numberOfLines={1} ellipsizeMode="tail">
968
+ {resetText || 'Enhance'}
969
+ </Text>
970
+ </TouchableOpacity>
971
+ <TouchableOpacity
1252
972
  style={[
1253
973
  styles.rotationButton,
1254
974
  {
1255
- width: Math.round(56 * responsiveScale),
1256
- height: Math.round(48 * responsiveScale),
1257
- borderRadius: Math.round(28 * responsiveScale),
975
+ minWidth: Math.max(
976
+ Math.round(88 * responsiveScale),
977
+ Math.round(((rotateText ? String(rotateText).length : 6) * 7 + 38) * responsiveScale)
978
+ ),
1258
979
  },
1259
980
  isRotating && { opacity: 0.7 },
1260
- ]}
981
+ ]}
1261
982
  onPress={() => enableRotation && rotatePreviewImage(90)}
1262
983
  disabled={isRotating}
984
+ activeOpacity={0.85}
1263
985
  >
1264
986
  {isRotating ? (
1265
987
  <ActivityIndicator size="small" color="white" />
1266
988
  ) : (
1267
- <Ionicons name="sync" size={Math.round(24 * responsiveScale)} color="white" />
989
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
990
+ <Ionicons name="refresh" size={Math.round(15 * responsiveScale)} color="white" />
991
+ <Text
992
+ style={[
993
+ styles.buttonText,
994
+ {
995
+ fontSize: Math.max(10, Math.round(11 * responsiveScale)),
996
+ letterSpacing: 0.5,
997
+ },
998
+ ]}
999
+ >
1000
+ {rotateText}
1001
+ </Text>
1002
+ </View>
1268
1003
  )}
1269
1004
  </TouchableOpacity>
1270
- )}
1271
-
1272
- <TouchableOpacity
1273
- style={[
1274
- styles.button,
1275
- {
1276
- minHeight: Math.round(48 * responsiveScale),
1277
- paddingVertical: Math.round(12 * responsiveScale),
1278
- paddingHorizontal: Math.round(10 * responsiveScale),
1279
- },
1280
- ]}
1281
- onPress={handleReset}
1282
- >
1283
- <Text
1284
- style={[
1285
- styles.buttonText,
1286
- {
1287
- // Smaller font for longer labels so they stay on one line
1288
- fontSize: Math.max(
1289
- 12,
1290
- Math.round(
1291
- (resetText && resetText.length > 10 ? 14 : 18) *
1292
- responsiveScale
1293
- )
1294
- ),
1295
- },
1296
- ]}
1297
- numberOfLines={1}
1298
- ellipsizeMode="tail"
1299
- >
1300
- {resetText}
1301
- </Text>
1302
- </TouchableOpacity>
1303
-
1005
+ </View>
1006
+
1304
1007
  <TouchableOpacity
1305
1008
  onPress={async () => {
1306
1009
  setIsLoading(true);
@@ -1435,30 +1138,26 @@ const rotatePreviewImage = async (degrees) => {
1435
1138
  style={[
1436
1139
  styles.button,
1437
1140
  {
1438
- minHeight: Math.round(48 * responsiveScale),
1439
- paddingVertical: Math.round(12 * responsiveScale),
1440
- paddingHorizontal: Math.round(10 * responsiveScale),
1141
+ marginTop: Math.round(10 * responsiveScale),
1142
+ // Keep confirm action above browser/system navigation bars.
1143
+ marginBottom: Math.max(insets.bottom + Math.round(10 * responsiveScale), Math.round(22 * responsiveScale)),
1144
+ borderWidth: 1.8,
1145
+ borderColor: ACCENT_GREEN,
1441
1146
  },
1442
1147
  ]}
1148
+ hitSlop={{ top: 8, left: 8, right: 8, bottom: 12 }}
1443
1149
  >
1444
1150
  <Text
1445
1151
  style={[
1446
1152
  styles.buttonText,
1447
1153
  {
1448
- // Smaller font for longer labels so they stay on one line
1449
- fontSize: Math.max(
1450
- 12,
1451
- Math.round(
1452
- (confirmText && confirmText.length > 10 ? 14 : 18) *
1453
- responsiveScale
1454
- )
1455
- ),
1154
+ fontSize: Math.max(13, Math.round(14 * responsiveScale)),
1456
1155
  },
1457
1156
  ]}
1458
1157
  numberOfLines={1}
1459
1158
  ellipsizeMode="tail"
1460
1159
  >
1461
- {confirmText}
1160
+ {confirmText || 'Use Scan'}
1462
1161
  </Text>
1463
1162
  </TouchableOpacity>
1464
1163
  </View>