diff --git a/assets/images/crop.svg b/assets/images/crop.svg new file mode 100644 index 000000000000..d9638c22851f --- /dev/null +++ b/assets/images/crop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/cspell.json b/cspell.json index a438d9c338e7..f9fe50a6e5c1 100644 --- a/cspell.json +++ b/cspell.json @@ -829,6 +829,8 @@ "Wooo", "Splittable", "pgrep", + "Nesw", + "nesw", "skia", "canvaskit", "Invoicify", diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 70d239973d2a..89fa7f739f18 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -360,7 +360,7 @@ function Button({ {!!icon && ( - + void; + + /** Initial crop rectangle (optional) */ + initialCrop?: CropRect; + + isAuthTokenRequired?: boolean; +}; + +type CornerPosition = 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'; +type EdgePosition = 'top' | 'bottom' | 'left' | 'right'; + +function ReceiptCropView({imageUri, onCropChange, initialCrop, isAuthTokenRequired}: ReceiptCropViewProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + + // Image dimensions + const [imageSize, setImageSize] = useState({width: 0, height: 0}); + const [hasImageDimensions, setHasImageDimensions] = useState(false); + + // Container dimensions (using shared values for worklet access) + const containerWidthSV = useSharedValue(0); + const containerHeightSV = useSharedValue(0); + const displayWidthSV = useSharedValue(0); + const displayHeightSV = useSharedValue(0); + const imageOffsetXSV = useSharedValue(0); + const imageOffsetYSV = useSharedValue(0); + const [containerSize, setContainerSize] = useState({width: 0, height: 0}); + + // Crop rectangle in display coordinates (relative to container) + const cropX = useSharedValue(0); + const cropY = useSharedValue(0); + const cropWidth = useSharedValue(0); + const cropHeight = useSharedValue(0); + + // Track previous values to detect changes and recalculate crop on resize + const prevDisplayValuesRef = useRef<{ + displayWidth: number; + displayHeight: number; + scaleX: number; + scaleY: number; + imageOffsetX: number; + imageOffsetY: number; + } | null>(null); + + const isCropInitialized = imageSize.width > 0 && imageSize.height > 0 && containerSize.width > 0 && containerSize.height > 0; + + // Calculate scale factors to convert display coordinates to image coordinates + const {scaleX, scaleY, displayWidth, displayHeight, imageOffsetX, imageOffsetY} = useMemo(() => { + if (!containerSize.width || !containerSize.height || !imageSize.width || !imageSize.height) { + return {scaleX: 1, scaleY: 1, displayWidth: 0, displayHeight: 0, imageOffsetX: 0, imageOffsetY: 0}; + } + + // Calculate how the image is scaled to fit the container + const imageAspectRatio = imageSize.width / imageSize.height; + const containerAspectRatio = containerSize.width / containerSize.height; + + let calculatedDisplayWidth: number; + let calculatedDisplayHeight: number; + + if (imageAspectRatio > containerAspectRatio) { + // Image is wider - fit to width + calculatedDisplayWidth = containerSize.width; + calculatedDisplayHeight = containerSize.width / imageAspectRatio; + } else { + // Image is taller - fit to height + calculatedDisplayHeight = containerSize.height; + calculatedDisplayWidth = containerSize.height * imageAspectRatio; + } + + // Calculate the image's offset within the container (centered) + const calculatedImageOffsetX = (containerSize.width - calculatedDisplayWidth) / 2; + const calculatedImageOffsetY = (containerSize.height - calculatedDisplayHeight) / 2; + + // Calculate scale factors to convert display coordinates to image coordinates + const calculatedScaleX = imageSize.width / calculatedDisplayWidth; + const calculatedScaleY = imageSize.height / calculatedDisplayHeight; + + // Update shared values for worklet access + displayWidthSV.set(calculatedDisplayWidth); + displayHeightSV.set(calculatedDisplayHeight); + imageOffsetXSV.set(calculatedImageOffsetX); + imageOffsetYSV.set(calculatedImageOffsetY); + + return { + scaleX: calculatedScaleX, + scaleY: calculatedScaleY, + displayWidth: calculatedDisplayWidth, + displayHeight: calculatedDisplayHeight, + imageOffsetX: calculatedImageOffsetX, + imageOffsetY: calculatedImageOffsetY, + }; + }, [containerSize.width, containerSize.height, imageSize.width, imageSize.height, displayWidthSV, displayHeightSV, imageOffsetXSV, imageOffsetYSV]); + + // Initialize crop rectangle when dimensions are available + useEffect(() => { + if (!displayWidth || !displayHeight || !scaleX || !scaleY) { + return; + } + + // Only initialize if crop values are still at 0 (not yet initialized) + if (cropWidth.get() === 0 && cropHeight.get() === 0) { + if (initialCrop) { + // Convert image coordinates to display coordinates and add image offset + cropX.set(imageOffsetX + initialCrop.x / scaleX); + cropY.set(imageOffsetY + initialCrop.y / scaleY); + cropWidth.set(initialCrop.width / scaleX); + cropHeight.set(initialCrop.height / scaleY); + } else { + // Default: crop the entire image area (no padding) + // Crop rectangle is relative to the image position (centered in container) + cropX.set(imageOffsetX); + cropY.set(imageOffsetY); + cropWidth.set(displayWidth); + cropHeight.set(displayHeight); + } + // Store initial values + prevDisplayValuesRef.current = { + displayWidth, + displayHeight, + scaleX, + scaleY, + imageOffsetX, + imageOffsetY, + }; + } + }, [displayWidth, displayHeight, scaleX, scaleY, imageOffsetX, imageOffsetY, initialCrop, cropX, cropY, cropWidth, cropHeight]); + + // Update crop rectangle when container/image dimensions change (e.g., window resize) + useEffect(() => { + if (!displayWidth || !displayHeight || !scaleX || !scaleY) { + return; + } + + // Only update if crop is already initialized and dimensions have changed + if (cropWidth.get() === 0 && cropHeight.get() === 0) { + return; + } + + const prev = prevDisplayValuesRef.current; + if (!prev) { + return; + } + + // Check if dimensions actually changed + const hasChanged = + prev.displayWidth !== displayWidth || + prev.displayHeight !== displayHeight || + prev.scaleX !== scaleX || + prev.scaleY !== scaleY || + prev.imageOffsetX !== imageOffsetX || + prev.imageOffsetY !== imageOffsetY; + + if (!hasChanged) { + return; + } + + // Get current crop rectangle in display coordinates + const currentCropX = cropX.get(); + const currentCropY = cropY.get(); + const currentCropWidth = cropWidth.get(); + const currentCropHeight = cropHeight.get(); + + // Convert current crop from old display coordinates to image coordinates + const imageX = (currentCropX - prev.imageOffsetX) * prev.scaleX; + const imageY = (currentCropY - prev.imageOffsetY) * prev.scaleY; + const imageWidth = currentCropWidth * prev.scaleX; + const imageHeight = currentCropHeight * prev.scaleY; + + // Convert back to new display coordinates + const newCropX = imageOffsetX + imageX / scaleX; + const newCropY = imageOffsetY + imageY / scaleY; + const newCropWidth = imageWidth / scaleX; + const newCropHeight = imageHeight / scaleY; + + // Update crop rectangle + cropX.set(newCropX); + cropY.set(newCropY); + cropWidth.set(newCropWidth); + cropHeight.set(newCropHeight); + + // Update stored values + prevDisplayValuesRef.current = { + displayWidth, + displayHeight, + scaleX, + scaleY, + imageOffsetX, + imageOffsetY, + }; + + // Notify parent of the updated crop (in image coordinates) + const crop: CropRect = { + x: imageX, + y: imageY, + width: imageWidth, + height: imageHeight, + }; + onCropChange?.(crop); + }, [displayWidth, displayHeight, scaleX, scaleY, imageOffsetX, imageOffsetY, cropX, cropY, cropWidth, cropHeight, onCropChange]); + + const onContainerLayout = useCallback( + (event: LayoutChangeEvent) => { + const {width, height} = event.nativeEvent.layout; + setContainerSize({width, height}); + containerWidthSV.set(width); + containerHeightSV.set(height); + }, + [containerWidthSV, containerHeightSV], + ); + + const onImageLoad = useCallback( + (event: {nativeEvent: {width: number; height: number}}) => { + const {width, height} = event.nativeEvent; + if (!width || !height || (imageSize.width === width && imageSize.height === height)) { + return; + } + if (!hasImageDimensions) { + setHasImageDimensions(true); + } + setImageSize({width, height}); + }, + [hasImageDimensions, imageSize.width, imageSize.height], + ); + + /** + * Clamp a value between min and max + */ + const clamp = useCallback((value: number, min: number, max: number) => { + 'worklet'; + + return Math.max(min, Math.min(max, value)); + }, []); + + const applyCropBoundsAndNotify = useCallback( + (newX: number, newY: number, newWidth: number, newHeight: number, imageLeft: number, imageRight: number, imageTop: number, imageBottom: number) => { + // clamp to image bounds + let x = newX; + let y = newY; + let w = newWidth; + let h = newHeight; + + if (x < imageLeft) { + const diff = imageLeft - x; + x = imageLeft; + w -= diff; + } + if (x + w > imageRight) { + w = imageRight - x; + } + if (y < imageTop) { + const diff = imageTop - y; + y = imageTop; + h -= diff; + } + if (y + h > imageBottom) { + h = imageBottom - y; + } + + cropX.set(x); + cropY.set(y); + cropWidth.set(w); + cropHeight.set(h); + + onCropChange?.({ + x: (x - imageOffsetX) * scaleX, + y: (y - imageOffsetY) * scaleY, + width: w * scaleX, + height: h * scaleY, + }); + }, + [cropX, cropY, cropWidth, cropHeight, imageOffsetX, scaleX, imageOffsetY, scaleY, onCropChange], + ); + + /** + * Create gesture handler for an edge + */ + const createEdgeGesture = useCallback( + (edge: EdgePosition) => { + return Gesture.Pan() + .runOnJS(true) + .onChange((event: GestureUpdateEvent) => { + const currentX = cropX.get(); + const currentY = cropY.get(); + const currentWidth = cropWidth.get(); + const currentHeight = cropHeight.get(); + + let newX = currentX; + let newY = currentY; + let newWidth = currentWidth; + let newHeight = currentHeight; + + const minSize = variables.cornerHandleSize * 2; + const imgDisplayWidth = displayWidthSV.get(); + const imgDisplayHeight = displayHeightSV.get(); + const imageLeft = imageOffsetXSV.get(); + const imageRight = imageLeft + imgDisplayWidth; + const imageTop = imageOffsetYSV.get(); + const imageBottom = imageTop + imgDisplayHeight; + + switch (edge) { + case 'top': + newY = clamp(currentY + event.changeY, imageTop, currentY + currentHeight - minSize); + newHeight = currentHeight - (newY - currentY); + break; + case 'bottom': + newHeight = clamp(currentHeight + event.changeY, minSize, imageBottom - currentY); + break; + case 'left': + newX = clamp(currentX + event.changeX, imageLeft, currentX + currentWidth - minSize); + newWidth = currentWidth - (newX - currentX); + break; + case 'right': + newWidth = clamp(currentWidth + event.changeX, minSize, imageRight - currentX); + break; + default: + break; + } + + applyCropBoundsAndNotify(newX, newY, newWidth, newHeight, imageLeft, imageRight, imageTop, imageBottom); + }); + }, + [cropX, cropY, cropWidth, cropHeight, displayWidthSV, displayHeightSV, imageOffsetXSV, imageOffsetYSV, applyCropBoundsAndNotify, clamp], + ); + + /** + * Create gesture handler for a corner + */ + const createCornerGesture = useCallback( + (corner: CornerPosition) => { + return Gesture.Pan() + .runOnJS(true) + .onChange((event: GestureUpdateEvent) => { + const currentX = cropX.get(); + const currentY = cropY.get(); + const currentWidth = cropWidth.get(); + const currentHeight = cropHeight.get(); + + let newX = currentX; + let newY = currentY; + let newWidth = currentWidth; + let newHeight = currentHeight; + + const minSize = variables.cornerHandleSize * 2; + const imgDisplayWidth = displayWidthSV.get(); + const imgDisplayHeight = displayHeightSV.get(); + const imageLeft = imageOffsetXSV.get(); + const imageRight = imageLeft + imgDisplayWidth; + const imageTop = imageOffsetYSV.get(); + const imageBottom = imageTop + imgDisplayHeight; + + switch (corner) { + case 'topLeft': + newX = clamp(currentX + event.changeX, imageLeft, currentX + currentWidth - minSize); + newY = clamp(currentY + event.changeY, imageTop, currentY + currentHeight - minSize); + newWidth = currentWidth - (newX - currentX); + newHeight = currentHeight - (newY - currentY); + break; + case 'topRight': + newY = clamp(currentY + event.changeY, imageTop, currentY + currentHeight - minSize); + newWidth = clamp(currentWidth + event.changeX, minSize, imageRight - currentX); + newHeight = currentHeight - (newY - currentY); + break; + case 'bottomLeft': + newX = clamp(currentX + event.changeX, imageLeft, currentX + currentWidth - minSize); + newWidth = currentWidth - (newX - currentX); + newHeight = clamp(currentHeight + event.changeY, minSize, imageBottom - currentY); + break; + case 'bottomRight': + newWidth = clamp(currentWidth + event.changeX, minSize, imageRight - currentX); + newHeight = clamp(currentHeight + event.changeY, minSize, imageBottom - currentY); + break; + default: + break; + } + + applyCropBoundsAndNotify(newX, newY, newWidth, newHeight, imageLeft, imageRight, imageTop, imageBottom); + }); + }, + [cropX, cropY, cropWidth, cropHeight, displayWidthSV, displayHeightSV, imageOffsetXSV, imageOffsetYSV, applyCropBoundsAndNotify, clamp], + ); + + const borderStyle = useAnimatedStyle(() => { + 'worklet'; + + return StyleUtils.getCropViewStyle('border', { + cropX: cropX.get(), + cropY: cropY.get(), + cropWidth: cropWidth.get(), + cropHeight: cropHeight.get(), + }); + }); + + const topLeftCornerStyle = useAnimatedStyle(() => { + 'worklet'; + + return StyleUtils.getCropViewStyle('cornerTopLeft', { + cropX: cropX.get(), + cropY: cropY.get(), + cropWidth: cropWidth.get(), + cropHeight: cropHeight.get(), + }); + }); + + const topRightCornerStyle = useAnimatedStyle(() => { + 'worklet'; + + return StyleUtils.getCropViewStyle('cornerTopRight', { + cropX: cropX.get(), + cropY: cropY.get(), + cropWidth: cropWidth.get(), + cropHeight: cropHeight.get(), + }); + }); + + const bottomLeftCornerStyle = useAnimatedStyle(() => { + 'worklet'; + + return StyleUtils.getCropViewStyle('cornerBottomLeft', { + cropX: cropX.get(), + cropY: cropY.get(), + cropWidth: cropWidth.get(), + cropHeight: cropHeight.get(), + }); + }); + + const bottomRightCornerStyle = useAnimatedStyle(() => { + 'worklet'; + + return StyleUtils.getCropViewStyle('cornerBottomRight', { + cropX: cropX.get(), + cropY: cropY.get(), + cropWidth: cropWidth.get(), + cropHeight: cropHeight.get(), + }); + }); + + // Edge handle styles - thicker tap target while keeping visual size + const topEdgeStyle = useAnimatedStyle(() => { + 'worklet'; + + return StyleUtils.getCropViewStyle('edgeTop', { + cropX: cropX.get(), + cropY: cropY.get(), + cropWidth: cropWidth.get(), + cropHeight: cropHeight.get(), + }); + }); + + const bottomEdgeStyle = useAnimatedStyle(() => { + 'worklet'; + + return StyleUtils.getCropViewStyle('edgeBottom', { + cropX: cropX.get(), + cropY: cropY.get(), + cropWidth: cropWidth.get(), + cropHeight: cropHeight.get(), + }); + }); + + const leftEdgeStyle = useAnimatedStyle(() => { + 'worklet'; + + return StyleUtils.getCropViewStyle('edgeLeft', { + cropX: cropX.get(), + cropY: cropY.get(), + cropWidth: cropWidth.get(), + cropHeight: cropHeight.get(), + }); + }); + + const rightEdgeStyle = useAnimatedStyle(() => { + 'worklet'; + + return StyleUtils.getCropViewStyle('edgeRight', { + cropX: cropX.get(), + cropY: cropY.get(), + cropWidth: cropWidth.get(), + cropHeight: cropHeight.get(), + }); + }); + + const overlayTopStyle = useAnimatedStyle(() => { + 'worklet'; + + const imageLeft = imageOffsetXSV.get(); + const imageTop = imageOffsetYSV.get(); + const imgDisplayWidth = displayWidthSV.get(); + const cropTop = cropY.get(); + + return StyleUtils.getCropViewStyle('overlayTop', { + imageLeft, + imageTop, + imgDisplayWidth, + cropTop, + }); + }); + + const overlayBottomStyle = useAnimatedStyle(() => { + 'worklet'; + + const imageLeft = imageOffsetXSV.get(); + const imageTop = imageOffsetYSV.get(); + const imgDisplayWidth = displayWidthSV.get(); + const imgDisplayHeight = displayHeightSV.get(); + const cropBottom = cropY.get() + cropHeight.get(); + const imageBottom = imageTop + imgDisplayHeight; + + return StyleUtils.getCropViewStyle('overlayBottom', { + imageLeft, + imageTop, + imgDisplayWidth, + cropBottom, + imageBottom, + }); + }); + + const overlayLeftStyle = useAnimatedStyle(() => { + 'worklet'; + + const imageLeft = imageOffsetXSV.get(); + const cropLeft = cropX.get(); + const cropTop = cropY.get(); + const cropHeightValue = cropHeight.get(); + + return StyleUtils.getCropViewStyle('overlayLeft', { + imageLeft, + cropLeft, + cropTop, + cropHeight: cropHeightValue, + }); + }); + + const overlayRightStyle = useAnimatedStyle(() => { + 'worklet'; + + const imageLeft = imageOffsetXSV.get(); + const imgDisplayWidth = displayWidthSV.get(); + const cropRight = cropX.get() + cropWidth.get(); + const imageRight = imageLeft + imgDisplayWidth; + const cropTop = cropY.get(); + const cropHeightValue = cropHeight.get(); + + return StyleUtils.getCropViewStyle('overlayRight', { + imageLeft, + imgDisplayWidth, + cropRight, + imageRight, + cropTop, + cropHeight: cropHeightValue, + }); + }); + + const cornerVisualStyle = StyleUtils.getCropViewStyle('cornerVisual'); + + return ( + + ControlSelection.blockElement(el as HTMLElement | null)} + > + + + + + {isCropInitialized && ( + <> + + + + + + + + {/* Edge handles */} + + + + + + + + + + + + + + {/* Corner handles */} + + + + + + + + + + + + + + + + + + + + + + )} + {!hasImageDimensions && } + + + ); +} + +ReceiptCropView.displayName = 'ReceiptCropView'; + +export default ReceiptCropView; +export type {CropRect}; diff --git a/src/languages/de.ts b/src/languages/de.ts index cdc2e5526b00..897d7ed64908 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1083,6 +1083,7 @@ const translations: TranslationDeepObject = { deleteConfirmation: 'Sind Sie sicher, dass Sie diesen Beleg löschen möchten?', addReceipt: 'Beleg hinzufügen', scanFailed: 'Der Beleg konnte nicht gescannt werden, da Händler, Datum oder Betrag fehlen.', + crop: 'Zuschneiden', addAReceipt: { phrase1: 'Beleg hinzufügen', phrase2: 'oder ziehe eine hierher und lege sie ab', diff --git a/src/languages/en.ts b/src/languages/en.ts index fe10986c45d5..97be62ab6255 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1105,6 +1105,7 @@ const translations = { deleteConfirmation: 'Are you sure you want to delete this receipt?', addReceipt: 'Add receipt', scanFailed: "The receipt couldn't be scanned, as it's missing a merchant, date, or amount.", + crop: 'Crop', addAReceipt: { phrase1: 'Add a receipt', phrase2: 'or drag and drop one here', diff --git a/src/languages/es.ts b/src/languages/es.ts index 308af6fe1d71..e972a488ecbb 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -936,6 +936,7 @@ const translations: TranslationDeepObject = { deleteConfirmation: '¿Estás seguro de que quieres borrar este recibo?', addReceipt: 'Añadir recibo', scanFailed: 'El recibo no pudo ser escaneado, ya que falta el comerciante, la fecha o el monto.', + crop: 'Recortar', addAReceipt: { phrase1: 'Añade un recibo', phrase2: 'o arrastra y suelta uno aquí', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index e2f830bb90bd..a6b7967a2dbf 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1087,6 +1087,7 @@ const translations: TranslationDeepObject = { deleteConfirmation: 'Voulez-vous vraiment supprimer ce reçu ?', addReceipt: 'Ajouter un reçu', scanFailed: 'Le reçu n’a pas pu être scanné, car il lui manque un commerçant, une date ou un montant.', + crop: 'Recadrer', addAReceipt: { phrase1: 'Ajouter un reçu', phrase2: 'ou faites-en glisser un ici', diff --git a/src/languages/it.ts b/src/languages/it.ts index 71a693672a3a..72b4a0b126e0 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1081,6 +1081,7 @@ const translations: TranslationDeepObject = { deleteConfirmation: 'Sei sicuro di voler eliminare questa ricevuta?', addReceipt: 'Aggiungi ricevuta', scanFailed: 'La ricevuta non può essere acquisita perché manca il nome dell’esercente, la data o l’importo.', + crop: 'Ritaglia', addAReceipt: { phrase1: 'Aggiungi una ricevuta', phrase2: 'o trascinalo qui', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 78da63366d2c..8f0ace2df696 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1073,6 +1073,7 @@ const translations: TranslationDeepObject = { deleteConfirmation: 'この領収書を削除してもよろしいですか?', addReceipt: '領収書を追加', scanFailed: 'このレシートは、店舗名、日付、または金額が不足しているためスキャンできませんでした。', + crop: 'トリミング', addAReceipt: { phrase1: '領収書を追加', phrase2: 'または、ここにファイルをドラッグ&ドロップしてください', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index fb7319713ee0..270fb8041331 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1080,6 +1080,7 @@ const translations: TranslationDeepObject = { deleteConfirmation: 'Weet je zeker dat je deze bon wilt verwijderen?', addReceipt: 'Bon toevoegen', scanFailed: 'De bon is niet gescand, omdat er een handelaar, datum of bedrag ontbreekt.', + crop: 'Bijsnijden', addAReceipt: { phrase1: 'Voeg een bon toe', phrase2: 'of sleep ze hier naartoe', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 78200c1d5e0b..d7f9879de14d 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1080,6 +1080,7 @@ const translations: TranslationDeepObject = { deleteConfirmation: 'Czy na pewno chcesz usunąć ten paragon?', addReceipt: 'Dodaj paragon', scanFailed: 'Nie można było zeskanować paragonu, ponieważ brakuje na nim sprzedawcy, daty lub kwoty.', + crop: 'Przytnij', addAReceipt: { phrase1: 'Dodaj paragon', phrase2: 'lub przeciągnij i upuść tutaj', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index caba5c45cf8a..89005bfca02c 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1079,6 +1079,7 @@ const translations: TranslationDeepObject = { deleteConfirmation: 'Tem certeza de que deseja excluir este recibo?', addReceipt: 'Adicionar recibo', scanFailed: 'O recibo não pôde ser digitalizado porque está faltando o comerciante, a data ou o valor.', + crop: 'Cortar', addAReceipt: { phrase1: 'Adicionar um recibo', phrase2: 'ou arraste e solte um aqui', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index ebb52f8a4baf..a684bc7ea822 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1060,6 +1060,7 @@ const translations: TranslationDeepObject = { deleteConfirmation: '确定要删除这张收据吗?', addReceipt: '添加收据', scanFailed: '无法扫描此收据,因为缺少商家、日期或金额。', + crop: '裁剪', addAReceipt: { phrase1: '添加收据', phrase2: '或将文件拖放到此处', diff --git a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx index 5ea9b770d2ca..72c272305de0 100644 --- a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx +++ b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx @@ -67,6 +67,9 @@ function AttachmentModalBaseContent({ onCarouselAttachmentChange = () => {}, transaction: transactionProp, shouldCloseOnSwipeDown = false, + footerActionButtons, + customAttachmentContent, + attachmentViewContainerStyles, }: AttachmentModalBaseContentProps) { const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -326,7 +329,7 @@ function AttachmentModalBaseContent({ shouldOverlayDots subTitleLink={currentAttachmentLink ?? ''} /> - + {isLoading && } {shouldShowNotFoundPage && !isLoading && ( )} - {shouldDisplayContent && Content} + {shouldDisplayContent && (customAttachmentContent ?? Content)} + {!!footerActionButtons && ( + + + {footerActionButtons} + + + )} {/* If we have an onConfirm method show a confirmation button */} {!!onConfirm && !isConfirmButtonDisabled && ( diff --git a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts index 15d2c2bddf21..94d737d12ce9 100644 --- a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts +++ b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts @@ -1,5 +1,5 @@ import type {RefObject} from 'react'; -import type {View} from 'react-native'; +import type {StyleProp, View, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type {Attachment} from '@components/Attachments/types'; @@ -133,6 +133,15 @@ type AttachmentModalBaseContentProps = { /** Allows users to swipe down to close the modal */ shouldCloseOnSwipeDown?: boolean; + + /** Footer action buttons to display below the image */ + footerActionButtons?: React.ReactNode; + + /** Custom content to render instead of the default attachment view (e.g., crop view) */ + customAttachmentContent?: React.ReactNode; + + /** Extra styles to pass for the attachment view container */ + attachmentViewContainerStyles?: StyleProp; }; export type { diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx index 72eaf7b7d772..e74c61f6c441 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -1,12 +1,17 @@ import {Str} from 'expensify-common'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {View} from 'react-native'; +import Button from '@components/Button'; import ConfirmModal from '@components/ConfirmModal'; +import ReceiptCropView from '@components/ReceiptCropView'; +import type {CropRect} from '@components/ReceiptCropView'; import useAllTransactions from '@hooks/useAllTransactions'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; import {detachReceipt, navigateToStartStepIfScanFileCannotBeRead, removeMoneyRequestOdometerImage, replaceReceipt, setMoneyRequestReceipt} from '@libs/actions/IOU'; import {openReport} from '@libs/actions/Report'; import cropOrRotateImage from '@libs/cropOrRotateImage'; @@ -32,10 +37,9 @@ import useDownloadAttachment from './hooks/useDownloadAttachment'; function TransactionReceiptModalContent({navigation, route}: AttachmentModalScreenProps) { const {reportID, transactionID, action, iouType: iouTypeParam, readonly: readonlyParam, mergeTransactionID, imageType} = route.params; - const icons = useMemoizedLazyExpensifyIcons(['Download', 'Trashcan'] as const); const {translate} = useLocalize(); const {isOffline} = useNetwork(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Camera']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Camera', 'Download', 'Crop', 'Trashcan', 'Rotate', 'Close', 'Checkmark']); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const allTransactions = useAllTransactions(); @@ -134,6 +138,10 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre const [isDeleteReceiptConfirmModalVisible, setIsDeleteReceiptConfirmModalVisible] = useState(false); const [isRotating, setIsRotating] = useState(false); + const [isCropping, setIsCropping] = useState(false); + const [isCropSaving, setIsCropSaving] = useState(false); + const [cropRect, setCropRect] = useState(null); + const styles = useThemeStyles(); useEffect(() => { if ((!!report && !!transaction) || isDraftTransaction) { @@ -243,9 +251,6 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre const allowDownload = !isEReceipt; - /** - * Rotate the receipt image 90 degrees and save it automatically. - */ const rotateReceipt = useCallback(() => { if (!transaction?.transactionID || !sourceUri || !isImage) { return; @@ -265,7 +270,6 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre return; } - // Both web and native return objects with uri property const imageUriResult = 'uri' in rotatedImage && rotatedImage.uri ? rotatedImage.uri : undefined; if (!imageUriResult) { setIsRotating(false); @@ -276,7 +280,6 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre const rotatedFilename = file.name ?? receiptFilename; if (isDraftTransaction) { - // Update the transaction immediately so the modal displays the rotated image right away setMoneyRequestReceipt(transaction.transactionID, imageUriResult, rotatedFilename, isDraftTransaction, receiptType); } else { replaceReceipt({ @@ -294,7 +297,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre }); }, [transaction?.transactionID, isDraftTransaction, sourceUri, isImage, receiptFilename, policyCategories, transaction?.receipt?.type, policy]); - const shouldShowRotateReceiptButton = useMemo( + const shouldShowRotateAndCropReceiptButton = useMemo( () => shouldShowReplaceReceiptButton && transaction && @@ -305,35 +308,87 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre [shouldShowReplaceReceiptButton, transaction, isEReceipt, receiptFilename], ); + const enterCropMode = useCallback(() => { + setIsCropping(true); + setCropRect(null); + }, []); + + const exitCropMode = useCallback(() => { + setIsCropping(false); + setCropRect(null); + }, []); + + const handleCropChange = useCallback((crop: CropRect) => { + setCropRect(crop); + }, []); + + const saveCrop = useCallback(() => { + if (!transaction?.transactionID || !sourceUri || !isImage || !cropRect) { + return; + } + + const receiptType = transaction?.receipt?.type ?? CONST.IMAGE_FILE_FORMAT.JPEG; + + setIsCropSaving(true); + cropOrRotateImage( + sourceUri as string, + [ + { + crop: { + originX: cropRect.x, + originY: cropRect.y, + width: cropRect.width, + height: cropRect.height, + }, + }, + ], + { + compress: 1, + name: receiptFilename, + type: receiptType, + }, + ) + .then((croppedImage) => { + if (!croppedImage) { + setIsCropSaving(false); + return; + } + + const imageUriResult = 'uri' in croppedImage && croppedImage.uri ? croppedImage.uri : undefined; + if (!imageUriResult) { + setIsCropSaving(false); + return; + } + + const file = croppedImage as File; + const croppedFilename = file.name ?? receiptFilename; + + if (isDraftTransaction) { + setMoneyRequestReceipt(transaction.transactionID, imageUriResult, croppedFilename, isDraftTransaction, receiptType); + } else { + replaceReceipt({ + transactionID: transaction.transactionID, + file, + source: imageUriResult, + transactionPolicyCategories: policyCategories, + transactionPolicy: policy, + }); + } + setIsCropSaving(false); + setIsCropping(false); + setCropRect(null); + }) + .catch(() => { + setIsCropSaving(false); + }); + }, [transaction?.transactionID, isDraftTransaction, sourceUri, isImage, cropRect, receiptFilename, policyCategories, transaction?.receipt?.type, policy]); + const threeDotsMenuItems: ThreeDotsMenuItemFactory = useCallback( ({file, source: innerSource, isLocalSource}) => { const menuItems = []; - if (shouldShowReplaceReceiptButton || isOdometerImage) { - menuItems.push({ - icon: expensifyIcons.Camera, - text: translate('common.replace'), - onSelected: () => { - Navigation.dismissModal({ - callback: () => - Navigation.navigate( - isOdometerImage - ? ROUTES.ODOMETER_IMAGE.getRoute(action ?? CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, imageType) - : ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute( - action ?? CONST.IOU.ACTION.EDIT, - iouType, - draftTransactionID ?? transaction?.transactionID, - report?.reportID, - Navigation.getActiveRoute(), - ), - ), - }); - }, - sentryLabel: CONST.SENTRY_LABEL.RECEIPT_MODAL.REPLACE_RECEIPT, - }); - } if ((!isOffline && allowDownload && !isLocalSource) || !!draftTransactionID) { menuItems.push({ - icon: icons.Download, + icon: expensifyIcons.Download, text: translate('common.download'), onSelected: () => onDownloadAttachment({source: innerSource, file}), sentryLabel: CONST.SENTRY_LABEL.RECEIPT_MODAL.DOWNLOAD_RECEIPT, @@ -350,7 +405,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre const isDraftOdometer = isOdometerImage && isDraftTransaction; if (isDeletableReceipt || isDraftOdometer) { menuItems.push({ - icon: icons.Trashcan, + icon: expensifyIcons.Trashcan, text: isOdometerImage ? translate('distance.odometer.deleteOdometerPhoto') : translate('receipt.deleteReceipt'), onSelected: () => setIsDeleteReceiptConfirmModalVisible?.(true), shouldCallAfterModalHide: true, @@ -360,7 +415,6 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre return menuItems; }, [ - shouldShowReplaceReceiptButton, isOdometerImage, isOffline, allowDownload, @@ -369,16 +423,8 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre shouldShowDeleteReceiptButton, transactionReport, isDraftTransaction, - expensifyIcons.Camera, translate, - action, - iouType, - transactionID, - reportID, - imageType, - report?.reportID, - icons.Download, - icons.Trashcan, + expensifyIcons, onDownloadAttachment, ], ); @@ -406,6 +452,126 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre [deleteReceiptAndClose, deleteOdometerImageAndClose, isDeleteReceiptConfirmModalVisible, isOdometerImage, translate], ); + const footerActionButtons = useMemo(() => { + if (isCropping) { + return ( + +