From dfe8df9b944b11f8e5d2c9de9eb0e1ecd73bbb21 Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Tue, 8 Sep 2026 10:56:43 +0200 Subject: [PATCH 1/2] =?UTF-8?q?refactor(m11-04):=20d=C3=A9couper=20les=20?= =?UTF-8?q?=C3=A9crans=20de=20scan=20cam=C3=A9ra=20&=20code-barres=20(M-11?= =?UTF-8?q?-#184)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/scan/barcode.tsx | 560 ++---------- src/app/scan/camera.tsx | 841 ++---------------- src/components/__tests__/ocr-analysis.test.ts | 51 ++ .../scan/barcode-overlay-controls.tsx | 100 +++ .../scan/barcode-pending-sheets.tsx | 136 +++ src/components/scan/barcode-styles.ts | 211 +++++ src/components/scan/camera-ocr-styles.ts | 262 ++++++ src/components/scan/ocr-analysis.ts | 73 ++ src/components/scan/ocr-analyzing-overlay.tsx | 144 +++ src/components/scan/ocr-confirm-overlay.tsx | 104 +++ src/components/scan/ocr-result-overlay.tsx | 94 ++ src/components/scan/use-barcode-scanning.ts | 144 +++ src/components/scan/use-ocr-analysis.ts | 204 +++++ 13 files changed, 1644 insertions(+), 1280 deletions(-) create mode 100644 src/components/__tests__/ocr-analysis.test.ts create mode 100644 src/components/scan/barcode-overlay-controls.tsx create mode 100644 src/components/scan/barcode-pending-sheets.tsx create mode 100644 src/components/scan/barcode-styles.ts create mode 100644 src/components/scan/camera-ocr-styles.ts create mode 100644 src/components/scan/ocr-analysis.ts create mode 100644 src/components/scan/ocr-analyzing-overlay.tsx create mode 100644 src/components/scan/ocr-confirm-overlay.tsx create mode 100644 src/components/scan/ocr-result-overlay.tsx create mode 100644 src/components/scan/use-barcode-scanning.ts create mode 100644 src/components/scan/use-ocr-analysis.ts diff --git a/src/app/scan/barcode.tsx b/src/app/scan/barcode.tsx index 6b50b7a..620fb52 100644 --- a/src/app/scan/barcode.tsx +++ b/src/app/scan/barcode.tsx @@ -1,140 +1,38 @@ -import { CameraView, useCameraPermissions } from 'expo-camera'; -import { useLocalSearchParams, useRouter } from 'expo-router'; -import { useRef, useState } from 'react'; -import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; -import { Feather } from '@expo/vector-icons'; +import { CameraView } from 'expo-camera'; +import { useRouter } from 'expo-router'; +import { View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { CameraPermissionScreen } from '@/components/camera-permission-screen'; -import { Spacing, type ThemeColors } from '@/constants/theme'; -import { getDeps } from '@/dependencies'; import { useThemeColors } from '@/hooks/use-theme'; -import { BarcodeStabilizer } from '@/identification/barcodeStabilizer'; -import { useCollectionStore } from '@/store/use-collection-store'; -import type { Magazine } from '@/types'; - -type ScanState = - { status: 'idle' } | { status: 'searching' } | { status: 'invalid'; reason: string }; - -type Pending = - | { kind: 'confirm'; magazine: Magazine; ownedCount: number } - | { kind: 'success'; publication: string; issueNumber: number | null } - | { kind: 'unknown'; barcode: string }; +import { BarcodeOverlayControls } from '@/components/scan/barcode-overlay-controls'; +import { + BarcodeContinuousBar, + BarcodePendingSheets, +} from '@/components/scan/barcode-pending-sheets'; +import { makeBarcodeStyles } from '@/components/scan/barcode-styles'; +import { useBarcodeScanning } from '@/components/scan/use-barcode-scanning'; export default function BarcodeScreen() { const router = useRouter(); const colors = useThemeColors(); const insets = useSafeAreaInsets(); - const styles = makeStyles(colors, insets); - - const addExistingCopy = useCollectionStore((s) => s.addExistingCopy); - const params = useLocalSearchParams<{ continuous?: string }>(); - - const [permission, requestPermission] = useCameraPermissions(); - const [state, setState] = useState({ status: 'idle' }); - const [scanning, setScanning] = useState(true); - const [continuous, setContinuous] = useState(params.continuous === '1'); - const [torchOn, setTorchOn] = useState(false); - const [pending, setPending] = useState(null); - const stabilizer = useRef(new BarcodeStabilizer(3)); - - const resume = () => { - stabilizer.current.reset(); - setPending(null); - setScanning(true); - setState({ status: 'idle' }); - }; - - const handleSingle = async (stabilized: string) => { - const { identificationService } = getDeps(); - const result = await identificationService.identifyByBarcode(stabilized); - - if (result.status === 'found') { - router.replace({ - pathname: '/scan/result', - params: { - id: result.magazine.id, - publication: result.magazine.publication, - issueNumber: - result.magazine.issueNumber != null ? String(result.magazine.issueNumber) : '', - barcode: stabilized, - }, - }); - } else if (result.status === 'ambiguous') { - router.replace({ pathname: '/scan/multiple', params: { barcode: stabilized } }); - } else if (result.status === 'unknown') { - router.replace({ pathname: '/scan/result', params: { barcode: stabilized } }); - } else { - setState({ status: 'invalid', reason: result.reason }); - setScanning(true); - } - }; - - const handleContinuous = async (stabilized: string) => { - const { identificationService, collectionRepository } = getDeps(); - const result = await identificationService.identifyByBarcode(stabilized); - - if (result.status === 'found') { - const ownedCount = await collectionRepository.countByMagazine(result.magazine.id); - if (ownedCount > 0) { - setPending({ kind: 'confirm', magazine: result.magazine, ownedCount }); - } else { - await addExistingCopy(result.magazine.id); - setPending({ - kind: 'success', - publication: result.magazine.publication, - issueNumber: result.magazine.issueNumber, - }); - } - } else if (result.status === 'ambiguous') { - router.replace({ pathname: '/scan/multiple', params: { barcode: stabilized } }); - } else if (result.status === 'unknown') { - setPending({ kind: 'unknown', barcode: stabilized }); - } else { - setState({ status: 'invalid', reason: result.reason }); - setScanning(true); - } - }; - - const handleScan = async ({ data }: { data: string; type: string }) => { - if (!scanning || state.status === 'searching' || pending) { - return; - } - const stabilized = stabilizer.current.push(data); - if (stabilized === null) { - return; - } - setScanning(false); - setState({ status: 'searching' }); - - if (continuous) { - await handleContinuous(stabilized); - } else { - await handleSingle(stabilized); - } - }; - - const reset = () => { - stabilizer.current.reset(); - setScanning(true); - setState({ status: 'idle' }); - }; - - const confirmAdd = async () => { - if (pending?.kind !== 'confirm') { - return; - } - await addExistingCopy(pending.magazine.id); - setPending({ - kind: 'success', - publication: pending.magazine.publication, - issueNumber: pending.magazine.issueNumber, - }); - }; - - const confirm = pending?.kind === 'confirm' ? pending : null; - const success = pending?.kind === 'success' ? pending : null; - const unknown = pending?.kind === 'unknown' ? pending : null; + const styles = makeBarcodeStyles(colors, insets); + + const { + permission, + requestPermission, + state, + continuous, + torchOn, + pending, + setContinuous, + setTorchOn, + handleScan, + resume, + reset, + confirmAdd, + } = useBarcodeScanning(); if (!permission || !permission.granted) { return ( @@ -162,390 +60,36 @@ export default function BarcodeScreen() { /> {continuous && !pending && ( - - Scan en continu — ajoute chaque exemplaire - [styles.continuousStop, pressed && styles.buttonPressed]} - onPress={() => { - setContinuous(false); - resume(); - }} - testID="continuous-stop" - accessibilityRole="button" - accessibilityLabel="Arrêter le scan en continu"> - Arrêter - - + { + setContinuous(false); + resume(); + }} + /> )} - - - - {state.status === 'searching' ? ( - - ) : null} - - {state.status === 'searching' ? 'Recherche…' : 'Alignez le code-barres dans le cadre'} - - - {state.status === 'invalid' && ( - - {state.reason} - - )} - - - {state.status === 'idle' && !pending && !continuous && ( - [styles.startContinuousButton, pressed && styles.buttonPressed]} - onPress={() => setContinuous(true)} - testID="continuous-start" - accessibilityRole="button" - accessibilityLabel="Lancer le scan en continu"> - Scan en continu - - )} - - {state.status === 'idle' && !pending && ( - [styles.backButton, pressed && styles.buttonPressed]} - onPress={() => router.back()} - testID="scan-back" - accessibilityRole="button" - accessibilityLabel="Annuler"> - - - )} - - {state.status === 'idle' && !pending && ( - [styles.torchButton, pressed && styles.buttonPressed]} - onPress={() => setTorchOn((t) => !t)} - testID="scan-torch" - accessibilityRole="button" - accessibilityLabel={torchOn ? 'Désactiver la torche' : 'Activer la torche'}> - - - )} - - {state.status === 'invalid' && ( - - [styles.primaryButton, pressed && styles.buttonPressed]} - onPress={reset} - testID="invalid-retry" - accessibilityRole="button"> - Scanner à nouveau - - - )} - - {confirm && ( - - - - [styles.sheetClose, pressed && styles.buttonPressed]} - onPress={resume} - testID="pending-close" - accessibilityRole="button" - accessibilityLabel="Fermer"> - - - Vous possédez déjà ce magazine - - {confirm.magazine.publication} - {confirm.magazine.issueNumber != null ? ` n° ${confirm.magazine.issueNumber}` : ''} - - - Exemplaires actuels : {confirm.ownedCount}. Ajouter un exemplaire ? - - [styles.primaryButton, pressed && styles.buttonPressed]} - onPress={confirmAdd} - testID="pending-confirm-add" - accessibilityRole="button"> - Ajouter un exemplaire - - [styles.pendingCancel, pressed && styles.buttonPressed]} - onPress={resume} - testID="pending-confirm-cancel" - accessibilityRole="button"> - Annuler - - - - - )} - - {success && ( - - - - Ajouté à la collection - - {success.publication} - {success.issueNumber != null ? ` n° ${success.issueNumber}` : ''} - - [styles.primaryButton, pressed && styles.buttonPressed]} - onPress={resume} - testID="pending-success-ok" - accessibilityRole="button"> - Scanner le suivant - - - - - )} + setContinuous(true)} + onBack={() => router.back()} + onToggleTorch={() => setTorchOn((t) => !t)} + onRetry={reset} + /> - {unknown && ( - - - - Code-barres inconnu - {unknown.barcode} - - Le scan seul ne crée pas l'édition. Saisissez-la manuellement. - - [styles.primaryButton, pressed && styles.buttonPressed]} - onPress={() => - router.replace({ pathname: '/scan/manual', params: { barcode: unknown.barcode } }) - } - testID="pending-unknown-manual" - accessibilityRole="button"> - Saisir manuellement - - [styles.pendingCancel, pressed && styles.buttonPressed]} - onPress={resume} - testID="pending-unknown-continue" - accessibilityRole="button"> - Continuer le scan - - - - + {pending && ( + router.replace({ pathname: '/scan/manual', params: { barcode } })} + /> )} ); } - -function makeStyles(colors: ThemeColors, insets: { top: number; bottom: number }) { - return StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background, - }, - overlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - alignItems: 'center', - justifyContent: 'center', - }, - camera: { - flex: 1, - }, - reticle: { - width: 220, - height: 220, - borderWidth: 3, - borderColor: colors.accent, - borderRadius: 16, - backgroundColor: 'transparent', - }, - hintRow: { - flexDirection: 'row', - alignItems: 'center', - gap: Spacing.two, - marginTop: Spacing.three, - backgroundColor: 'rgba(0,0,0,0.55)', - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - borderRadius: 8, - }, - scanHint: { - fontSize: 14, - lineHeight: 20, - color: '#FFFFFF', - textAlign: 'center', - }, - invalidText: { - marginTop: Spacing.two, - fontSize: 14, - color: '#FFD5D2', - textAlign: 'center', - backgroundColor: 'rgba(0,0,0,0.55)', - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - borderRadius: 8, - overflow: 'hidden', - }, - invalidActions: { - position: 'absolute', - left: Spacing.four, - right: Spacing.four, - bottom: insets.bottom + 24, - }, - continuousBar: { - position: 'absolute', - top: insets.top + 12, - left: Spacing.three, - right: Spacing.three, - flexDirection: 'row', - alignItems: 'center', - backgroundColor: 'rgba(0,0,0,0.7)', - borderRadius: 10, - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - }, - continuousText: { - flex: 1, - fontSize: 13, - color: '#FFFFFF', - }, - continuousStop: { - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - borderRadius: 8, - backgroundColor: colors.danger, - }, - continuousStopText: { - fontSize: 13, - fontWeight: '700', - color: colors.onDanger, - }, - startContinuousButton: { - position: 'absolute', - left: Spacing.four, - right: Spacing.four, - bottom: insets.bottom + 80, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'rgba(0,0,0,0.65)', - paddingVertical: Spacing.two, - borderRadius: 10, - }, - startContinuousText: { - fontSize: 15, - fontWeight: '600', - color: '#FFFFFF', - }, - pendingCard: { - marginHorizontal: Spacing.four, - backgroundColor: colors.backgroundElement, - borderRadius: 14, - padding: Spacing.four, - gap: Spacing.two, - alignItems: 'center', - }, - sheetClose: { - alignSelf: 'flex-end', - minWidth: 44, - minHeight: 44, - alignItems: 'center', - justifyContent: 'center', - marginTop: -Spacing.three, - marginRight: -Spacing.three, - }, - backdrop: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - justifyContent: 'center', - backgroundColor: 'rgba(0,0,0,0.5)', - }, - sheetScroll: { - flexGrow: 1, - justifyContent: 'center', - }, - pendingTitle: { - fontSize: 18, - fontWeight: '700', - color: colors.text, - textAlign: 'center', - }, - pendingMagazine: { - fontSize: 17, - fontWeight: '600', - color: colors.accentTextOnLight, - textAlign: 'center', - }, - pendingMessage: { - fontSize: 14, - color: colors.textSecondary, - textAlign: 'center', - lineHeight: 20, - }, - pendingCancel: { - minHeight: 44, - justifyContent: 'center', - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - }, - pendingCancelText: { - fontSize: 16, - color: colors.textSecondary, - textAlign: 'center', - }, - primaryButton: { - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.accent, - minHeight: 48, - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - borderRadius: 12, - alignSelf: 'stretch', - }, - primaryButtonText: { - fontSize: 16, - fontWeight: '700', - color: colors.accentText, - textAlign: 'center', - }, - buttonPressed: { - opacity: 0.8, - }, - backButton: { - position: 'absolute', - top: insets.top + 12, - left: Spacing.three, - width: 44, - height: 44, - borderRadius: 22, - backgroundColor: 'rgba(0,0,0,0.55)', - alignItems: 'center', - justifyContent: 'center', - }, - torchButton: { - position: 'absolute', - top: insets.top + 12, - right: Spacing.three, - width: 44, - height: 44, - borderRadius: 22, - backgroundColor: 'rgba(0,0,0,0.55)', - alignItems: 'center', - justifyContent: 'center', - }, - }); -} diff --git a/src/app/scan/camera.tsx b/src/app/scan/camera.tsx index 0af6e55..9b2de08 100644 --- a/src/app/scan/camera.tsx +++ b/src/app/scan/camera.tsx @@ -1,255 +1,37 @@ -import { CameraView, useCameraPermissions } from 'expo-camera'; +import { CameraView } from 'expo-camera'; import { useRouter } from 'expo-router'; -import { useEffect, useRef, useState } from 'react'; -import { - ActivityIndicator, - Pressable, - ScrollView, - StyleSheet, - Text, - TextInput, - View, -} from 'react-native'; -import { Feather } from '@expo/vector-icons'; +import { View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import type { CameraView as CameraViewType } from 'expo-camera'; import { CameraPermissionScreen } from '@/components/camera-permission-screen'; -import { Spacing, type ThemeColors } from '@/constants/theme'; -import { getDeps } from '@/dependencies'; import { useThemeColors } from '@/hooks/use-theme'; -import { OcrTextStabilizer } from '@/identification/ocr/ocrTextStabilizer'; -import { MIN_CONFIDENCE } from '@/identification/ocr/ocrTextParser'; - -/** Intervalle d'analyse OCR : quelques frames / seconde max (pas toutes). */ -const ANALYSIS_INTERVAL_MS = 500; - -/** - * Lectures OCR identiques requises avant de conclure une identification - * (M10R2-09 — vote multi-frames conservateur, une lecture isolée pouvant être - * erronée sur des textes stylisés / à encres faibles). - */ -const OCR_STABLE_READS = 2; - -/** - * Informations détectées par l'OCR, affichées en surcouche caméra (US-ID-08) - * et proposées à la validation / correction (US-ID-09). - */ -type DetectedInfo = { - publication: string | null; - issueNumber: number | null; - date: string | null; -}; - -const EMPTY_DETECTED: DetectedInfo = { publication: null, issueNumber: null, date: null }; - -type OcrUiState = - | { status: 'analyzing'; detected: DetectedInfo } - | { status: 'confirm'; detected: DetectedInfo } - | { - status: 'found'; - id: string; - publication: string; - issueNumber: number | null; - date: string | null; - confidence: number; - } - | { - status: 'unknown'; - publication: string; - issueNumber: number | null; - date: string | null; - confidence: number; - }; - -function hasAnyDetected(detected: DetectedInfo): boolean { - return detected.publication !== null || detected.issueNumber !== null || detected.date !== null; -} +import { OcrAnalyzingOverlay } from '@/components/scan/ocr-analyzing-overlay'; +import { OcrConfirmOverlay } from '@/components/scan/ocr-confirm-overlay'; +import { OcrResultOverlay } from '@/components/scan/ocr-result-overlay'; +import { makeCameraOcrStyles } from '@/components/scan/camera-ocr-styles'; +import { useOcrAnalysis } from '@/components/scan/use-ocr-analysis'; export default function CameraOcrScreen() { const router = useRouter(); const colors = useThemeColors(); const insets = useSafeAreaInsets(); - const styles = makeStyles(colors, insets); - - const [permission, requestPermission] = useCameraPermissions(); - const [state, setState] = useState({ - status: 'analyzing', - detected: EMPTY_DETECTED, - }); - const [draft, setDraft] = useState(EMPTY_DETECTED); - const [torchOn, setTorchOn] = useState(false); - const [weakCycles, setWeakCycles] = useState(0); - const inFlight = useRef(false); - const cameraRef = useRef(null); - const ocrStabilizer = useRef(new OcrTextStabilizer(OCR_STABLE_READS)); - - useEffect(() => { - if (!permission?.granted) { - return; - } - if (state.status !== 'analyzing') { - return; - } - - const { ocrEngine, identificationService } = getDeps(); - const interval = setInterval(async () => { - if (inFlight.current) { - return; - } - inFlight.current = true; - try { - // Capture éphémère d'une photo (aucune image persistée) → URI. - // Haute résolution (M10R2-09) : préserve les petites encres des textes stylisés. - const photo = await cameraRef.current?.takePictureAsync?.({ - quality: 1, - skipProcessing: false, - }); - const uri = photo?.uri ?? null; - const frame = await ocrEngine.recognize({ native: uri, width: 0, height: 0 }); - if (!frame) { - return; - } - const result = await identificationService.identifyByOCR(frame.text); - - if (result.status === 'no-text') { - return; - } - - if (result.status === 'weak') { - // US-ID-08 : on ne conclut plus en échec dès la première lecture partielle. - // On met en surcouche les champs détectés et on continue d'analyser - // (le pointeur guide l'utilisateur vers le champ manquant). - setWeakCycles((c) => c + 1); - setState((prev) => - prev.status === 'analyzing' - ? { - status: 'analyzing', - detected: { - publication: - result.publication === 'Publication inconnue' ? null : result.publication, - issueNumber: result.issueNumber, - date: result.date, - }, - } - : prev, - ); - return; - } - - // Vote multi-frames (M10R2-09) : on ne conclut pas sur une lecture - // isolée, une frame suivante identique est requise (textes stylisés). - const key = [ - result.publication ?? '', - result.issueNumber != null ? String(result.issueNumber) : '', - result.date ?? '', - ].join('|'); - if (!ocrStabilizer.current.push(key)) { - return; - } - ocrStabilizer.current.reset(); - - if (result.status === 'unknown') { - setState({ - status: 'unknown', - publication: result.publication, - issueNumber: result.issueNumber, - date: result.date, - confidence: result.confidence, - }); - return; - } - - setState({ - status: 'found', - id: result.magazine.id, - publication: result.publication, - issueNumber: result.issueNumber, - date: result.date, - confidence: result.confidence, - }); - } finally { - inFlight.current = false; - } - }, ANALYSIS_INTERVAL_MS); - - return () => clearInterval(interval); - }, [permission?.granted, state.status]); - - const stopAndRetry = () => { - ocrStabilizer.current.reset(); - setWeakCycles(0); - setState({ status: 'analyzing', detected: EMPTY_DETECTED }); - }; - - const openConfirm = () => { - const detected = - state.status === 'analyzing' || state.status === 'confirm' ? state.detected : EMPTY_DETECTED; - setDraft(detected); - setState({ status: 'confirm', detected }); - }; - - const buildManualParams = (detected: Partial): Record => { - const params: Record = {}; - if (detected.publication) { - params.publication = detected.publication; - } - if (detected.issueNumber != null) { - params.issueNumber = String(detected.issueNumber); - } - if (detected.date) { - params.year = detected.date; - } - return params; - }; - - const goManual = (detected: Partial) => { - router.replace({ pathname: '/scan/manual', params: buildManualParams(detected) }); - }; - - const goBarcode = () => { - router.replace('/scan/barcode'); - }; - - /** US-ID-09 : recherche en outrepassant la confiance (champs validés/corrigés). */ - const searchFromDraft = async () => { - const publication = draft.publication?.trim() ?? ''; - const rawNumber = draft.issueNumber?.toString().trim() ?? ''; - const issueNumber = rawNumber ? Number(rawNumber) : null; - const date = draft.date?.trim() || null; - - if (!publication || issueNumber === null || !Number.isFinite(issueNumber)) { - // Impossible de rechercher : on oriente vers la saisie manuelle pré-remplie. - goManual({ publication: publication || undefined, issueNumber, date }); - return; - } - - const { identificationService } = getDeps(); - const result = await identificationService.searchByOcrFields(publication, issueNumber, date); - - if (result.status === 'weak' || result.status === 'no-text') { - goManual({ publication, issueNumber, date }); - return; - } - if (result.status === 'unknown') { - setState({ - status: 'unknown', - publication: result.publication, - issueNumber: result.issueNumber, - date: result.date, - confidence: result.confidence, - }); - return; - } - setState({ - status: 'found', - id: result.magazine.id, - publication: result.publication, - issueNumber: result.issueNumber, - date: result.date, - confidence: result.confidence, - }); - }; + const styles = makeCameraOcrStyles(colors, insets); + const { + cameraRef, + permission, + requestPermission, + state, + draft, + torchOn, + weakCycles, + setTorchOn, + setDraft, + stopAndRetry, + openConfirm, + goManual, + goBarcode, + searchFromDraft, + } = useOcrAnalysis(); if (!permission || !permission.granted) { return ( @@ -286,546 +68,61 @@ export default function CameraOcrScreen() { /> {isAnalyzing && ( - <> - - - - {hint} - - - - Lecture… - - - {/* Surcouche US-ID-08 : champs détectés en direct. */} - - - Nom - - {state.detected.publication ?? '…'} - - - - Numéro - - {state.detected.issueNumber?.toString() ?? '…'} - - - - Édition / date - - {state.detected.date ?? '…'} - - - - - {hasAnyDetected(state.detected) && ( - [styles.primaryButton, pressed && styles.buttonPressed]} - onPress={openConfirm} - testID="ocr-confirm-detected" - accessibilityRole="button"> - Valider ces informations détectées - - )} - - - [styles.secondaryButton, pressed && styles.buttonPressed]} - onPress={goBarcode} - testID="ocr-barcode" - accessibilityRole="button"> - Scanner le code-barres - - - [styles.backButton, pressed && styles.buttonPressed]} - onPress={() => router.back()} - testID="ocr-back" - accessibilityRole="button" - accessibilityLabel="Annuler"> - - - - [styles.torchButton, pressed && styles.buttonPressed]} - onPress={() => setTorchOn((t) => !t)} - testID="ocr-torch" - accessibilityRole="button" - accessibilityLabel={torchOn ? 'Désactiver la torche' : 'Activer la torche'}> - - - - {weakCycles > 3 && detected?.publication && detected?.issueNumber === null && ( - - - - Le texte stylisé est difficile à lire automatiquement. - - - [styles.guidanceButton, pressed && styles.buttonPressed]} - onPress={goBarcode} - testID="ocr-guidance-barcode" - accessibilityRole="button"> - Code-barres - - [styles.guidanceButton, pressed && styles.buttonPressed]} - onPress={() => goManual(detected)} - testID="ocr-guidance-manual" - accessibilityRole="button"> - Saisie manuelle - - - - )} - + router.back()} + onToggleTorch={() => setTorchOn((t) => !t)} + /> )} {isConfirming && ( - - - Vérifier les informations - - Corrigez les informations détectées puis validez la recherche, même si la confiance - était insuffisante. - - - - Nom - setDraft((d) => ({ ...d, publication: t }))} - placeholder="Publication du magazine" - placeholderTextColor={colors.textSecondary} - testID="ocr-override-publication" - /> - - - - Numéro - { - const digits = t.replace(/[^0-9]/g, ''); - setDraft((d) => ({ ...d, issueNumber: digits ? Number(digits) : null })); - }} - placeholder="N° du magazine" - keyboardType="number-pad" - returnKeyType="done" - placeholderTextColor={colors.textSecondary} - testID="ocr-override-issue" - accessibilityLabel="Numéro du magazine (chiffres uniquement)" - /> - - - - Année - setDraft((d) => ({ ...d, date: t }))} - placeholder="Année / date (optionnel)" - keyboardType="default" - placeholderTextColor={colors.textSecondary} - testID="ocr-override-date" - /> - - - [styles.primaryButton, pressed && styles.buttonPressed]} - onPress={searchFromDraft} - testID="ocr-override-search" - accessibilityRole="button"> - Rechercher - - - [styles.secondaryButton, pressed && styles.buttonPressed]} - onPress={() => goManual(draft)} - testID="ocr-override-manual" - accessibilityRole="button"> - Saisir manuellement - - - [styles.cancelButton, pressed && styles.buttonPressed]} - onPress={stopAndRetry} - testID="ocr-override-back" - accessibilityRole="button"> - Retour à la caméra - - - + goManual(draft)} + onBack={stopAndRetry} + /> )} {state.status === 'found' && ( - - - Couverture reconnue - - {state.publication} - - {state.issueNumber != null && N° {state.issueNumber}} - {state.date && {state.date}} - - Confiance : {confidenceLabel(state.confidence)} - - [styles.primaryButton, pressed && styles.buttonPressed]} - onPress={() => router.replace(`/collection/${state.id}`)} - testID="ocr-confirm" - accessibilityRole="button"> - Confirmer - - [styles.secondaryButton, pressed && styles.buttonPressed]} - onPress={stopAndRetry} - testID="ocr-retry" - accessibilityRole="button"> - Réessayer - - [styles.secondaryButton, pressed && styles.buttonPressed]} - onPress={() => - goManual({ - publication: state.publication, - issueNumber: state.issueNumber, - date: state.date, - }) - } - testID="ocr-manual" - accessibilityRole="button"> - Saisie manuelle - - - + router.replace(`/collection/${state.id}`)} + onRetry={stopAndRetry} + onGoManual={() => + goManual({ + publication: state.publication, + issueNumber: state.issueNumber, + date: state.date, + }) + } + /> )} {state.status === 'unknown' && ( - - - Non trouvé en collection - {state.publication} - {state.issueNumber != null && N° {state.issueNumber}} - - {state.publication} n'est pas encore référencé. Vous pouvez le saisir - manuellement pour le créer. - - [styles.primaryButton, pressed && styles.buttonPressed]} - onPress={() => - goManual({ - publication: state.publication, - issueNumber: state.issueNumber, - date: state.date, - }) - } - testID="ocr-manual" - accessibilityRole="button"> - Saisir manuellement - - [styles.secondaryButton, pressed && styles.buttonPressed]} - onPress={stopAndRetry} - testID="ocr-retry" - accessibilityRole="button"> - Réessayer avec la caméra - - - + + goManual({ + publication: state.publication, + issueNumber: state.issueNumber, + date: state.date, + }) + } + /> )} ); } - -function confidenceLabel(confidence: number): string { - if (confidence >= 0.8) { - return 'élevée'; - } - if (confidence >= MIN_CONFIDENCE) { - return 'moyenne'; - } - return 'faible'; -} - -function makeStyles(colors: ThemeColors, insets: { top: number; bottom: number }) { - return StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background, - }, - overlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: Spacing.four, - }, - camera: { - flex: 1, - }, - reticle: { - width: 220, - height: 220, - borderWidth: 3, - borderColor: colors.accent, - borderRadius: 16, - backgroundColor: 'transparent', - }, - scanHint: { - marginTop: Spacing.three, - fontSize: 14, - color: '#FFFFFF', - textAlign: 'center', - backgroundColor: 'rgba(0,0,0,0.55)', - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - borderRadius: 8, - overflow: 'hidden', - }, - processingPill: { - marginTop: Spacing.two, - flexDirection: 'row', - alignItems: 'center', - gap: Spacing.two, - backgroundColor: 'rgba(0,0,0,0.55)', - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - borderRadius: 20, - }, - processingText: { - fontSize: 14, - color: '#FFFFFF', - }, - detectedBoard: { - alignSelf: 'stretch', - marginTop: Spacing.three, - backgroundColor: 'rgba(0,0,0,0.55)', - borderRadius: 12, - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - gap: 6, - }, - detectedRow: { - flexDirection: 'row', - justifyContent: 'space-between', - gap: Spacing.two, - }, - detectedLabel: { - fontSize: 13, - fontWeight: '700', - color: '#FFFFFF', - textTransform: 'uppercase', - letterSpacing: 0.5, - }, - detectedValue: { - fontSize: 14, - lineHeight: 20, - fontWeight: '600', - color: colors.accent, - }, - detectedValueEmpty: { - color: 'rgba(255,255,255,0.75)', - fontWeight: '400', - }, - resultCard: { - alignSelf: 'stretch', - marginHorizontal: Spacing.four, - marginTop: insets.top + Spacing.three, - marginBottom: insets.bottom + Spacing.three, - maxHeight: '85%', - backgroundColor: colors.backgroundElement, - borderRadius: 16, - padding: Spacing.four, - }, - resultCardContent: { - alignItems: 'center', - gap: Spacing.three, - }, - mutedTitle: { - fontSize: 14, - fontWeight: '700', - color: colors.textSecondary, - textTransform: 'uppercase', - letterSpacing: 1, - }, - magazine: { - fontSize: 20, - fontWeight: '700', - color: colors.text, - textAlign: 'center', - }, - issue: { - fontSize: 16, - color: colors.text, - }, - date: { - fontSize: 14, - color: colors.textSecondary, - }, - confidence: { - fontSize: 14, - color: colors.textSecondary, - }, - message: { - fontSize: 15, - color: colors.text, - textAlign: 'center', - lineHeight: 22, - }, - fieldWrap: { - alignSelf: 'stretch', - gap: 6, - }, - fieldLabel: { - fontSize: 13, - fontWeight: '700', - color: colors.textSecondary, - textTransform: 'uppercase', - letterSpacing: 0.5, - }, - input: { - alignSelf: 'stretch', - backgroundColor: colors.background, - borderRadius: 10, - paddingVertical: Spacing.three, - paddingHorizontal: Spacing.three, - fontSize: 16, - color: colors.text, - }, - primaryButton: { - alignSelf: 'stretch', - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.accent, - paddingVertical: Spacing.three, - borderRadius: 12, - marginTop: Spacing.two, - }, - primaryButtonText: { - fontSize: 16, - fontWeight: '700', - color: colors.accentText, - textAlign: 'center', - }, - secondaryButton: { - alignSelf: 'stretch', - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.backgroundElement, - paddingVertical: Spacing.three, - borderRadius: 12, - marginHorizontal: Spacing.four, - marginTop: Spacing.two, - marginBottom: insets.bottom + Spacing.three, - }, - secondaryButtonText: { - fontSize: 16, - fontWeight: '600', - color: colors.text, - textAlign: 'center', - }, - buttonPressed: { - opacity: 0.8, - }, - cancelButton: { - marginTop: Spacing.three, - alignSelf: 'center', - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - }, - cancelButtonText: { - fontSize: 16, - color: colors.textSecondary, - textAlign: 'center', - }, - backButton: { - position: 'absolute', - top: insets.top + 12, - left: Spacing.three, - width: 44, - height: 44, - borderRadius: 22, - backgroundColor: 'rgba(0,0,0,0.55)', - alignItems: 'center', - justifyContent: 'center', - }, - torchButton: { - position: 'absolute', - top: insets.top + 12, - right: Spacing.three, - width: 44, - height: 44, - borderRadius: 22, - backgroundColor: 'rgba(0,0,0,0.55)', - alignItems: 'center', - justifyContent: 'center', - }, - guidanceCard: { - position: 'absolute', - left: Spacing.four, - right: Spacing.four, - bottom: insets.bottom + Spacing.three, - flexDirection: 'row', - flexWrap: 'wrap', - alignItems: 'center', - gap: Spacing.two, - backgroundColor: 'rgba(0,0,0,0.75)', - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - borderRadius: 12, - }, - guidanceText: { - flex: 1, - fontSize: 13, - color: '#FFFFFF', - lineHeight: 18, - }, - guidanceActions: { - flexDirection: 'row', - gap: Spacing.two, - width: '100%', - }, - guidanceButton: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.accent, - paddingVertical: Spacing.two, - borderRadius: 8, - }, - guidanceButtonText: { - fontSize: 13, - fontWeight: '700', - color: colors.accentText, - }, - }); -} diff --git a/src/components/__tests__/ocr-analysis.test.ts b/src/components/__tests__/ocr-analysis.test.ts new file mode 100644 index 0000000..e9985c3 --- /dev/null +++ b/src/components/__tests__/ocr-analysis.test.ts @@ -0,0 +1,51 @@ +import { + buildManualParams, + confidenceLabel, + hasAnyDetected, + ANALYSIS_INTERVAL_MS, + OCR_STABLE_READS, +} from '@/components/scan/ocr-analysis'; + +describe('ocr-analysis (logique pure extraite de camera.tsx)', () => { + describe('buildManualParams', () => { + it('remplit publication, numéro et année', () => { + expect( + buildManualParams({ publication: 'Picsou Magazine', issueNumber: 547, date: '2023' }), + ).toEqual({ publication: 'Picsou Magazine', issueNumber: '547', year: '2023' }); + }); + + it('ignore les champs absents', () => { + expect(buildManualParams({})).toEqual({}); + expect(buildManualParams({ publication: null })).toEqual({}); + }); + + it('n’ajoute pas issueNumber à 0 ou null', () => { + expect(buildManualParams({ issueNumber: 0 })).toEqual({ issueNumber: '0' }); + expect(buildManualParams({ issueNumber: null })).toEqual({}); + }); + }); + + describe('confidenceLabel', () => { + it('qualifie les seuils de confiance', () => { + expect(confidenceLabel(0.9)).toBe('élevée'); + expect(confidenceLabel(0.8)).toBe('élevée'); + expect(confidenceLabel(0.7)).toBe('moyenne'); + expect(confidenceLabel(0.5)).toBe('moyenne'); + expect(confidenceLabel(0.4)).toBe('faible'); + }); + }); + + describe('hasAnyDetected', () => { + it('détecte la présence d’au moins un champ', () => { + expect(hasAnyDetected({ publication: 'Picsou', issueNumber: null, date: null })).toBe(true); + expect(hasAnyDetected({ publication: null, issueNumber: 5, date: null })).toBe(true); + expect(hasAnyDetected({ publication: null, issueNumber: null, date: '2023' })).toBe(true); + expect(hasAnyDetected({ publication: null, issueNumber: null, date: null })).toBe(false); + }); + }); + + it('expose les constantes d’analyse OCR', () => { + expect(ANALYSIS_INTERVAL_MS).toBe(500); + expect(OCR_STABLE_READS).toBe(2); + }); +}); diff --git a/src/components/scan/barcode-overlay-controls.tsx b/src/components/scan/barcode-overlay-controls.tsx new file mode 100644 index 0000000..e08e4c0 --- /dev/null +++ b/src/components/scan/barcode-overlay-controls.tsx @@ -0,0 +1,100 @@ +import { ActivityIndicator, Pressable, Text, View } from 'react-native'; +import { Feather } from '@expo/vector-icons'; + +import type { ScanState } from './use-barcode-scanning'; +import type { BarcodeStyles } from './barcode-styles'; + +type Props = { + styles: BarcodeStyles; + state: ScanState; + showContinuousStart: boolean; + showCameraButtons: boolean; + torchOn: boolean; + onStartContinuous: () => void; + onBack: () => void; + onToggleTorch: () => void; + onRetry: () => void; +}; + +export function BarcodeOverlayControls({ + styles, + state, + showContinuousStart, + showCameraButtons, + torchOn, + onStartContinuous, + onBack, + onToggleTorch, + onRetry, +}: Props) { + return ( + <> + + + + {state.status === 'searching' ? ( + + ) : null} + + {state.status === 'searching' ? 'Recherche…' : 'Alignez le code-barres dans le cadre'} + + + {state.status === 'invalid' && ( + + {state.reason} + + )} + + + {showContinuousStart && ( + [styles.startContinuousButton, pressed && styles.buttonPressed]} + onPress={onStartContinuous} + testID="continuous-start" + accessibilityRole="button" + accessibilityLabel="Lancer le scan en continu"> + Scan en continu + + )} + + {showCameraButtons && ( + [styles.backButton, pressed && styles.buttonPressed]} + onPress={onBack} + testID="scan-back" + accessibilityRole="button" + accessibilityLabel="Annuler"> + + + )} + + {showCameraButtons && ( + [styles.torchButton, pressed && styles.buttonPressed]} + onPress={onToggleTorch} + testID="scan-torch" + accessibilityRole="button" + accessibilityLabel={torchOn ? 'Désactiver la torche' : 'Activer la torche'}> + + + )} + + {state.status === 'invalid' && ( + + [styles.primaryButton, pressed && styles.buttonPressed]} + onPress={onRetry} + testID="invalid-retry" + accessibilityRole="button"> + Scanner à nouveau + + + )} + + ); +} diff --git a/src/components/scan/barcode-pending-sheets.tsx b/src/components/scan/barcode-pending-sheets.tsx new file mode 100644 index 0000000..7b90e00 --- /dev/null +++ b/src/components/scan/barcode-pending-sheets.tsx @@ -0,0 +1,136 @@ +import { Pressable, ScrollView, Text, View } from 'react-native'; +import { Feather } from '@expo/vector-icons'; + +import { useThemeColors } from '@/hooks/use-theme'; +import type { Pending } from './use-barcode-scanning'; +import type { BarcodeStyles } from './barcode-styles'; + +type Props = { + styles: BarcodeStyles; + pending: Pending; + onResume: () => void; + onConfirmAdd: () => void; + onManual: (barcode: string) => void; +}; + +export function BarcodePendingSheets({ styles, pending, onResume, onConfirmAdd, onManual }: Props) { + const colors = useThemeColors(); + + if (pending.kind === 'confirm') { + return ( + + + + [styles.sheetClose, pressed && styles.buttonPressed]} + onPress={onResume} + testID="pending-close" + accessibilityRole="button" + accessibilityLabel="Fermer"> + + + Vous possédez déjà ce magazine + + {pending.magazine.publication} + {pending.magazine.issueNumber != null ? ` n° ${pending.magazine.issueNumber}` : ''} + + + Exemplaires actuels : {pending.ownedCount}. Ajouter un exemplaire ? + + [styles.primaryButton, pressed && styles.buttonPressed]} + onPress={onConfirmAdd} + testID="pending-confirm-add" + accessibilityRole="button"> + Ajouter un exemplaire + + [styles.pendingCancel, pressed && styles.buttonPressed]} + onPress={onResume} + testID="pending-confirm-cancel" + accessibilityRole="button"> + Annuler + + + + + ); + } + + if (pending.kind === 'success') { + return ( + + + + Ajouté à la collection + + {pending.publication} + {pending.issueNumber != null ? ` n° ${pending.issueNumber}` : ''} + + [styles.primaryButton, pressed && styles.buttonPressed]} + onPress={onResume} + testID="pending-success-ok" + accessibilityRole="button"> + Scanner le suivant + + + + + ); + } + + if (pending.kind === 'unknown') { + return ( + + + + Code-barres inconnu + {pending.barcode} + + Le scan seul ne crée pas l'édition. Saisissez-la manuellement. + + [styles.primaryButton, pressed && styles.buttonPressed]} + onPress={() => onManual(pending.barcode)} + testID="pending-unknown-manual" + accessibilityRole="button"> + Saisir manuellement + + [styles.pendingCancel, pressed && styles.buttonPressed]} + onPress={onResume} + testID="pending-unknown-continue" + accessibilityRole="button"> + Continuer le scan + + + + + ); + } + + return null; +} + +export function BarcodeContinuousBar({ + styles, + onStop, +}: { + styles: BarcodeStyles; + onStop: () => void; +}) { + return ( + + Scan en continu — ajoute chaque exemplaire + [styles.continuousStop, pressed && styles.buttonPressed]} + onPress={onStop} + testID="continuous-stop" + accessibilityRole="button" + accessibilityLabel="Arrêter le scan en continu"> + Arrêter + + + ); +} diff --git a/src/components/scan/barcode-styles.ts b/src/components/scan/barcode-styles.ts new file mode 100644 index 0000000..fcaf792 --- /dev/null +++ b/src/components/scan/barcode-styles.ts @@ -0,0 +1,211 @@ +import { StyleSheet } from 'react-native'; + +import { Spacing, type ThemeColors } from '@/constants/theme'; + +export function makeBarcodeStyles(colors: ThemeColors, insets: { top: number; bottom: number }) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + overlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + alignItems: 'center', + justifyContent: 'center', + }, + camera: { + flex: 1, + }, + reticle: { + width: 220, + height: 220, + borderWidth: 3, + borderColor: colors.accent, + borderRadius: 16, + backgroundColor: 'transparent', + }, + hintRow: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.two, + marginTop: Spacing.three, + backgroundColor: 'rgba(0,0,0,0.55)', + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + borderRadius: 8, + }, + scanHint: { + fontSize: 14, + lineHeight: 20, + color: '#FFFFFF', + textAlign: 'center', + }, + invalidText: { + marginTop: Spacing.two, + fontSize: 14, + color: '#FFD5D2', + textAlign: 'center', + backgroundColor: 'rgba(0,0,0,0.55)', + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + borderRadius: 8, + overflow: 'hidden', + }, + invalidActions: { + position: 'absolute', + left: Spacing.four, + right: Spacing.four, + bottom: insets.bottom + 24, + }, + continuousBar: { + position: 'absolute', + top: insets.top + 12, + left: Spacing.three, + right: Spacing.three, + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.7)', + borderRadius: 10, + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + }, + continuousText: { + flex: 1, + fontSize: 13, + color: '#FFFFFF', + }, + continuousStop: { + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + borderRadius: 8, + backgroundColor: colors.danger, + }, + continuousStopText: { + fontSize: 13, + fontWeight: '700', + color: colors.onDanger, + }, + startContinuousButton: { + position: 'absolute', + left: Spacing.four, + right: Spacing.four, + bottom: insets.bottom + 80, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0,0,0,0.65)', + paddingVertical: Spacing.two, + borderRadius: 10, + }, + startContinuousText: { + fontSize: 15, + fontWeight: '600', + color: '#FFFFFF', + }, + pendingCard: { + marginHorizontal: Spacing.four, + backgroundColor: colors.backgroundElement, + borderRadius: 14, + padding: Spacing.four, + gap: Spacing.two, + alignItems: 'center', + }, + sheetClose: { + alignSelf: 'flex-end', + minWidth: 44, + minHeight: 44, + alignItems: 'center', + justifyContent: 'center', + marginTop: -Spacing.three, + marginRight: -Spacing.three, + }, + backdrop: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center', + backgroundColor: 'rgba(0,0,0,0.5)', + }, + sheetScroll: { + flexGrow: 1, + justifyContent: 'center', + }, + pendingTitle: { + fontSize: 18, + fontWeight: '700', + color: colors.text, + textAlign: 'center', + }, + pendingMagazine: { + fontSize: 17, + fontWeight: '600', + color: colors.accentTextOnLight, + textAlign: 'center', + }, + pendingMessage: { + fontSize: 14, + color: colors.textSecondary, + textAlign: 'center', + lineHeight: 20, + }, + pendingCancel: { + minHeight: 44, + justifyContent: 'center', + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + }, + pendingCancelText: { + fontSize: 16, + color: colors.textSecondary, + textAlign: 'center', + }, + primaryButton: { + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.accent, + minHeight: 48, + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + borderRadius: 12, + alignSelf: 'stretch', + }, + primaryButtonText: { + fontSize: 16, + fontWeight: '700', + color: colors.accentText, + textAlign: 'center', + }, + buttonPressed: { + opacity: 0.8, + }, + backButton: { + position: 'absolute', + top: insets.top + 12, + left: Spacing.three, + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: 'rgba(0,0,0,0.55)', + alignItems: 'center', + justifyContent: 'center', + }, + torchButton: { + position: 'absolute', + top: insets.top + 12, + right: Spacing.three, + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: 'rgba(0,0,0,0.55)', + alignItems: 'center', + justifyContent: 'center', + }, + }); +} + +export type BarcodeStyles = ReturnType; diff --git a/src/components/scan/camera-ocr-styles.ts b/src/components/scan/camera-ocr-styles.ts new file mode 100644 index 0000000..28d3d34 --- /dev/null +++ b/src/components/scan/camera-ocr-styles.ts @@ -0,0 +1,262 @@ +import { StyleSheet } from 'react-native'; + +import { Spacing, type ThemeColors } from '@/constants/theme'; + +export function makeCameraOcrStyles(colors: ThemeColors, insets: { top: number; bottom: number }) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + overlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: Spacing.four, + }, + camera: { + flex: 1, + }, + reticle: { + width: 220, + height: 220, + borderWidth: 3, + borderColor: colors.accent, + borderRadius: 16, + backgroundColor: 'transparent', + }, + scanHint: { + marginTop: Spacing.three, + fontSize: 14, + color: '#FFFFFF', + textAlign: 'center', + backgroundColor: 'rgba(0,0,0,0.55)', + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + borderRadius: 8, + overflow: 'hidden', + }, + processingPill: { + marginTop: Spacing.two, + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.two, + backgroundColor: 'rgba(0,0,0,0.55)', + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + borderRadius: 20, + }, + processingText: { + fontSize: 14, + color: '#FFFFFF', + }, + detectedBoard: { + alignSelf: 'stretch', + marginTop: Spacing.three, + backgroundColor: 'rgba(0,0,0,0.55)', + borderRadius: 12, + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + gap: 6, + }, + detectedRow: { + flexDirection: 'row', + justifyContent: 'space-between', + gap: Spacing.two, + }, + detectedLabel: { + fontSize: 13, + fontWeight: '700', + color: '#FFFFFF', + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + detectedValue: { + fontSize: 14, + lineHeight: 20, + fontWeight: '600', + color: colors.accent, + }, + detectedValueEmpty: { + color: 'rgba(255,255,255,0.75)', + fontWeight: '400', + }, + resultCard: { + alignSelf: 'stretch', + marginHorizontal: Spacing.four, + marginTop: insets.top + Spacing.three, + marginBottom: insets.bottom + Spacing.three, + maxHeight: '85%', + backgroundColor: colors.backgroundElement, + borderRadius: 16, + padding: Spacing.four, + }, + resultCardContent: { + alignItems: 'center', + gap: Spacing.three, + }, + mutedTitle: { + fontSize: 14, + fontWeight: '700', + color: colors.textSecondary, + textTransform: 'uppercase', + letterSpacing: 1, + }, + magazine: { + fontSize: 20, + fontWeight: '700', + color: colors.text, + textAlign: 'center', + }, + issue: { + fontSize: 16, + color: colors.text, + }, + date: { + fontSize: 14, + color: colors.textSecondary, + }, + confidence: { + fontSize: 14, + color: colors.textSecondary, + }, + message: { + fontSize: 15, + color: colors.text, + textAlign: 'center', + lineHeight: 22, + }, + fieldWrap: { + alignSelf: 'stretch', + gap: 6, + }, + fieldLabel: { + fontSize: 13, + fontWeight: '700', + color: colors.textSecondary, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + input: { + alignSelf: 'stretch', + backgroundColor: colors.background, + borderRadius: 10, + paddingVertical: Spacing.three, + paddingHorizontal: Spacing.three, + fontSize: 16, + color: colors.text, + }, + primaryButton: { + alignSelf: 'stretch', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.accent, + paddingVertical: Spacing.three, + borderRadius: 12, + marginTop: Spacing.two, + }, + primaryButtonText: { + fontSize: 16, + fontWeight: '700', + color: colors.accentText, + textAlign: 'center', + }, + secondaryButton: { + alignSelf: 'stretch', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.backgroundElement, + paddingVertical: Spacing.three, + borderRadius: 12, + marginHorizontal: Spacing.four, + marginTop: Spacing.two, + marginBottom: insets.bottom + Spacing.three, + }, + secondaryButtonText: { + fontSize: 16, + fontWeight: '600', + color: colors.text, + textAlign: 'center', + }, + buttonPressed: { + opacity: 0.8, + }, + cancelButton: { + marginTop: Spacing.three, + alignSelf: 'center', + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + }, + cancelButtonText: { + fontSize: 16, + color: colors.textSecondary, + textAlign: 'center', + }, + backButton: { + position: 'absolute', + top: insets.top + 12, + left: Spacing.three, + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: 'rgba(0,0,0,0.55)', + alignItems: 'center', + justifyContent: 'center', + }, + torchButton: { + position: 'absolute', + top: insets.top + 12, + right: Spacing.three, + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: 'rgba(0,0,0,0.55)', + alignItems: 'center', + justifyContent: 'center', + }, + guidanceCard: { + position: 'absolute', + left: Spacing.four, + right: Spacing.four, + bottom: insets.bottom + Spacing.three, + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + gap: Spacing.two, + backgroundColor: 'rgba(0,0,0,0.75)', + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + borderRadius: 12, + }, + guidanceText: { + flex: 1, + fontSize: 13, + color: '#FFFFFF', + lineHeight: 18, + }, + guidanceActions: { + flexDirection: 'row', + gap: Spacing.two, + width: '100%', + }, + guidanceButton: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.accent, + paddingVertical: Spacing.two, + borderRadius: 8, + }, + guidanceButtonText: { + fontSize: 13, + fontWeight: '700', + color: colors.accentText, + }, + }); +} + +export type CameraOcrStyles = ReturnType; diff --git a/src/components/scan/ocr-analysis.ts b/src/components/scan/ocr-analysis.ts new file mode 100644 index 0000000..cd1425a --- /dev/null +++ b/src/components/scan/ocr-analysis.ts @@ -0,0 +1,73 @@ +import { MIN_CONFIDENCE } from '@/identification/ocr/ocrTextParser'; + +/** + * Intervalle d'analyse OCR : quelques frames / seconde max (pas toutes). + */ +export const ANALYSIS_INTERVAL_MS = 500; + +/** + * Lectures OCR identiques requises avant de conclure une identification + * (M10R2-09 — vote multi-frames conservateur, une lecture isolée pouvant être + * erronée sur des textes stylisés / à encres faibles). + */ +export const OCR_STABLE_READS = 2; + +/** + * Informations détectées par l'OCR, affichées en surcouche caméra (US-ID-08) + * et proposées à la validation / correction (US-ID-09). + */ +export type DetectedInfo = { + publication: string | null; + issueNumber: number | null; + date: string | null; +}; + +export const EMPTY_DETECTED: DetectedInfo = { publication: null, issueNumber: null, date: null }; + +export type OcrUiState = + | { status: 'analyzing'; detected: DetectedInfo } + | { status: 'confirm'; detected: DetectedInfo } + | { + status: 'found'; + id: string; + publication: string; + issueNumber: number | null; + date: string | null; + confidence: number; + } + | { + status: 'unknown'; + publication: string; + issueNumber: number | null; + date: string | null; + confidence: number; + }; + +export function hasAnyDetected(detected: DetectedInfo): boolean { + return detected.publication !== null || detected.issueNumber !== null || detected.date !== null; +} + +/** Construit les paramètres de navigation vers la saisie manuelle pré-remplie. */ +export function buildManualParams(detected: Partial): Record { + const params: Record = {}; + if (detected.publication) { + params.publication = detected.publication; + } + if (detected.issueNumber != null) { + params.issueNumber = String(detected.issueNumber); + } + if (detected.date) { + params.year = detected.date; + } + return params; +} + +export function confidenceLabel(confidence: number): string { + if (confidence >= 0.8) { + return 'élevée'; + } + if (confidence >= MIN_CONFIDENCE) { + return 'moyenne'; + } + return 'faible'; +} diff --git a/src/components/scan/ocr-analyzing-overlay.tsx b/src/components/scan/ocr-analyzing-overlay.tsx new file mode 100644 index 0000000..d18270e --- /dev/null +++ b/src/components/scan/ocr-analyzing-overlay.tsx @@ -0,0 +1,144 @@ +import { ActivityIndicator, Pressable, Text, View } from 'react-native'; +import { Feather } from '@expo/vector-icons'; + +import { useThemeColors } from '@/hooks/use-theme'; +import { hasAnyDetected, type DetectedInfo } from './ocr-analysis'; +import type { CameraOcrStyles } from './camera-ocr-styles'; + +type Props = { + styles: CameraOcrStyles; + detected: DetectedInfo; + hint: string; + weakCycles: number; + torchOn: boolean; + onOpenConfirm: () => void; + onGoBarcode: () => void; + onGoManual: (detected: Partial) => void; + onBack: () => void; + onToggleTorch: () => void; +}; + +export function OcrAnalyzingOverlay({ + styles, + detected, + hint, + weakCycles, + torchOn, + onOpenConfirm, + onGoBarcode, + onGoManual, + onBack, + onToggleTorch, +}: Props) { + const colors = useThemeColors(); + + return ( + <> + + + + {hint} + + + + Lecture… + + + {/* Surcouche US-ID-08 : champs détectés en direct. */} + + + Nom + + {detected.publication ?? '…'} + + + + Numéro + + {detected.issueNumber?.toString() ?? '…'} + + + + Édition / date + + {detected.date ?? '…'} + + + + + {hasAnyDetected(detected) && ( + [styles.primaryButton, pressed && styles.buttonPressed]} + onPress={onOpenConfirm} + testID="ocr-confirm-detected" + accessibilityRole="button"> + Valider ces informations détectées + + )} + + + [styles.secondaryButton, pressed && styles.buttonPressed]} + onPress={onGoBarcode} + testID="ocr-barcode" + accessibilityRole="button"> + Scanner le code-barres + + + [styles.backButton, pressed && styles.buttonPressed]} + onPress={onBack} + testID="ocr-back" + accessibilityRole="button" + accessibilityLabel="Annuler"> + + + + [styles.torchButton, pressed && styles.buttonPressed]} + onPress={onToggleTorch} + testID="ocr-torch" + accessibilityRole="button" + accessibilityLabel={torchOn ? 'Désactiver la torche' : 'Activer la torche'}> + + + + {weakCycles > 3 && detected?.publication && detected?.issueNumber === null && ( + + + + Le texte stylisé est difficile à lire automatiquement. + + + [styles.guidanceButton, pressed && styles.buttonPressed]} + onPress={onGoBarcode} + testID="ocr-guidance-barcode" + accessibilityRole="button"> + Code-barres + + [styles.guidanceButton, pressed && styles.buttonPressed]} + onPress={() => onGoManual(detected)} + testID="ocr-guidance-manual" + accessibilityRole="button"> + Saisie manuelle + + + + )} + + ); +} diff --git a/src/components/scan/ocr-confirm-overlay.tsx b/src/components/scan/ocr-confirm-overlay.tsx new file mode 100644 index 0000000..74ddc08 --- /dev/null +++ b/src/components/scan/ocr-confirm-overlay.tsx @@ -0,0 +1,104 @@ +import { Pressable, Text, TextInput, View } from 'react-native'; + +import { useThemeColors } from '@/hooks/use-theme'; +import type { DetectedInfo } from './ocr-analysis'; +import type { CameraOcrStyles } from './camera-ocr-styles'; + +type Props = { + styles: CameraOcrStyles; + draft: DetectedInfo; + onDraftChange: (draft: DetectedInfo) => void; + onSearch: () => void; + onGoManual: () => void; + onBack: () => void; +}; + +export function OcrConfirmOverlay({ + styles, + draft, + onDraftChange, + onSearch, + onGoManual, + onBack, +}: Props) { + const colors = useThemeColors(); + + return ( + + + Vérifier les informations + + Corrigez les informations détectées puis validez la recherche, même si la confiance était + insuffisante. + + + + Nom + onDraftChange({ ...draft, publication: t })} + placeholder="Publication du magazine" + placeholderTextColor={colors.textSecondary} + testID="ocr-override-publication" + /> + + + + Numéro + { + const digits = t.replace(/[^0-9]/g, ''); + onDraftChange({ ...draft, issueNumber: digits ? Number(digits) : null }); + }} + placeholder="N° du magazine" + keyboardType="number-pad" + returnKeyType="done" + placeholderTextColor={colors.textSecondary} + testID="ocr-override-issue" + accessibilityLabel="Numéro du magazine (chiffres uniquement)" + /> + + + + Année + onDraftChange({ ...draft, date: t })} + placeholder="Année / date (optionnel)" + keyboardType="default" + placeholderTextColor={colors.textSecondary} + testID="ocr-override-date" + /> + + + [styles.primaryButton, pressed && styles.buttonPressed]} + onPress={onSearch} + testID="ocr-override-search" + accessibilityRole="button"> + Rechercher + + + [styles.secondaryButton, pressed && styles.buttonPressed]} + onPress={onGoManual} + testID="ocr-override-manual" + accessibilityRole="button"> + Saisir manuellement + + + [styles.cancelButton, pressed && styles.buttonPressed]} + onPress={onBack} + testID="ocr-override-back" + accessibilityRole="button"> + Retour à la caméra + + + + ); +} diff --git a/src/components/scan/ocr-result-overlay.tsx b/src/components/scan/ocr-result-overlay.tsx new file mode 100644 index 0000000..ae3fdd2 --- /dev/null +++ b/src/components/scan/ocr-result-overlay.tsx @@ -0,0 +1,94 @@ +import { Pressable, ScrollView, Text, View } from 'react-native'; + +import { confidenceLabel, type OcrUiState } from './ocr-analysis'; +import type { CameraOcrStyles } from './camera-ocr-styles'; + +type FoundState = Extract; +type UnknownState = Extract; + +type Props = { + styles: CameraOcrStyles; + state: FoundState | UnknownState; + onConfirm?: () => void; + onRetry: () => void; + onGoManual: () => void; +}; + +export function OcrResultOverlay({ styles, state, onConfirm, onRetry, onGoManual }: Props) { + if (state.status === 'found') { + return ( + + + Couverture reconnue + + {state.publication} + + {state.issueNumber != null && N° {state.issueNumber}} + {state.date && {state.date}} + + Confiance : {confidenceLabel(state.confidence)} + + [styles.primaryButton, pressed && styles.buttonPressed]} + onPress={onConfirm} + testID="ocr-confirm" + accessibilityRole="button"> + Confirmer + + [styles.secondaryButton, pressed && styles.buttonPressed]} + onPress={onRetry} + testID="ocr-retry" + accessibilityRole="button"> + Réessayer + + [styles.secondaryButton, pressed && styles.buttonPressed]} + onPress={onGoManual} + testID="ocr-manual" + accessibilityRole="button"> + Saisie manuelle + + + + ); + } + + if (state.status === 'unknown') { + return ( + + + Non trouvé en collection + {state.publication} + {state.issueNumber != null && N° {state.issueNumber}} + + {state.publication} n'est pas encore référencé. Vous pouvez le saisir manuellement + pour le créer. + + [styles.primaryButton, pressed && styles.buttonPressed]} + onPress={onGoManual} + testID="ocr-manual" + accessibilityRole="button"> + Saisir manuellement + + [styles.secondaryButton, pressed && styles.buttonPressed]} + onPress={onRetry} + testID="ocr-retry" + accessibilityRole="button"> + Réessayer avec la caméra + + + + ); + } + + return null; +} diff --git a/src/components/scan/use-barcode-scanning.ts b/src/components/scan/use-barcode-scanning.ts new file mode 100644 index 0000000..59d5e63 --- /dev/null +++ b/src/components/scan/use-barcode-scanning.ts @@ -0,0 +1,144 @@ +import { useCameraPermissions } from 'expo-camera'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useRef, useState } from 'react'; + +import { getDeps } from '@/dependencies'; +import { BarcodeStabilizer } from '@/identification/barcodeStabilizer'; +import { useCollectionStore } from '@/store/use-collection-store'; +import type { Magazine } from '@/types'; + +export type ScanState = + { status: 'idle' } | { status: 'searching' } | { status: 'invalid'; reason: string }; + +export type Pending = + | { kind: 'confirm'; magazine: Magazine; ownedCount: number } + | { kind: 'success'; publication: string; issueNumber: number | null } + | { kind: 'unknown'; barcode: string }; + +/** + * Logique du scan de code-barres (stabilisation multi-lectures, identification, + * mode continu) découplée de la vue pour la rendre testable. + */ +export function useBarcodeScanning() { + const router = useRouter(); + const addExistingCopy = useCollectionStore((s) => s.addExistingCopy); + const params = useLocalSearchParams<{ continuous?: string }>(); + + const [permission, requestPermission] = useCameraPermissions(); + const [state, setState] = useState({ status: 'idle' }); + const [scanning, setScanning] = useState(true); + const [continuous, setContinuous] = useState(params.continuous === '1'); + const [torchOn, setTorchOn] = useState(false); + const [pending, setPending] = useState(null); + const stabilizer = useRef(new BarcodeStabilizer(3)); + + const resume = () => { + stabilizer.current.reset(); + setPending(null); + setScanning(true); + setState({ status: 'idle' }); + }; + + const handleSingle = async (stabilized: string) => { + const { identificationService } = getDeps(); + const result = await identificationService.identifyByBarcode(stabilized); + + if (result.status === 'found') { + router.replace({ + pathname: '/scan/result', + params: { + id: result.magazine.id, + publication: result.magazine.publication, + issueNumber: + result.magazine.issueNumber != null ? String(result.magazine.issueNumber) : '', + barcode: stabilized, + }, + }); + } else if (result.status === 'ambiguous') { + router.replace({ pathname: '/scan/multiple', params: { barcode: stabilized } }); + } else if (result.status === 'unknown') { + router.replace({ pathname: '/scan/result', params: { barcode: stabilized } }); + } else { + setState({ status: 'invalid', reason: result.reason }); + setScanning(true); + } + }; + + const handleContinuous = async (stabilized: string) => { + const { identificationService, collectionRepository } = getDeps(); + const result = await identificationService.identifyByBarcode(stabilized); + + if (result.status === 'found') { + const ownedCount = await collectionRepository.countByMagazine(result.magazine.id); + if (ownedCount > 0) { + setPending({ kind: 'confirm', magazine: result.magazine, ownedCount }); + } else { + await addExistingCopy(result.magazine.id); + setPending({ + kind: 'success', + publication: result.magazine.publication, + issueNumber: result.magazine.issueNumber, + }); + } + } else if (result.status === 'ambiguous') { + router.replace({ pathname: '/scan/multiple', params: { barcode: stabilized } }); + } else if (result.status === 'unknown') { + setPending({ kind: 'unknown', barcode: stabilized }); + } else { + setState({ status: 'invalid', reason: result.reason }); + setScanning(true); + } + }; + + const handleScan = async ({ data }: { data: string; type: string }) => { + if (!scanning || state.status === 'searching' || pending) { + return; + } + const stabilized = stabilizer.current.push(data); + if (stabilized === null) { + return; + } + setScanning(false); + setState({ status: 'searching' }); + + if (continuous) { + await handleContinuous(stabilized); + } else { + await handleSingle(stabilized); + } + }; + + const reset = () => { + stabilizer.current.reset(); + setScanning(true); + setState({ status: 'idle' }); + }; + + const confirmAdd = async () => { + if (pending?.kind !== 'confirm') { + return; + } + await addExistingCopy(pending.magazine.id); + setPending({ + kind: 'success', + publication: pending.magazine.publication, + issueNumber: pending.magazine.issueNumber, + }); + }; + + return { + permission, + requestPermission, + state, + scanning, + continuous, + torchOn, + pending, + setContinuous, + setTorchOn, + handleScan, + resume, + reset, + confirmAdd, + }; +} diff --git a/src/components/scan/use-ocr-analysis.ts b/src/components/scan/use-ocr-analysis.ts new file mode 100644 index 0000000..6cd04be --- /dev/null +++ b/src/components/scan/use-ocr-analysis.ts @@ -0,0 +1,204 @@ +import { useCameraPermissions } from 'expo-camera'; +import { useRouter } from 'expo-router'; +import { useEffect, useRef, useState } from 'react'; +import type { CameraView as CameraViewType } from 'expo-camera'; + +import { getDeps } from '@/dependencies'; +import { OcrTextStabilizer } from '@/identification/ocr/ocrTextStabilizer'; +import { + ANALYSIS_INTERVAL_MS, + buildManualParams, + EMPTY_DETECTED, + OCR_STABLE_READS, + type DetectedInfo, + type OcrUiState, +} from './ocr-analysis'; + +/** + * Logique d'analyse OCR d'une couverture (détection périodique, vote multi-frames, + * surcouche de validation / correction, recherche hors confiance). + * + * Expose l'état d'interface OCR et les actions associées de façon découplée de + * la vue, afin de la rendre testable. + */ +export function useOcrAnalysis() { + const router = useRouter(); + const [permission, requestPermission] = useCameraPermissions(); + const [state, setState] = useState({ + status: 'analyzing', + detected: EMPTY_DETECTED, + }); + const [draft, setDraft] = useState(EMPTY_DETECTED); + const [torchOn, setTorchOn] = useState(false); + const [weakCycles, setWeakCycles] = useState(0); + const inFlight = useRef(false); + const cameraRef = useRef(null); + const ocrStabilizer = useRef(new OcrTextStabilizer(OCR_STABLE_READS)); + + useEffect(() => { + if (!permission?.granted || state.status !== 'analyzing') { + return; + } + + const { ocrEngine, identificationService } = getDeps(); + const interval = setInterval(async () => { + if (inFlight.current) { + return; + } + inFlight.current = true; + try { + // Capture éphémère d'une photo (aucune image persistée) → URI. + // Haute résolution (M10R2-09) : préserve les petites encres des textes stylisés. + const photo = await cameraRef.current?.takePictureAsync?.({ + quality: 1, + skipProcessing: false, + }); + const uri = photo?.uri ?? null; + const frame = await ocrEngine.recognize({ native: uri, width: 0, height: 0 }); + if (!frame) { + return; + } + const result = await identificationService.identifyByOCR(frame.text); + + if (result.status === 'no-text') { + return; + } + + if (result.status === 'weak') { + // US-ID-08 : on ne conclut plus en échec dès la première lecture partielle. + // On met en surcouche les champs détectés et on continue d'analyser + // (le pointeur guide l'utilisateur vers le champ manquant). + setWeakCycles((c) => c + 1); + setState((prev) => + prev.status === 'analyzing' + ? { + status: 'analyzing', + detected: { + publication: + result.publication === 'Publication inconnue' ? null : result.publication, + issueNumber: result.issueNumber, + date: result.date, + }, + } + : prev, + ); + return; + } + + // Vote multi-frames (M10R2-09) : on ne conclut pas sur une lecture + // isolée, une frame suivante identique est requise (textes stylisés). + const key = [ + result.publication ?? '', + result.issueNumber != null ? String(result.issueNumber) : '', + result.date ?? '', + ].join('|'); + if (!ocrStabilizer.current.push(key)) { + return; + } + ocrStabilizer.current.reset(); + + if (result.status === 'unknown') { + setState({ + status: 'unknown', + publication: result.publication, + issueNumber: result.issueNumber, + date: result.date, + confidence: result.confidence, + }); + return; + } + + setState({ + status: 'found', + id: result.magazine.id, + publication: result.publication, + issueNumber: result.issueNumber, + date: result.date, + confidence: result.confidence, + }); + } finally { + inFlight.current = false; + } + }, ANALYSIS_INTERVAL_MS); + + return () => clearInterval(interval); + }, [permission?.granted, state.status]); + + const stopAndRetry = () => { + ocrStabilizer.current.reset(); + setWeakCycles(0); + setState({ status: 'analyzing', detected: EMPTY_DETECTED }); + }; + + const openConfirm = () => { + const detected = + state.status === 'analyzing' || state.status === 'confirm' ? state.detected : EMPTY_DETECTED; + setDraft(detected); + setState({ status: 'confirm', detected }); + }; + + const goManual = (detected: Partial) => { + router.replace({ pathname: '/scan/manual', params: buildManualParams(detected) }); + }; + + const goBarcode = () => { + router.replace('/scan/barcode'); + }; + + /** US-ID-09 : recherche en outrepassant la confiance (champs validés/corrigés). */ + const searchFromDraft = async () => { + const publication = draft.publication?.trim() ?? ''; + const rawNumber = draft.issueNumber?.toString().trim() ?? ''; + const issueNumber = rawNumber ? Number(rawNumber) : null; + const date = draft.date?.trim() || null; + + if (!publication || issueNumber === null || !Number.isFinite(issueNumber)) { + // Impossible de rechercher : on oriente vers la saisie manuelle pré-remplie. + goManual({ publication: publication || undefined, issueNumber, date }); + return; + } + + const { identificationService } = getDeps(); + const result = await identificationService.searchByOcrFields(publication, issueNumber, date); + + if (result.status === 'weak' || result.status === 'no-text') { + goManual({ publication, issueNumber, date }); + return; + } + if (result.status === 'unknown') { + setState({ + status: 'unknown', + publication: result.publication, + issueNumber: result.issueNumber, + date: result.date, + confidence: result.confidence, + }); + return; + } + setState({ + status: 'found', + id: result.magazine.id, + publication: result.publication, + issueNumber: result.issueNumber, + date: result.date, + confidence: result.confidence, + }); + }; + + return { + cameraRef, + permission, + requestPermission, + state, + draft, + torchOn, + weakCycles, + setTorchOn, + setDraft, + stopAndRetry, + openConfirm, + goManual, + goBarcode, + searchFromDraft, + }; +} From 7df8adb9b907536d155e1e38ee953be814a4c4e2 Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Tue, 8 Sep 2026 12:30:29 +0200 Subject: [PATCH 2/2] =?UTF-8?q?test(m11-04):=20couvrir=20les=20composants?= =?UTF-8?q?=20de=20scan=20cam=C3=A9ra=20&=20code-barres=20(M-11-#184)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - barcode-pending-sheets : confirm/success/unknown + bandeau continu - ocr-analyzing-overlay : champs détectés, guide, torche, validation - ocr-confirm-overlay : édition des champs, filtrage du numéro, actions --- .../__tests__/barcode-pending-sheets.test.tsx | 124 +++++++++++++++ .../__tests__/ocr-analyzing-overlay.test.tsx | 145 ++++++++++++++++++ .../__tests__/ocr-confirm-overlay.test.tsx | 90 +++++++++++ 3 files changed, 359 insertions(+) create mode 100644 src/components/__tests__/barcode-pending-sheets.test.tsx create mode 100644 src/components/__tests__/ocr-analyzing-overlay.test.tsx create mode 100644 src/components/__tests__/ocr-confirm-overlay.test.tsx diff --git a/src/components/__tests__/barcode-pending-sheets.test.tsx b/src/components/__tests__/barcode-pending-sheets.test.tsx new file mode 100644 index 0000000..94d56a6 --- /dev/null +++ b/src/components/__tests__/barcode-pending-sheets.test.tsx @@ -0,0 +1,124 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { + BarcodeContinuousBar, + BarcodePendingSheets, +} from '@/components/scan/barcode-pending-sheets'; +import { makeBarcodeStyles } from '@/components/scan/barcode-styles'; +import type { Pending } from '@/components/scan/use-barcode-scanning'; +import { Colors } from '@/constants/theme'; +import type { Magazine } from '@/types'; + +const styles = makeBarcodeStyles(Colors.light, { top: 0, bottom: 0 }); + +const magazine: Magazine = { + id: 'mag-1', + publication: 'Picsou Magazine', + issueNumber: 547, + edition: null, + language: null, + condition: null, + publicationDate: null, + barcode: null, + notes: null, + ocrText: null, + createdAt: '2026-09-01T10:00:00.000Z', + updatedAt: '2026-09-01T10:00:00.000Z', +}; + +function renderPending(pending: Pending) { + const callbacks = { + onResume: jest.fn(), + onConfirmAdd: jest.fn(), + onManual: jest.fn(), + }; + render( + , + ); + return callbacks; +} + +describe('BarcodePendingSheets', () => { + it('affiche la confirmation de doublon avec le numéro et le nombre d’exemplaires', () => { + const callbacks = renderPending({ + kind: 'confirm', + magazine, + ownedCount: 3, + }); + + expect(screen.getByText('Vous possédez déjà ce magazine')).toBeTruthy(); + expect(screen.getByText('Picsou Magazine n° 547')).toBeTruthy(); + expect(screen.getByText(/Exemplaires actuels : 3/)).toBeTruthy(); + + fireEvent.press(screen.getByTestId('pending-confirm-add')); + expect(callbacks.onConfirmAdd).toHaveBeenCalled(); + + fireEvent.press(screen.getByTestId('pending-confirm-cancel')); + expect(callbacks.onResume).toHaveBeenCalled(); + }); + + it('affiche le magazine sans numéro quand il est absent', () => { + renderPending({ kind: 'confirm', magazine: { ...magazine, issueNumber: null }, ownedCount: 1 }); + + expect(screen.getByText('Picsou Magazine')).toBeTruthy(); + }); + + it('affiche l’écran de succès puis reprend le scan', () => { + const callbacks = renderPending({ + kind: 'success', + publication: 'Picsou Magazine', + issueNumber: 547, + }); + + expect(screen.getByText('Ajouté à la collection')).toBeTruthy(); + expect(screen.getByText('Picsou Magazine n° 547')).toBeTruthy(); + + fireEvent.press(screen.getByTestId('pending-success-ok')); + expect(callbacks.onResume).toHaveBeenCalled(); + }); + + it('affiche l’écran de succès sans numéro si l’édition n’en a pas', () => { + renderPending({ kind: 'success', publication: 'Picsou Magazine', issueNumber: null }); + + expect(screen.getByText('Picsou Magazine')).toBeTruthy(); + }); + + it('suggère la saisie manuelle pour un code-barres inconnu', () => { + const callbacks = renderPending({ kind: 'unknown', barcode: '9791234567890' }); + + expect(screen.getByText('Code-barres inconnu')).toBeTruthy(); + expect(screen.getByText('9791234567890')).toBeTruthy(); + + fireEvent.press(screen.getByTestId('pending-unknown-manual')); + expect(callbacks.onManual).toHaveBeenCalledWith('9791234567890'); + + fireEvent.press(screen.getByTestId('pending-unknown-continue')); + expect(callbacks.onResume).toHaveBeenCalled(); + }); + + it('ne rend rien pour un état inconnu', () => { + renderPending('idle' as unknown as Pending); + + expect(screen.queryByTestId('pending-confirm')).toBeNull(); + expect(screen.queryByTestId('pending-success')).toBeNull(); + expect(screen.queryByTestId('pending-unknown')).toBeNull(); + }); +}); + +describe('BarcodeContinuousBar', () => { + it('affiche le bandeau continu et permet de l’arrêter', () => { + const onStop = jest.fn(); + render(); + + expect(screen.getByText(/Scan en continu/)).toBeTruthy(); + + fireEvent.press(screen.getByTestId('continuous-stop')); + expect(onStop).toHaveBeenCalled(); + }); +}); diff --git a/src/components/__tests__/ocr-analyzing-overlay.test.tsx b/src/components/__tests__/ocr-analyzing-overlay.test.tsx new file mode 100644 index 0000000..dfa3a5b --- /dev/null +++ b/src/components/__tests__/ocr-analyzing-overlay.test.tsx @@ -0,0 +1,145 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { OcrAnalyzingOverlay } from '@/components/scan/ocr-analyzing-overlay'; +import { makeCameraOcrStyles } from '@/components/scan/camera-ocr-styles'; +import type { DetectedInfo } from '@/components/scan/ocr-analysis'; +import { Colors } from '@/constants/theme'; + +const styles = makeCameraOcrStyles(Colors.light, { top: 0, bottom: 0 }); + +function renderOverlay(overrides: Partial<{ detected: DetectedInfo; weakCycles: number }> = {}) { + const callbacks = { + onOpenConfirm: jest.fn(), + onGoBarcode: jest.fn(), + onGoManual: jest.fn(), + onBack: jest.fn(), + onToggleTorch: jest.fn(), + }; + render( + , + ); + return callbacks; +} + +describe('OcrAnalyzingOverlay', () => { + it('affiche le texte-guide et la lecture en cours', () => { + renderOverlay(); + + expect(screen.getByTestId('ocr-hint')).toHaveTextContent('Centre le magazine'); + expect(screen.getByText('Lecture…')).toBeTruthy(); + }); + + it('affiche des champs détectés avec leurs valeurs', () => { + renderOverlay({ + detected: { publication: 'Picsou Magazine', issueNumber: 547, date: '2024-06' }, + }); + + expect(screen.getByTestId('ocr-field-publication')).toHaveTextContent('Picsou Magazine'); + expect(screen.getByTestId('ocr-field-issue')).toHaveTextContent('547'); + expect(screen.getByTestId('ocr-field-date')).toHaveTextContent('2024-06'); + }); + + it('affiche des points de suspension tant qu’un champ est vide', () => { + renderOverlay(); + + expect(screen.getByTestId('ocr-field-publication')).toHaveTextContent('…'); + expect(screen.getByTestId('ocr-field-issue')).toHaveTextContent('…'); + expect(screen.getByTestId('ocr-field-date')).toHaveTextContent('…'); + }); + + it('masque la validation tant qu’aucune information n’est détectée', () => { + renderOverlay(); + + expect(screen.queryByTestId('ocr-confirm-detected')).toBeNull(); + }); + + it('valide les informations détectées', () => { + const callbacks = renderOverlay({ + detected: { publication: 'Picsou Magazine', issueNumber: null, date: null }, + }); + + fireEvent.press(screen.getByTestId('ocr-confirm-detected')); + expect(callbacks.onOpenConfirm).toHaveBeenCalled(); + }); + + it('propose code-barres, retour et torche', () => { + const callbacks = renderOverlay(); + + fireEvent.press(screen.getByTestId('ocr-barcode')); + expect(callbacks.onGoBarcode).toHaveBeenCalled(); + + fireEvent.press(screen.getByTestId('ocr-back')); + expect(callbacks.onBack).toHaveBeenCalled(); + + fireEvent.press(screen.getByTestId('ocr-torch')); + expect(callbacks.onToggleTorch).toHaveBeenCalled(); + expect(screen.getByLabelText('Activer la torche')).toBeTruthy(); + }); + + it('expose le libellé de torche allumée', () => { + render( + , + ); + + expect(screen.getByLabelText('Désactiver la torche')).toBeTruthy(); + }); + + it('affiche le guide seulement après plusieurs cycles sans numéro', () => { + const callbacks = renderOverlay({ + detected: { publication: 'Picsou Magazine', issueNumber: null, date: null }, + weakCycles: 4, + }); + + expect(screen.getByTestId('ocr-guidance-card')).toBeTruthy(); + + fireEvent.press(screen.getByTestId('ocr-guidance-barcode')); + expect(callbacks.onGoBarcode).toHaveBeenCalled(); + + fireEvent.press(screen.getByTestId('ocr-guidance-manual')); + expect(callbacks.onGoManual).toHaveBeenCalledWith({ + publication: 'Picsou Magazine', + issueNumber: null, + date: null, + }); + }); + + it('n’affiche pas le guide si le numéro est détecté', () => { + renderOverlay({ + detected: { publication: 'Picsou Magazine', issueNumber: 547, date: null }, + weakCycles: 4, + }); + + expect(screen.queryByTestId('ocr-guidance-card')).toBeNull(); + }); + + it('n’affiche pas le guide si la lecture est trop jeune', () => { + renderOverlay({ + detected: { publication: 'Picsou Magazine', issueNumber: null, date: null }, + weakCycles: 2, + }); + + expect(screen.queryByTestId('ocr-guidance-card')).toBeNull(); + }); +}); diff --git a/src/components/__tests__/ocr-confirm-overlay.test.tsx b/src/components/__tests__/ocr-confirm-overlay.test.tsx new file mode 100644 index 0000000..396afc0 --- /dev/null +++ b/src/components/__tests__/ocr-confirm-overlay.test.tsx @@ -0,0 +1,90 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { OcrConfirmOverlay } from '@/components/scan/ocr-confirm-overlay'; +import { makeCameraOcrStyles } from '@/components/scan/camera-ocr-styles'; +import { Colors } from '@/constants/theme'; + +const styles = makeCameraOcrStyles(Colors.light, { top: 0, bottom: 0 }); +const draft = { publication: 'Picsou Magazine', issueNumber: 547, date: '2024-06' }; + +function renderOverlay(onDraftChange = jest.fn()) { + const callbacks = { + onDraftChange, + onSearch: jest.fn(), + onGoManual: jest.fn(), + onBack: jest.fn(), + }; + render( + , + ); + return callbacks; +} + +describe('OcrConfirmOverlay', () => { + it('pré-remplit les champs avec le brouillon détecté', () => { + renderOverlay(); + + expect(screen.getByTestId('ocr-override-publication').props.value).toBe('Picsou Magazine'); + expect(screen.getByTestId('ocr-override-issue').props.value).toBe('547'); + expect(screen.getByTestId('ocr-override-date').props.value).toBe('2024-06'); + }); + + it('permet de corriger la publication et la date', () => { + const onDraftChange = jest.fn(); + renderOverlay(onDraftChange); + + fireEvent.changeText(screen.getByTestId('ocr-override-publication'), 'Donald Hebdo'); + expect(onDraftChange).toHaveBeenLastCalledWith({ ...draft, publication: 'Donald Hebdo' }); + + fireEvent.changeText(screen.getByTestId('ocr-override-date'), '1999-03'); + expect(onDraftChange).toHaveBeenLastCalledWith({ ...draft, date: '1999-03' }); + }); + + it('ne retient que les chiffres du numéro, sinon le remet à null', () => { + const onDraftChange = jest.fn(); + renderOverlay(onDraftChange); + + fireEvent.changeText(screen.getByTestId('ocr-override-issue'), '1a2b3'); + expect(onDraftChange).toHaveBeenLastCalledWith({ ...draft, issueNumber: 123 }); + + fireEvent.changeText(screen.getByTestId('ocr-override-issue'), 'abc'); + expect(onDraftChange).toHaveBeenLastCalledWith({ ...draft, issueNumber: null }); + }); + + it('affiche un numéro vide quand il est absent', () => { + render( + , + ); + + expect(screen.getByTestId('ocr-override-publication').props.value).toBe(''); + expect(screen.getByTestId('ocr-override-issue').props.value).toBe(''); + expect(screen.getByTestId('ocr-override-date').props.value).toBe(''); + }); + + it('lance la recherche, la saisie manuelle ou le retour caméra', () => { + const callbacks = renderOverlay(); + + fireEvent.press(screen.getByTestId('ocr-override-search')); + expect(callbacks.onSearch).toHaveBeenCalled(); + + fireEvent.press(screen.getByTestId('ocr-override-manual')); + expect(callbacks.onGoManual).toHaveBeenCalled(); + + fireEvent.press(screen.getByTestId('ocr-override-back')); + expect(callbacks.onBack).toHaveBeenCalled(); + }); +});