react-native-expo-cropper 1.2.47 → 1.2.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.MD CHANGED
@@ -32,6 +32,25 @@ EXPO SDK 54 -------------------------------------------------------------
32
32
  },
33
33
 
34
34
 
35
+ ## ✅ IMPORTANT (APK / production build)
36
+
37
+ Expo Go already contains native camera modules/permissions. In a **standalone APK** you must ensure the native config is present.
38
+
39
+ Add this to your `app.json` / `app.config.js` (example):
40
+
41
+ ```json
42
+ {
43
+ "expo": {
44
+ "plugins": ["expo-camera", "expo-image-picker", "expo-media-library"],
45
+ "android": {
46
+ "permissions": ["CAMERA"]
47
+ }
48
+ }
49
+ }
50
+ ```
51
+
52
+ Then rebuild the APK (EAS / prebuild). If the camera crashes only in release, it’s almost always missing permissions/plugin config or a device-specific camera processing issue.
53
+
35
54
  ------------------------------ DO THIS IN YOUR APP SCREEN CODE !!!!!!!!!! :-------------------------------
36
55
 
37
56
  import ImageCropper from 'react-native-expo-cropper/src/ImageCropper';
@@ -9,7 +9,7 @@ var _react = _interopRequireWildcard(require("react"));
9
9
  var _reactNative = require("react-native");
10
10
  var _reactNativeSafeAreaContext = require("react-native-safe-area-context");
11
11
  var _expoCamera = require("expo-camera");
12
- function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function _interopRequireWildcard(e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, "default": e }; if (null === e || "object" != _typeof(e) && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (var _t2 in e) "default" !== _t2 && {}.hasOwnProperty.call(e, _t2) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, _t2)) && (i.get || i.set) ? o(f, _t2, i) : f[_t2] = e[_t2]); return f; })(e, t); }
12
+ function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function _interopRequireWildcard(e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, "default": e }; if (null === e || "object" != _typeof(e) && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (var _t3 in e) "default" !== _t3 && {}.hasOwnProperty.call(e, _t3) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, _t3)) && (i.get || i.set) ? o(f, _t3, i) : f[_t3] = e[_t3]); return f; })(e, t); }
13
13
  function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
14
14
  function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
15
15
  function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; }
@@ -45,28 +45,22 @@ function CustomCamera(_ref) {
45
45
  _useState6 = _slicedToArray(_useState5, 2),
46
46
  hasPermission = _useState6[0],
47
47
  setHasPermission = _useState6[1];
48
- // Force a fresh native CameraView instance per "open" to avoid stale/cached preview.
49
- var _useState7 = (0, _react.useState)(function () {
50
- return "".concat(Date.now(), "-").concat(Math.random().toString(16).slice(2));
51
- }),
52
- _useState8 = _slicedToArray(_useState7, 1),
53
- cameraViewMountKey = _useState8[0];
54
48
  var cameraRef = (0, _react.useRef)(null);
55
49
  var cameraWrapperRef = (0, _react.useRef)(null);
56
50
  var insets = (0, _reactNativeSafeAreaContext.useSafeAreaInsets)();
57
- var _useState9 = (0, _react.useState)({
51
+ var _useState7 = (0, _react.useState)({
58
52
  width: 0,
59
53
  height: 0,
60
54
  x: 0,
61
55
  y: 0
62
56
  }),
57
+ _useState8 = _slicedToArray(_useState7, 2),
58
+ cameraWrapperLayout = _useState8[0],
59
+ setCameraWrapperLayout = _useState8[1];
60
+ var _useState9 = (0, _react.useState)(null),
63
61
  _useState0 = _slicedToArray(_useState9, 2),
64
- cameraWrapperLayout = _useState0[0],
65
- setCameraWrapperLayout = _useState0[1];
66
- var _useState1 = (0, _react.useState)(null),
67
- _useState10 = _slicedToArray(_useState1, 2),
68
- greenFrame = _useState10[0],
69
- setGreenFrame = _useState10[1];
62
+ greenFrame = _useState0[0],
63
+ setGreenFrame = _useState0[1];
70
64
  (0, _react.useEffect)(function () {
71
65
  _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee() {
72
66
  var _yield$Camera$request, status;
@@ -85,6 +79,34 @@ function CustomCamera(_ref) {
85
79
  }, _callee);
86
80
  }))();
87
81
  }, []);
82
+ var requestPermissionAgain = /*#__PURE__*/function () {
83
+ var _ref3 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee2() {
84
+ var _yield$Camera$request2, status, _t;
85
+ return _regenerator().w(function (_context2) {
86
+ while (1) switch (_context2.p = _context2.n) {
87
+ case 0:
88
+ _context2.p = 0;
89
+ _context2.n = 1;
90
+ return _expoCamera.Camera.requestCameraPermissionsAsync();
91
+ case 1:
92
+ _yield$Camera$request2 = _context2.v;
93
+ status = _yield$Camera$request2.status;
94
+ setHasPermission(status === 'granted');
95
+ _context2.n = 3;
96
+ break;
97
+ case 2:
98
+ _context2.p = 2;
99
+ _t = _context2.v;
100
+ setHasPermission(false);
101
+ case 3:
102
+ return _context2.a(2);
103
+ }
104
+ }, _callee2, null, [[0, 2]]);
105
+ }));
106
+ return function requestPermissionAgain() {
107
+ return _ref3.apply(this, arguments);
108
+ };
109
+ }();
88
110
 
89
111
  // Helper function to wait for multiple render cycles (works on iOS)
90
112
  var waitForRender = function waitForRender() {
@@ -150,25 +172,32 @@ function CustomCamera(_ref) {
150
172
  }
151
173
  }, [cameraWrapperLayout]);
152
174
  var takePicture = /*#__PURE__*/function () {
153
- var _ref3 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee2() {
154
- var captureOptions, photo, capturedAspectRatio, greenFrameCoords, _t;
155
- return _regenerator().w(function (_context2) {
156
- while (1) switch (_context2.p = _context2.n) {
175
+ var _ref4 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee3() {
176
+ var captureOptions, photo, capturedAspectRatio, greenFrameCoords, _t2;
177
+ return _regenerator().w(function (_context3) {
178
+ while (1) switch (_context3.p = _context3.n) {
157
179
  case 0:
180
+ if (hasPermission) {
181
+ _context3.n = 1;
182
+ break;
183
+ }
184
+ _reactNative.Alert.alert("Permission requise", "Veuillez autoriser l'accès à la caméra pour prendre une photo.");
185
+ return _context3.a(2);
186
+ case 1:
158
187
  if (!cameraRef.current) {
159
- _context2.n = 7;
188
+ _context3.n = 8;
160
189
  break;
161
190
  }
162
- _context2.p = 1;
191
+ _context3.p = 2;
163
192
  // Show loading after a delay (using setImmediate for iOS compatibility)
164
193
  waitForRender(5).then(function () {
165
194
  setLoadingBeforeCapture(true);
166
195
  });
167
196
 
168
197
  // Wait a bit before taking picture (works on iOS)
169
- _context2.n = 2;
198
+ _context3.n = 3;
170
199
  return waitForRender(2);
171
- case 2:
200
+ case 3:
172
201
  // ✅ OPTIMIZED: Capture with maximum quality and native camera ratio
173
202
  // Platform-specific optimizations for best quality
174
203
  captureOptions = _objectSpread(_objectSpread({
@@ -178,7 +207,8 @@ function CustomCamera(_ref) {
178
207
  shutterSound: false,
179
208
  // ✅ CRITICAL: skipProcessing preserves original resolution and avoids interpolation
180
209
  // This ensures pixel-perfect quality and prevents premature resizing
181
- skipProcessing: true,
210
+ // NOTE: Some Android devices are unstable with skipProcessing; keep it off there.
211
+ skipProcessing: _reactNative.Platform.OS === 'ios',
182
212
  // Include EXIF metadata (orientation, camera settings, etc.)
183
213
  exif: true
184
214
  }, _reactNative.Platform.OS === 'ios' && {
@@ -196,16 +226,16 @@ function CustomCamera(_ref) {
196
226
  height: cameraWrapperLayout.height
197
227
  }
198
228
  });
199
- _context2.n = 3;
229
+ _context3.n = 4;
200
230
  return cameraRef.current.takePictureAsync(captureOptions);
201
- case 3:
202
- photo = _context2.v;
231
+ case 4:
232
+ photo = _context3.v;
203
233
  if (!(!photo.width || !photo.height || photo.width === 0 || photo.height === 0)) {
204
- _context2.n = 4;
234
+ _context3.n = 5;
205
235
  break;
206
236
  }
207
237
  throw new Error("Invalid photo dimensions received from camera");
208
- case 4:
238
+ case 5:
209
239
  capturedAspectRatio = photo.width / photo.height;
210
240
  console.log("✅ Photo captured with maximum quality:", {
211
241
  uri: photo.uri,
@@ -221,11 +251,11 @@ function CustomCamera(_ref) {
221
251
  // Fallback to recalculation if, for some reason, state is not yet set
222
252
  greenFrameCoords = greenFrame || calculateGreenFrameCoordinates();
223
253
  if (greenFrameCoords) {
224
- _context2.n = 5;
254
+ _context3.n = 6;
225
255
  break;
226
256
  }
227
257
  throw new Error("Green frame coordinates not available");
228
- case 5:
258
+ case 6:
229
259
  // ✅ Send photo URI and frame data to ImageCropper
230
260
  // The photo maintains its native resolution and aspect ratio
231
261
  // ImageCropper will handle display and cropping while preserving quality
@@ -238,26 +268,42 @@ function CustomCamera(_ref) {
238
268
  }
239
269
  });
240
270
  setLoadingBeforeCapture(false);
241
- _context2.n = 7;
271
+ _context3.n = 8;
242
272
  break;
243
- case 6:
244
- _context2.p = 6;
245
- _t = _context2.v;
246
- console.error("❌ Error capturing photo:", _t);
247
- setLoadingBeforeCapture(false);
248
- _reactNative.Alert.alert("Erreur", "Impossible de capturer la photo: ".concat(_t.message || "Erreur inconnue", ". Veuillez r\xE9essayer."));
249
273
  case 7:
250
- return _context2.a(2);
274
+ _context3.p = 7;
275
+ _t2 = _context3.v;
276
+ console.error("❌ Error capturing photo:", _t2);
277
+ setLoadingBeforeCapture(false);
278
+ _reactNative.Alert.alert("Erreur", "Impossible de capturer la photo: ".concat(_t2.message || "Erreur inconnue", ". Veuillez r\xE9essayer."));
279
+ case 8:
280
+ return _context3.a(2);
251
281
  }
252
- }, _callee2, null, [[1, 6]]);
282
+ }, _callee3, null, [[2, 7]]);
253
283
  }));
254
284
  return function takePicture() {
255
- return _ref3.apply(this, arguments);
285
+ return _ref4.apply(this, arguments);
256
286
  };
257
287
  }();
258
288
  return /*#__PURE__*/_react["default"].createElement(_reactNative.SafeAreaView, {
259
289
  style: styles.outerContainer
260
- }, /*#__PURE__*/_react["default"].createElement(_reactNative.View, {
290
+ }, hasPermission === null && /*#__PURE__*/_react["default"].createElement(_reactNative.View, {
291
+ style: styles.permissionContainer
292
+ }, /*#__PURE__*/_react["default"].createElement(_reactNative.ActivityIndicator, {
293
+ size: "large",
294
+ color: PRIMARY_GREEN
295
+ }), /*#__PURE__*/_react["default"].createElement(_reactNative.Text, {
296
+ style: styles.permissionText
297
+ }, "Demande d'autorisation cam\xE9ra...")), hasPermission === false && /*#__PURE__*/_react["default"].createElement(_reactNative.View, {
298
+ style: styles.permissionContainer
299
+ }, /*#__PURE__*/_react["default"].createElement(_reactNative.Text, {
300
+ style: styles.permissionText
301
+ }, "Autorisation cam\xE9ra refus\xE9e. Activez-la dans les param\xE8tres pour continuer."), /*#__PURE__*/_react["default"].createElement(_reactNative.TouchableOpacity, {
302
+ style: styles.permissionButton,
303
+ onPress: requestPermissionAgain
304
+ }, /*#__PURE__*/_react["default"].createElement(_reactNative.Text, {
305
+ style: styles.permissionButtonText
306
+ }, "R\xE9essayer"))), hasPermission === true && /*#__PURE__*/_react["default"].createElement(_react["default"].Fragment, null, /*#__PURE__*/_react["default"].createElement(_reactNative.View, {
261
307
  style: [styles.cameraWrapper, {
262
308
  width: cameraPreviewWidth
263
309
  }],
@@ -273,7 +319,6 @@ function CustomCamera(_ref) {
273
319
  console.log("Camera wrapper layout updated:", layout);
274
320
  }
275
321
  }, /*#__PURE__*/_react["default"].createElement(_expoCamera.CameraView, {
276
- key: cameraViewMountKey,
277
322
  style: styles.camera,
278
323
  facing: "back",
279
324
  ref: cameraRef,
@@ -286,10 +331,9 @@ function CustomCamera(_ref) {
286
331
  // This ensures preview matches what will be captured
287
332
  }), loadingBeforeCapture && /*#__PURE__*/_react["default"].createElement(_react["default"].Fragment, null, /*#__PURE__*/_react["default"].createElement(_reactNative.View, {
288
333
  style: styles.loadingOverlay
289
- }, /*#__PURE__*/_react["default"].createElement(_reactNative.Image, {
290
- source: require('../src/assets/loadingCamera.gif'),
291
- style: styles.loadingGif,
292
- resizeMode: "contain"
334
+ }, /*#__PURE__*/_react["default"].createElement(_reactNative.ActivityIndicator, {
335
+ size: "large",
336
+ color: PRIMARY_GREEN
293
337
  })), /*#__PURE__*/_react["default"].createElement(_reactNative.View, {
294
338
  style: styles.touchBlocker,
295
339
  pointerEvents: "auto"
@@ -308,8 +352,8 @@ function CustomCamera(_ref) {
308
352
  }, /*#__PURE__*/_react["default"].createElement(_reactNative.TouchableOpacity, {
309
353
  style: styles.button,
310
354
  onPress: takePicture,
311
- disabled: !isReady || loadingBeforeCapture
312
- })));
355
+ disabled: !isReady || loadingBeforeCapture || !hasPermission
356
+ }))));
313
357
  }
314
358
  var PRIMARY_GREEN = '#198754';
315
359
  var DEEP_BLACK = '#0B0B0B';
@@ -321,6 +365,30 @@ var styles = _reactNative.StyleSheet.create({
321
365
  justifyContent: 'center',
322
366
  alignItems: 'center'
323
367
  },
368
+ permissionContainer: {
369
+ flex: 1,
370
+ width: '100%',
371
+ justifyContent: 'center',
372
+ alignItems: 'center',
373
+ paddingHorizontal: 24
374
+ },
375
+ permissionText: {
376
+ marginTop: 12,
377
+ fontSize: 14,
378
+ color: GLOW_WHITE,
379
+ textAlign: 'center'
380
+ },
381
+ permissionButton: {
382
+ marginTop: 16,
383
+ backgroundColor: PRIMARY_GREEN,
384
+ paddingHorizontal: 16,
385
+ paddingVertical: 10,
386
+ borderRadius: 8
387
+ },
388
+ permissionButtonText: {
389
+ color: 'white',
390
+ fontWeight: '600'
391
+ },
324
392
  cameraWrapper: {
325
393
  aspectRatio: 9 / 16,
326
394
  borderRadius: 30,
@@ -343,10 +411,6 @@ var styles = _reactNative.StyleSheet.create({
343
411
  justifyContent: 'center',
344
412
  alignItems: 'center'
345
413
  }),
346
- loadingGif: {
347
- width: 100,
348
- height: 100
349
- },
350
414
  touchBlocker: _objectSpread(_objectSpread({}, _reactNative.StyleSheet.absoluteFillObject), {}, {
351
415
  zIndex: 21,
352
416
  backgroundColor: 'transparent'
@@ -49,6 +49,15 @@ var ImageCropper = function ImageCropper(_ref) {
49
49
  var _useWindowDimensions = (0, _reactNative.useWindowDimensions)(),
50
50
  windowWidth = _useWindowDimensions.width;
51
51
  var cameraPreviewWidth = Math.min(windowWidth, CAMERA_PREVIEW_MAX_WIDTH);
52
+ var guessExtensionFromUri = function guessExtensionFromUri(uri) {
53
+ if (!uri || typeof uri !== 'string') return null;
54
+ var match = uri.match(/\.([a-zA-Z0-9]+)(?:[?#].*)?$/);
55
+ if (!match) return null;
56
+ var ext = match[1].toLowerCase();
57
+ // Keep only common image extensions
58
+ if (['jpg', 'jpeg', 'png', 'webp', 'heic', 'heif'].includes(ext)) return ext;
59
+ return null;
60
+ };
52
61
  var _useState = (0, _react.useState)(null),
53
62
  _useState2 = _slicedToArray(_useState, 2),
54
63
  image = _useState2[0],
@@ -253,7 +262,6 @@ var ImageCropper = function ImageCropper(_ref) {
253
262
  var rotationInProgressRef = (0, _react.useRef)(false); // block duplicate taps immediately
254
263
  var lastValidPosition = (0, _react.useRef)(null);
255
264
  var insets = (0, _reactNativeSafeAreaContext.useSafeAreaInsets)();
256
- var openCameraTimeoutRef = (0, _react.useRef)(null);
257
265
 
258
266
  // ✅ NEW ARCH: mobile does NOT export the final crop.
259
267
 
@@ -262,17 +270,8 @@ var ImageCropper = function ImageCropper(_ref) {
262
270
  var enableRotation = true; // rotation resets crop box so it is re-initialized on rotated image.
263
271
 
264
272
  (0, _react.useEffect)(function () {
265
- // If a previous open was scheduled, cancel it.
266
- if (openCameraTimeoutRef.current) {
267
- clearTimeout(openCameraTimeoutRef.current);
268
- openCameraTimeoutRef.current = null;
269
- }
270
273
  if (openCameraFirst) {
271
- // Delay opening the camera so the prior session can fully release.
272
- openCameraTimeoutRef.current = setTimeout(function () {
273
- setShowCustomCamera(true);
274
- openCameraTimeoutRef.current = null;
275
- }, 800);
274
+ setShowCustomCamera(true);
276
275
  } else if (initialImage) {
277
276
  setImage(initialImage);
278
277
  sourceImageUri.current = initialImage;
@@ -286,12 +285,6 @@ var ImageCropper = function ImageCropper(_ref) {
286
285
  hasInitializedCropBox.current = false;
287
286
  imageSource.current = null;
288
287
  }
289
- return function () {
290
- if (openCameraTimeoutRef.current) {
291
- clearTimeout(openCameraTimeoutRef.current);
292
- openCameraTimeoutRef.current = null;
293
- }
294
- };
295
288
  }, [openCameraFirst, initialImage]);
296
289
 
297
290
  // ✅ REFACTORISATION : Stocker uniquement les dimensions originales (pas de calcul théorique)
@@ -1494,7 +1487,7 @@ var ImageCropper = function ImageCropper(_ref) {
1494
1487
  }, "Reset")), /*#__PURE__*/_react["default"].createElement(_reactNative.TouchableOpacity, {
1495
1488
  style: _ImageCropperStyles["default"].button,
1496
1489
  onPress: /*#__PURE__*/_asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee2() {
1497
- var _cameraFrameData$curr4, actualImageWidth, actualImageHeight, isCoverMode, captured, layout, contentRect, displayedWidth, displayedHeight, scale, coverOffsetX, coverOffsetY, scaledWidth, scaledHeight, originalUri, cropMeta, imagePoints, minX, minY, maxX, maxY, cropX, cropY, cropEndX, cropEndY, cropWidth, cropHeight, bbox, polygon, name, _t2;
1490
+ var _cameraFrameData$curr4, actualImageWidth, actualImageHeight, isCoverMode, captured, layout, contentRect, displayedWidth, displayedHeight, scale, coverOffsetX, coverOffsetY, scaledWidth, scaledHeight, originalUri, cropMeta, imagePoints, minX, minY, maxX, maxY, cropX, cropY, cropEndX, cropEndY, cropWidth, cropHeight, bbox, polygon, ext, safeExt, name, _t2;
1498
1491
  return _regenerator().w(function (_context2) {
1499
1492
  while (1) switch (_context2.p = _context2.n) {
1500
1493
  case 0:
@@ -1635,7 +1628,9 @@ var ImageCropper = function ImageCropper(_ref) {
1635
1628
  console.error("Error computing crop meta:", cropError);
1636
1629
  }
1637
1630
  }
1638
- name = "IMAGE XTK".concat(Date.now());
1631
+ ext = guessExtensionFromUri(originalUri) || 'jpg';
1632
+ safeExt = ext === 'jpeg' ? 'jpg' : ext;
1633
+ name = "IMAGE_XTK_".concat(Date.now(), ".").concat(safeExt);
1639
1634
  if (onConfirm) {
1640
1635
  onConfirm(originalUri, name, cropMeta);
1641
1636
  }
@@ -1688,10 +1683,9 @@ var ImageCropper = function ImageCropper(_ref) {
1688
1683
  animationType: "fade"
1689
1684
  }, /*#__PURE__*/_react["default"].createElement(_reactNative.View, {
1690
1685
  style: _ImageCropperStyles["default"].loadingOverlay
1691
- }, /*#__PURE__*/_react["default"].createElement(_reactNative.Image, {
1692
- source: require('../src/assets/loadingCamera.gif'),
1693
- style: _ImageCropperStyles["default"].loadingGif,
1694
- resizeMode: "contain"
1686
+ }, /*#__PURE__*/_react["default"].createElement(_reactNative.ActivityIndicator, {
1687
+ size: "large",
1688
+ color: PRIMARY_GREEN
1695
1689
  }))));
1696
1690
  };
1697
1691
  var _default = exports["default"] = ImageCropper;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-expo-cropper",
3
- "version": "1.2.47",
3
+ "version": "1.2.48",
4
4
  "description": "Recadrage polygonal d'images.",
5
5
  "main": "index.js",
6
6
  "author": "PCS AGRI",
@@ -47,7 +47,7 @@
47
47
  "homepage": "https://github.com/pcsagri/react-native-expo-cropper#readme",
48
48
  "scripts": {
49
49
  "test": "echo \"Error: no test specified\" && exit 1",
50
- "build": "npx --package=@babel/cli babel src --out-dir dist"
50
+ "build": "node ./node_modules/@babel/cli/bin/babel.js src --out-dir dist"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@babel/cli": "^7.28.3",
@@ -6,8 +6,8 @@ import {
6
6
  TouchableOpacity,
7
7
  Alert,
8
8
  SafeAreaView,
9
- Image,
10
9
  Platform,
10
+ ActivityIndicator,
11
11
  useWindowDimensions,
12
12
  } from 'react-native';
13
13
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -22,8 +22,6 @@ export default function CustomCamera({ onPhotoCaptured, onFrameCalculated }) {
22
22
  const [isReady, setIsReady] = useState(false);
23
23
  const [loadingBeforeCapture, setLoadingBeforeCapture] = useState(false);
24
24
  const [hasPermission, setHasPermission] = useState(null);
25
- // Force a fresh native CameraView instance per "open" to avoid stale/cached preview.
26
- const [cameraViewMountKey] = useState(() => `${Date.now()}-${Math.random().toString(16).slice(2)}`);
27
25
  const cameraRef = useRef(null);
28
26
  const cameraWrapperRef = useRef(null);
29
27
  const insets = useSafeAreaInsets();
@@ -37,6 +35,15 @@ export default function CustomCamera({ onPhotoCaptured, onFrameCalculated }) {
37
35
  })();
38
36
  }, []);
39
37
 
38
+ const requestPermissionAgain = async () => {
39
+ try {
40
+ const { status } = await Camera.requestCameraPermissionsAsync();
41
+ setHasPermission(status === 'granted');
42
+ } catch (e) {
43
+ setHasPermission(false);
44
+ }
45
+ };
46
+
40
47
  // Helper function to wait for multiple render cycles (works on iOS)
41
48
  const waitForRender = (cycles = 5) => {
42
49
  return new Promise((resolve) => {
@@ -104,6 +111,14 @@ export default function CustomCamera({ onPhotoCaptured, onFrameCalculated }) {
104
111
  }, [cameraWrapperLayout]);
105
112
 
106
113
  const takePicture = async () => {
114
+ if (!hasPermission) {
115
+ Alert.alert(
116
+ "Permission requise",
117
+ "Veuillez autoriser l'accès à la caméra pour prendre une photo."
118
+ );
119
+ return;
120
+ }
121
+
107
122
  if (cameraRef.current) {
108
123
  try {
109
124
  // Show loading after a delay (using setImmediate for iOS compatibility)
@@ -125,7 +140,8 @@ export default function CustomCamera({ onPhotoCaptured, onFrameCalculated }) {
125
140
 
126
141
  // ✅ CRITICAL: skipProcessing preserves original resolution and avoids interpolation
127
142
  // This ensures pixel-perfect quality and prevents premature resizing
128
- skipProcessing: true,
143
+ // NOTE: Some Android devices are unstable with skipProcessing; keep it off there.
144
+ skipProcessing: Platform.OS === 'ios',
129
145
 
130
146
  // Include EXIF metadata (orientation, camera settings, etc.)
131
147
  exif: true,
@@ -200,71 +216,88 @@ export default function CustomCamera({ onPhotoCaptured, onFrameCalculated }) {
200
216
 
201
217
  return (
202
218
  <SafeAreaView style={styles.outerContainer}>
203
- <View
204
- style={[styles.cameraWrapper, { width: cameraPreviewWidth }]}
205
- ref={cameraWrapperRef}
206
- onLayout={(e) => {
207
- const layout = e.nativeEvent.layout;
208
- setCameraWrapperLayout({
209
- width: layout.width,
210
- height: layout.height,
211
- x: layout.x,
212
- y: layout.y
213
- });
214
- console.log("Camera wrapper layout updated:", layout);
215
- }}
216
- >
217
- <CameraView
218
- key={cameraViewMountKey}
219
- style={styles.camera}
220
- facing="back"
221
- ref={cameraRef}
222
- onCameraReady={() => {
223
- setIsReady(true);
224
- console.log("✅ Camera ready - Maximum quality capture enabled");
225
- }}
226
- // ✅ Ensure camera uses native aspect ratio (typically 4:3 for most devices)
227
- // The wrapper constrains the view, but capture uses full sensor resolution
228
- // This ensures preview matches what will be captured
229
- />
230
-
231
- {/* Loading overlay */}
232
- {loadingBeforeCapture && (
233
- <>
234
- <View style={styles.loadingOverlay}>
235
- <Image
236
- source={require('../src/assets/loadingCamera.gif')}
237
- style={styles.loadingGif}
238
- resizeMode="contain"
239
- />
240
- </View>
241
- <View style={styles.touchBlocker} pointerEvents="auto" />
242
- </>
243
- )}
244
-
245
- {/* Cadre de scan - rendered using calculated coordinates (x, y, width, height) */}
246
- {greenFrame && (
219
+ {hasPermission === null && (
220
+ <View style={styles.permissionContainer}>
221
+ <ActivityIndicator size="large" color={PRIMARY_GREEN} />
222
+ <Text style={styles.permissionText}>Demande d'autorisation caméra...</Text>
223
+ </View>
224
+ )}
225
+
226
+ {hasPermission === false && (
227
+ <View style={styles.permissionContainer}>
228
+ <Text style={styles.permissionText}>
229
+ Autorisation caméra refusée. Activez-la dans les paramètres pour continuer.
230
+ </Text>
231
+ <TouchableOpacity style={styles.permissionButton} onPress={requestPermissionAgain}>
232
+ <Text style={styles.permissionButtonText}>Réessayer</Text>
233
+ </TouchableOpacity>
234
+ </View>
235
+ )}
236
+
237
+ {hasPermission === true && (
238
+ <>
247
239
  <View
248
- style={[
249
- styles.scanFrame,
250
- {
251
- left: greenFrame.x,
252
- top: greenFrame.y,
253
- width: greenFrame.width,
254
- height: greenFrame.height,
255
- },
256
- ]}
257
- />
258
- )}
259
- </View>
260
-
261
- <View style={[styles.buttonContainer, { bottom: (insets?.bottom || 0) + 16, marginBottom: 0 }]}>
262
- <TouchableOpacity
263
- style={styles.button}
264
- onPress={takePicture}
265
- disabled={!isReady || loadingBeforeCapture}
266
- />
267
- </View>
240
+ style={[styles.cameraWrapper, { width: cameraPreviewWidth }]}
241
+ ref={cameraWrapperRef}
242
+ onLayout={(e) => {
243
+ const layout = e.nativeEvent.layout;
244
+ setCameraWrapperLayout({
245
+ width: layout.width,
246
+ height: layout.height,
247
+ x: layout.x,
248
+ y: layout.y
249
+ });
250
+ console.log("Camera wrapper layout updated:", layout);
251
+ }}
252
+ >
253
+ <CameraView
254
+ style={styles.camera}
255
+ facing="back"
256
+ ref={cameraRef}
257
+ onCameraReady={() => {
258
+ setIsReady(true);
259
+ console.log("✅ Camera ready - Maximum quality capture enabled");
260
+ }}
261
+ // ✅ Ensure camera uses native aspect ratio (typically 4:3 for most devices)
262
+ // The wrapper constrains the view, but capture uses full sensor resolution
263
+ // This ensures preview matches what will be captured
264
+ />
265
+
266
+ {/* Loading overlay */}
267
+ {loadingBeforeCapture && (
268
+ <>
269
+ <View style={styles.loadingOverlay}>
270
+ <ActivityIndicator size="large" color={PRIMARY_GREEN} />
271
+ </View>
272
+ <View style={styles.touchBlocker} pointerEvents="auto" />
273
+ </>
274
+ )}
275
+
276
+ {/* Cadre de scan - rendered using calculated coordinates (x, y, width, height) */}
277
+ {greenFrame && (
278
+ <View
279
+ style={[
280
+ styles.scanFrame,
281
+ {
282
+ left: greenFrame.x,
283
+ top: greenFrame.y,
284
+ width: greenFrame.width,
285
+ height: greenFrame.height,
286
+ },
287
+ ]}
288
+ />
289
+ )}
290
+ </View>
291
+
292
+ <View style={[styles.buttonContainer, { bottom: (insets?.bottom || 0) + 16, marginBottom: 0 }]}>
293
+ <TouchableOpacity
294
+ style={styles.button}
295
+ onPress={takePicture}
296
+ disabled={!isReady || loadingBeforeCapture || !hasPermission}
297
+ />
298
+ </View>
299
+ </>
300
+ )}
268
301
  </SafeAreaView>
269
302
  );
270
303
  }
@@ -280,6 +313,30 @@ const styles = StyleSheet.create({
280
313
  justifyContent: 'center',
281
314
  alignItems: 'center',
282
315
  },
316
+ permissionContainer: {
317
+ flex: 1,
318
+ width: '100%',
319
+ justifyContent: 'center',
320
+ alignItems: 'center',
321
+ paddingHorizontal: 24,
322
+ },
323
+ permissionText: {
324
+ marginTop: 12,
325
+ fontSize: 14,
326
+ color: GLOW_WHITE,
327
+ textAlign: 'center',
328
+ },
329
+ permissionButton: {
330
+ marginTop: 16,
331
+ backgroundColor: PRIMARY_GREEN,
332
+ paddingHorizontal: 16,
333
+ paddingVertical: 10,
334
+ borderRadius: 8,
335
+ },
336
+ permissionButtonText: {
337
+ color: 'white',
338
+ fontWeight: '600',
339
+ },
283
340
  cameraWrapper: {
284
341
  aspectRatio: 9 / 16,
285
342
  borderRadius: 30,
@@ -305,10 +362,6 @@ const styles = StyleSheet.create({
305
362
  justifyContent: 'center',
306
363
  alignItems: 'center',
307
364
  },
308
- loadingGif: {
309
- width: 100,
310
- height: 100,
311
- },
312
365
  touchBlocker: {
313
366
  ...StyleSheet.absoluteFillObject,
314
367
  zIndex: 21,
@@ -17,6 +17,16 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage, addheight, rot
17
17
  const { width: windowWidth } = useWindowDimensions();
18
18
  const cameraPreviewWidth = Math.min(windowWidth, CAMERA_PREVIEW_MAX_WIDTH);
19
19
 
20
+ const guessExtensionFromUri = (uri) => {
21
+ if (!uri || typeof uri !== 'string') return null;
22
+ const match = uri.match(/\.([a-zA-Z0-9]+)(?:[?#].*)?$/);
23
+ if (!match) return null;
24
+ const ext = match[1].toLowerCase();
25
+ // Keep only common image extensions
26
+ if (['jpg', 'jpeg', 'png', 'webp', 'heic', 'heif'].includes(ext)) return ext;
27
+ return null;
28
+ };
29
+
20
30
  const [image, setImage] = useState(null);
21
31
  const [points, setPoints] = useState([]);
22
32
  const [showResult, setShowResult] = useState(false);
@@ -141,7 +151,6 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage, addheight, rot
141
151
  const rotationInProgressRef = useRef(false); // block duplicate taps immediately
142
152
  const lastValidPosition = useRef(null);
143
153
  const insets = useSafeAreaInsets();
144
- const openCameraTimeoutRef = useRef(null);
145
154
 
146
155
  // ✅ NEW ARCH: mobile does NOT export the final crop.
147
156
 
@@ -154,39 +163,22 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage, addheight, rot
154
163
 
155
164
 
156
165
  useEffect(() => {
157
- // If a previous open was scheduled, cancel it.
158
- if (openCameraTimeoutRef.current) {
159
- clearTimeout(openCameraTimeoutRef.current);
160
- openCameraTimeoutRef.current = null;
161
- }
162
-
163
- if (openCameraFirst) {
164
- // Delay opening the camera so the prior session can fully release.
165
- openCameraTimeoutRef.current = setTimeout(() => {
166
- setShowCustomCamera(true);
167
- openCameraTimeoutRef.current = null;
168
- }, 800);
169
- } else if (initialImage) {
170
- setImage(initialImage);
171
- sourceImageUri.current = initialImage;
172
- // CRITICAL: Reset points when loading a new image from gallery
173
- // This ensures the crop box will be automatically initialized
174
- setPoints([]);
175
- rotationAngle.current = 0;
176
- // Clear camera frame data for gallery images
177
- cameraFrameData.current = null;
178
- // ✅ CRITICAL: Reset initialization guard for new image
179
- hasInitializedCropBox.current = false;
180
- imageSource.current = null;
181
- }
182
-
183
- return () => {
184
- if (openCameraTimeoutRef.current) {
185
- clearTimeout(openCameraTimeoutRef.current);
186
- openCameraTimeoutRef.current = null;
187
- }
188
- };
189
- }, [openCameraFirst, initialImage]);
166
+ if (openCameraFirst) {
167
+ setShowCustomCamera(true);
168
+ } else if (initialImage) {
169
+ setImage(initialImage);
170
+ sourceImageUri.current = initialImage;
171
+ // ✅ CRITICAL: Reset points when loading a new image from gallery
172
+ // This ensures the crop box will be automatically initialized
173
+ setPoints([]);
174
+ rotationAngle.current = 0;
175
+ // Clear camera frame data for gallery images
176
+ cameraFrameData.current = null;
177
+ // ✅ CRITICAL: Reset initialization guard for new image
178
+ hasInitializedCropBox.current = false;
179
+ imageSource.current = null;
180
+ }
181
+ }, [openCameraFirst, initialImage]);
190
182
 
191
183
 
192
184
  // ✅ REFACTORISATION : Stocker uniquement les dimensions originales (pas de calcul théorique)
@@ -1392,7 +1384,9 @@ const rotatePreviewImage = async (degrees) => {
1392
1384
  }
1393
1385
  }
1394
1386
 
1395
- const name = `IMAGE XTK${Date.now()}`;
1387
+ const ext = guessExtensionFromUri(originalUri) || 'jpg';
1388
+ const safeExt = ext === 'jpeg' ? 'jpg' : ext;
1389
+ const name = `IMAGE_XTK_${Date.now()}.${safeExt}`;
1396
1390
  if (onConfirm) {
1397
1391
  onConfirm(originalUri, name, cropMeta);
1398
1392
  }
@@ -1449,14 +1443,10 @@ const rotatePreviewImage = async (degrees) => {
1449
1443
  )}
1450
1444
 
1451
1445
  <Modal visible={isLoading} transparent animationType="fade">
1452
- <View style={styles.loadingOverlay}>
1453
- <Image
1454
- source={require('../src/assets/loadingCamera.gif')}
1455
- style={styles.loadingGif}
1456
- resizeMode="contain"
1457
- />
1458
- </View>
1459
- </Modal>
1446
+ <View style={styles.loadingOverlay}>
1447
+ <ActivityIndicator size="large" color={PRIMARY_GREEN} />
1448
+ </View>
1449
+ </Modal>
1460
1450
  </View>
1461
1451
  );
1462
1452
  };