PD-103 — Agent Developer — M8 capture-ui
Module
capture-ui (M8) — Ecran capture probatoire, previsualisation, validation/annulation, toggle OCR, indicateur progression.
Fichiers produits
| Fichier | Statut | Description |
src/screens/CaptureScreen.tsx | NOUVEAU | Ecran principal de capture probatoire |
src/components/capture/CapturePreview.tsx | NOUVEAU | Previsualisation image + hash + toggle OCR |
src/components/capture/CaptureProgressOverlay.tsx | NOUVEAU | Overlay progression : crypto, upload, etats |
src/components/capture/CaptureStatusBadge.tsx | NOUVEAU | Badge etat FSM (CAPTURED, UPLOADING, etc.) |
src/components/capture/DeferredCaptureList.tsx | NOUVEAU | Liste des captures differees avec reprise |
src/navigation/AppNavigator.tsx | MODIFIE | Ajout route Capture dans RootStackParamList |
Dependances
| Module | Import | Usage |
M1 capture-state-machine | Type CaptureState | Affichage etat courant |
M5 useCaptureStore | Selectors granulaires | Re-renders cibles (CA-284-14 pattern) |
M6 CaptureOrchestrator | execute(), cancel(), resumeDeferred() | Declenchement flux |
types.ts | CaptureId, CaptureUploadProgress, CAPTURE_ERROR_CODES | Typage + codes erreur |
expo-screen-capture | captureRef() | Capture PNG brut ecran |
react-native-get-random-values | crypto.randomUUID() | Generation capture_id (INV-103-31) |
react-i18next | useTranslation | Internationalisation |
Implementation
1. Navigation — src/navigation/AppNavigator.tsx
Ajout de la route Capture dans RootStackParamList et le navigator :
// Ajout type
export type RootStackParamList = {
Login: undefined;
Home: undefined;
Upload: undefined;
Proof: undefined;
Capture: undefined; // PD-103
TamperingLockout: undefined;
};
// Ajout screen (dans le bloc isAuthenticated)
<Stack.Screen
name="Capture"
component={CaptureScreen}
options={{ title: "Capture probatoire" }}
/>
2. Ecran principal — src/screens/CaptureScreen.tsx
// ============================================================================
// src/screens/CaptureScreen.tsx — PD-103 / M8
// ----------------------------------------------------------------------------
// Ecran principal de capture probatoire :
// previsualisation → validation/annulation → progression → resultat.
//
// Alignement : PD-103-specification.md v3, §5.4, §5.5, §5.7
// Invariants couverts : INV-103-01, INV-103-04, INV-103-07, INV-103-21, INV-103-29
// ============================================================================
import React, { useCallback, useRef, useState } from "react";
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Alert,
ActivityIndicator,
} from "react-native";
import { useNavigation } from "@react-navigation/native";
import { useTranslation } from "react-i18next";
import { captureRef } from "react-native-view-shot";
import { CaptureOrchestrator } from "../capture";
import type { CaptureOrchestratorResult } from "../capture/types";
import {
asCaptureId,
asDeviceId,
asAppVersion,
asTimestampDevice,
CAPTURE_MIME_TYPE,
} from "../capture/types";
import type { CaptureMetadata, CaptureUploadProgress } from "../capture/types";
import {
useCaptureStore,
selectActiveCaptureState,
selectIsProcessing,
selectErrorMessage,
selectUploadProgress,
selectHasDeferredCaptures,
} from "../store/useCaptureStore";
import { useAuth } from "../context/AuthContext";
import { CapturePreview } from "../components/capture/CapturePreview";
import { CaptureProgressOverlay } from "../components/capture/CaptureProgressOverlay";
import { CaptureStatusBadge } from "../components/capture/CaptureStatusBadge";
import { DeferredCaptureList } from "../components/capture/DeferredCaptureList";
// =============================================================================
// Types internes
// =============================================================================
type CapturePhase = "idle" | "preview" | "processing" | "result";
// =============================================================================
// Component
// =============================================================================
export default function CaptureScreen() {
const { t } = useTranslation("capture");
const navigation = useNavigation();
const { jwtToken, deviceId, appVersion } = useAuth();
// Granular selectors — CA-284-14 pattern
const captureState = useCaptureStore(selectActiveCaptureState);
const isProcessing = useCaptureStore(selectIsProcessing);
const errorMessage = useCaptureStore(selectErrorMessage);
const uploadProgress = useCaptureStore(selectUploadProgress);
const hasDeferredCaptures = useCaptureStore(selectHasDeferredCaptures);
// Local UI state
const [phase, setPhase] = useState<CapturePhase>("idle");
const [imageBytes, setImageBytes] = useState<Uint8Array | null>(null);
const [ocrEnabled, setOcrEnabled] = useState<boolean>(false);
const [lastResult, setLastResult] = useState<CaptureOrchestratorResult | null>(null);
// Abort controller pour annulation cooperative (INV-103-29)
const abortControllerRef = useRef<AbortController | null>(null);
// Orchestrator ref (stable across renders)
const orchestratorRef = useRef<CaptureOrchestrator | null>(null);
// Ref pour capture ecran
const captureViewRef = useRef<View>(null);
// -------------------------------------------------------------------------
// Capture ecran — INV-103-01, INV-103-02 (PNG brut, pas de transformation)
// -------------------------------------------------------------------------
const handleCapture = useCallback(async () => {
try {
// Capture PNG brut via react-native-view-shot (INV-103-01)
const uri = await captureRef(captureViewRef, {
format: "png",
quality: 1,
result: "tmpfile",
});
// Lire les bytes bruts depuis le fichier temporaire
const response = await fetch(uri);
const arrayBuffer = await response.arrayBuffer();
const bytes = new Uint8Array(arrayBuffer);
setImageBytes(bytes);
setPhase("preview");
} catch {
Alert.alert(
t("error.captureFailedTitle", "Erreur"),
t("error.captureFailed", "La capture a echoue. Veuillez reessayer."),
);
}
}, [t]);
// -------------------------------------------------------------------------
// Validation — Lance le flux complet M6 orchestrator
// -------------------------------------------------------------------------
const handleValidate = useCallback(async () => {
if (!imageBytes || !jwtToken) return;
setPhase("processing");
const abortController = new AbortController();
abortControllerRef.current = abortController;
// Construire metadata (INV-103-31 : captureId lowercase via asCaptureId)
const captureId = asCaptureId(crypto.randomUUID());
const metadata: CaptureMetadata = {
captureId,
deviceId: asDeviceId(deviceId),
appVersion: asAppVersion(appVersion),
mimeType: CAPTURE_MIME_TYPE,
sizeBytes: imageBytes.byteLength,
timestampDevice: asTimestampDevice(new Date().toISOString()),
};
// Callback progression → store (M8 ← M4 via M6)
const onProgress = (progress: CaptureUploadProgress): void => {
useCaptureStore.getState().setUploadProgress(progress);
};
try {
// Obtenir ou creer l'orchestrateur
if (!orchestratorRef.current) {
// NOTE: kekProvider et ocrService doivent etre injectes
// depuis le contexte applicatif (config/DI)
orchestratorRef.current = new CaptureOrchestrator({
kekProvider: /* injecte depuis contexte app */ {} as any,
ocrService: /* injecte depuis contexte app */ {} as any,
});
}
const result = await orchestratorRef.current.execute(
imageBytes,
metadata,
jwtToken,
ocrEnabled,
abortController.signal,
onProgress,
);
setLastResult(result);
setPhase("result");
} catch {
setPhase("result");
} finally {
abortControllerRef.current = null;
}
}, [imageBytes, jwtToken, deviceId, appVersion, ocrEnabled]);
// -------------------------------------------------------------------------
// Annulation — INV-103-21 (pre-upload) et INV-103-29 (pendant upload)
// -------------------------------------------------------------------------
const handleCancel = useCallback(async () => {
if (phase === "preview") {
// Pre-upload : reset sans persistance (INV-103-21)
setImageBytes(null);
setPhase("idle");
return;
}
if (phase === "processing") {
// Annulation cooperative (INV-103-29)
abortControllerRef.current?.abort();
// Annulation via orchestrator (abort S3 + purge)
if (orchestratorRef.current) {
await orchestratorRef.current.cancel(jwtToken ?? undefined);
}
setPhase("result");
}
}, [phase, jwtToken]);
// -------------------------------------------------------------------------
// Toggle OCR — CA-103-05
// -------------------------------------------------------------------------
const handleToggleOcr = useCallback(() => {
setOcrEnabled((prev) => !prev);
}, []);
// -------------------------------------------------------------------------
// Nouvelle capture (reset)
// -------------------------------------------------------------------------
const handleNewCapture = useCallback(() => {
setImageBytes(null);
setLastResult(null);
setPhase("idle");
useCaptureStore.getState().setError(null);
}, []);
// -------------------------------------------------------------------------
// Render
// -------------------------------------------------------------------------
return (
<View style={styles.container}>
{/* Zone de capture (ref pour captureRef) */}
<View ref={captureViewRef} style={styles.captureArea} collapsable={false}>
{phase === "idle" && (
<View style={styles.idleContent}>
<Text style={styles.title}>
{t("title", "Capture probatoire")}
</Text>
<Text style={styles.subtitle}>
{t("subtitle", "Capturez le contenu de l'ecran pour creer une preuve probatoire scellee.")}
</Text>
<TouchableOpacity
style={styles.captureButton}
onPress={handleCapture}
testID="capture-trigger-button"
>
<Text style={styles.captureButtonText}>
{t("action.capture", "Capturer l'ecran")}
</Text>
</TouchableOpacity>
{/* Captures differees en attente */}
{hasDeferredCaptures && (
<DeferredCaptureList
orchestrator={orchestratorRef.current}
jwtToken={jwtToken}
/>
)}
</View>
)}
</View>
{/* Previsualisation — CA-103-05 (toggle OCR) */}
{phase === "preview" && imageBytes && (
<CapturePreview
imageBytes={imageBytes}
ocrEnabled={ocrEnabled}
onToggleOcr={handleToggleOcr}
onValidate={handleValidate}
onCancel={handleCancel}
/>
)}
{/* Progression — overlay pendant processing */}
{phase === "processing" && (
<CaptureProgressOverlay
captureState={captureState}
uploadProgress={uploadProgress}
isProcessing={isProcessing}
onCancel={handleCancel}
/>
)}
{/* Resultat */}
{phase === "result" && (
<View style={styles.resultContainer}>
{lastResult?.success ? (
<View style={styles.resultSuccess} testID="capture-result-success">
<Text style={styles.resultSuccessTitle}>
{t("result.success", "Capture soumise")}
</Text>
<Text style={styles.resultSuccessDetail}>
{t("result.successDetail", "Votre capture est en cours de scellement. Vous recevrez une notification une fois le processus termine.")}
</Text>
</View>
) : (
<View style={styles.resultError} testID="capture-result-error">
{lastResult?.finalState === "UPLOAD_DEFERRED" ? (
<>
<Text style={styles.resultDeferredTitle}>
{t("result.deferred", "Capture en attente")}
</Text>
<Text style={styles.resultDeferredDetail}>
{t("result.deferredDetail", "La connexion reseau n'est pas disponible. La capture sera envoyee automatiquement des le retour du reseau.")}
</Text>
</>
) : (
<>
<Text style={styles.resultErrorTitle}>
{t("result.cancelled", "Capture annulee")}
</Text>
{errorMessage && (
<Text style={styles.resultErrorDetail}>{errorMessage}</Text>
)}
</>
)}
</View>
)}
<TouchableOpacity
style={styles.newCaptureButton}
onPress={handleNewCapture}
testID="capture-new-button"
>
<Text style={styles.newCaptureButtonText}>
{t("action.newCapture", "Nouvelle capture")}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
testID="capture-back-button"
>
<Text style={styles.backButtonText}>
{t("action.back", "Retour")}
</Text>
</TouchableOpacity>
</View>
)}
</View>
);
}
// =============================================================================
// Styles
// =============================================================================
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f9fafb",
},
captureArea: {
flex: 1,
},
idleContent: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 32,
},
title: {
fontSize: 22,
fontWeight: "700",
color: "#111827",
marginBottom: 8,
textAlign: "center",
},
subtitle: {
fontSize: 14,
color: "#6b7280",
textAlign: "center",
marginBottom: 32,
lineHeight: 20,
},
captureButton: {
backgroundColor: "#2563eb",
paddingVertical: 14,
paddingHorizontal: 32,
borderRadius: 10,
minWidth: 200,
alignItems: "center",
},
captureButtonText: {
color: "#ffffff",
fontSize: 16,
fontWeight: "600",
},
resultContainer: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "#f9fafb",
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 32,
},
resultSuccess: {
backgroundColor: "#ecfdf5",
borderRadius: 12,
padding: 20,
marginBottom: 24,
width: "100%",
},
resultSuccessTitle: {
fontSize: 18,
fontWeight: "600",
color: "#065f46",
textAlign: "center",
marginBottom: 8,
},
resultSuccessDetail: {
fontSize: 14,
color: "#047857",
textAlign: "center",
lineHeight: 20,
},
resultError: {
backgroundColor: "#fef2f2",
borderRadius: 12,
padding: 20,
marginBottom: 24,
width: "100%",
},
resultErrorTitle: {
fontSize: 18,
fontWeight: "600",
color: "#991b1b",
textAlign: "center",
marginBottom: 8,
},
resultErrorDetail: {
fontSize: 14,
color: "#7f1d1d",
textAlign: "center",
lineHeight: 20,
},
resultDeferredTitle: {
fontSize: 18,
fontWeight: "600",
color: "#92400e",
textAlign: "center",
marginBottom: 8,
},
resultDeferredDetail: {
fontSize: 14,
color: "#78350f",
textAlign: "center",
lineHeight: 20,
},
newCaptureButton: {
backgroundColor: "#2563eb",
paddingVertical: 14,
paddingHorizontal: 32,
borderRadius: 10,
minWidth: 200,
alignItems: "center",
marginBottom: 12,
},
newCaptureButtonText: {
color: "#ffffff",
fontSize: 16,
fontWeight: "600",
},
backButton: {
paddingVertical: 12,
paddingHorizontal: 32,
},
backButtonText: {
color: "#6b7280",
fontSize: 14,
},
});
3. Previsualisation — src/components/capture/CapturePreview.tsx
// ============================================================================
// src/components/capture/CapturePreview.tsx — PD-103 / M8
// ----------------------------------------------------------------------------
// Previsualisation de la capture avant validation.
// Toggle OCR (CA-103-05), boutons valider/annuler.
//
// INV-103-01 : image affichee = image_original_bytes, aucune transformation.
// INV-103-04 : OCR desactivable par l'utilisateur.
// ============================================================================
import React, { useMemo } from "react";
import { View, Text, Image, TouchableOpacity, Switch, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
// =============================================================================
// Props
// =============================================================================
export interface CapturePreviewProps {
readonly imageBytes: Uint8Array;
readonly ocrEnabled: boolean;
readonly onToggleOcr: () => void;
readonly onValidate: () => void;
readonly onCancel: () => void;
}
// =============================================================================
// Component
// =============================================================================
export function CapturePreview({
imageBytes,
ocrEnabled,
onToggleOcr,
onValidate,
onCancel,
}: Readonly<CapturePreviewProps>) {
const { t } = useTranslation("capture");
// Construire data URI pour affichage (lecture seule, pas de transformation)
const imageUri = useMemo(() => {
let binary = "";
for (let i = 0; i < imageBytes.length; i++) {
binary += String.fromCharCode(imageBytes[i]);
}
return `data:image/png;base64,${globalThis.btoa(binary)}`;
}, [imageBytes]);
const fileSizeKb = Math.round(imageBytes.byteLength / 1024);
return (
<View style={styles.overlay} testID="capture-preview">
{/* Image preview — INV-103-01 : pas de transformation */}
<View style={styles.imageContainer}>
<Image
source={{ uri: imageUri }}
style={styles.image}
resizeMode="contain"
testID="capture-preview-image"
/>
</View>
{/* Metadata */}
<View style={styles.metadataRow}>
<Text style={styles.metadataText}>PNG</Text>
<Text style={styles.metadataSeparator}>|</Text>
<Text style={styles.metadataText}>{fileSizeKb} Ko</Text>
</View>
{/* Toggle OCR — CA-103-05, INV-103-04 */}
<View style={styles.ocrRow} testID="capture-ocr-toggle">
<Text style={styles.ocrLabel}>
{t("preview.ocrLabel", "Extraction de texte (OCR)")}
</Text>
<Switch
value={ocrEnabled}
onValueChange={onToggleOcr}
trackColor={{ false: "#d1d5db", true: "#93c5fd" }}
thumbColor={ocrEnabled ? "#2563eb" : "#f4f3f4"}
testID="capture-ocr-switch"
/>
</View>
<Text style={styles.ocrHint}>
{t("preview.ocrHint", "Extraction locale uniquement. Sans valeur probatoire.")}
</Text>
{/* Actions */}
<View style={styles.actionsRow}>
<TouchableOpacity
style={styles.cancelButton}
onPress={onCancel}
testID="capture-cancel-button"
>
<Text style={styles.cancelButtonText}>
{t("action.cancel", "Annuler")}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.validateButton}
onPress={onValidate}
testID="capture-validate-button"
>
<Text style={styles.validateButtonText}>
{t("action.validate", "Valider et envoyer")}
</Text>
</TouchableOpacity>
</View>
</View>
);
}
// =============================================================================
// Styles
// =============================================================================
const styles = StyleSheet.create({
overlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "#f9fafb",
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 32,
},
imageContainer: {
flex: 1,
borderRadius: 12,
overflow: "hidden",
backgroundColor: "#e5e7eb",
marginBottom: 12,
},
image: {
width: "100%",
height: "100%",
},
metadataRow: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
marginBottom: 16,
},
metadataText: {
fontSize: 13,
color: "#6b7280",
fontWeight: "500",
},
metadataSeparator: {
marginHorizontal: 8,
color: "#d1d5db",
},
ocrRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 4,
marginBottom: 4,
},
ocrLabel: {
fontSize: 14,
color: "#374151",
fontWeight: "500",
},
ocrHint: {
fontSize: 12,
color: "#9ca3af",
paddingHorizontal: 4,
marginBottom: 20,
fontStyle: "italic",
},
actionsRow: {
flexDirection: "row",
gap: 12,
},
cancelButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 10,
borderWidth: 1,
borderColor: "#d1d5db",
alignItems: "center",
},
cancelButtonText: {
color: "#374151",
fontSize: 16,
fontWeight: "500",
},
validateButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 10,
backgroundColor: "#2563eb",
alignItems: "center",
},
validateButtonText: {
color: "#ffffff",
fontSize: 16,
fontWeight: "600",
},
});
4. Overlay progression — src/components/capture/CaptureProgressOverlay.tsx
// ============================================================================
// src/components/capture/CaptureProgressOverlay.tsx — PD-103 / M8
// ----------------------------------------------------------------------------
// Overlay de progression pendant le flux capture probatoire.
// Affiche l'etat courant FSM, barre de progression upload, bouton annuler.
//
// Pattern : src/components/seal/SealProgressCard.tsx (steps + badges)
// Pattern : src/components/upload/UploadProgressBar.tsx (barre + stats)
// ============================================================================
import React, { useMemo } from "react";
import {
View,
Text,
TouchableOpacity,
ActivityIndicator,
StyleSheet,
} from "react-native";
import { useTranslation } from "react-i18next";
import type { CaptureState, CaptureUploadProgress } from "../../capture/types";
import { CaptureStatusBadge } from "./CaptureStatusBadge";
// =============================================================================
// Props
// =============================================================================
export interface CaptureProgressOverlayProps {
readonly captureState: CaptureState | null;
readonly uploadProgress: CaptureUploadProgress | null;
readonly isProcessing: boolean;
readonly onCancel: () => void;
}
// =============================================================================
// Step computation
// =============================================================================
interface ProgressStep {
readonly label: string;
readonly key: string;
readonly status: "completed" | "active" | "pending";
}
const CAPTURE_STEPS = [
{ key: "purge", states: [] },
{ key: "hash", states: ["CAPTURED"] },
{ key: "encrypt", states: ["CAPTURED"] },
{ key: "upload", states: ["UPLOADING"] },
{ key: "submitted", states: ["UPLOADED"] },
] as const;
function computeSteps(
state: CaptureState | null,
t: (key: string, defaultValue: string) => string,
): readonly ProgressStep[] {
const stateOrder: Record<string, number> = {
CAPTURED: 1,
UPLOADING: 3,
UPLOADED: 4,
UPLOAD_DEFERRED: 3,
};
const currentOrder = state ? (stateOrder[state] ?? 0) : 0;
return [
{
key: "purge",
label: t("progress.purge", "Nettoyage"),
status: currentOrder >= 1 ? "completed" : "active",
},
{
key: "hash",
label: t("progress.hash", "Calcul d'integrite"),
status: currentOrder >= 2 ? "completed" : currentOrder >= 1 ? "active" : "pending",
},
{
key: "encrypt",
label: t("progress.encrypt", "Chiffrement"),
status: currentOrder >= 3 ? "completed" : currentOrder >= 2 ? "active" : "pending",
},
{
key: "upload",
label: t("progress.upload", "Envoi securise"),
status: currentOrder >= 4 ? "completed" : currentOrder >= 3 ? "active" : "pending",
},
{
key: "submitted",
label: t("progress.submitted", "Soumis au scellement"),
status: currentOrder >= 4 ? "completed" : "pending",
},
];
}
// =============================================================================
// Component
// =============================================================================
export function CaptureProgressOverlay({
captureState,
uploadProgress,
isProcessing,
onCancel,
}: Readonly<CaptureProgressOverlayProps>) {
const { t } = useTranslation("capture");
const steps = useMemo(
() => computeSteps(captureState, t),
[captureState, t],
);
const showUploadBar = captureState === "UPLOADING" && uploadProgress !== null;
return (
<View style={styles.overlay} testID="capture-progress-overlay">
<View style={styles.card}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>
{t("progress.title", "Capture en cours...")}
</Text>
{captureState && <CaptureStatusBadge state={captureState} />}
</View>
{/* Steps */}
<View style={styles.stepsContainer}>
{steps.map((step, index) => (
<StepRow
key={step.key}
step={step}
index={index}
isLast={index === steps.length - 1}
/>
))}
</View>
{/* Upload progress bar */}
{showUploadBar && (
<View style={styles.uploadBarContainer}>
<View style={styles.barBackground}>
<View
style={[styles.barFill, { width: `${uploadProgress.percent}%` }]}
/>
</View>
<View style={styles.uploadStats}>
<Text style={styles.uploadPercent}>
{uploadProgress.percent}%
</Text>
{uploadProgress.totalParts != null && (
<Text style={styles.uploadParts}>
{t("progress.parts", {
current: uploadProgress.currentPart ?? 0,
total: uploadProgress.totalParts,
defaultValue: "Part {{current}}/{{total}}",
})}
</Text>
)}
</View>
</View>
)}
{/* Spinner */}
{isProcessing && (
<ActivityIndicator
size="small"
color="#2563eb"
style={styles.spinner}
/>
)}
{/* Cancel button — INV-103-29 */}
<TouchableOpacity
style={styles.cancelButton}
onPress={onCancel}
testID="capture-progress-cancel"
>
<Text style={styles.cancelButtonText}>
{t("action.cancel", "Annuler")}
</Text>
</TouchableOpacity>
</View>
</View>
);
}
// =============================================================================
// Sub-component — StepRow (extracted per SonarLint pattern)
// =============================================================================
const StepRow = React.memo<{
step: ProgressStep;
index: number;
isLast: boolean;
}>(({ step, index, isLast }) => (
<View style={styles.stepRow}>
<View
style={[
styles.stepCircle,
step.status === "completed" && styles.stepCircleCompleted,
step.status === "active" && styles.stepCircleActive,
step.status === "pending" && styles.stepCirclePending,
]}
>
<Text
style={[
styles.stepCircleText,
step.status === "completed" && styles.stepCircleTextCompleted,
]}
>
{step.status === "completed" ? "\u2713" : index + 1}
</Text>
</View>
{!isLast && (
<View
style={[
styles.stepLine,
step.status === "completed" && styles.stepLineCompleted,
]}
/>
)}
<Text
style={[
styles.stepLabel,
step.status === "completed" && styles.stepLabelCompleted,
step.status === "active" && styles.stepLabelActive,
]}
>
{step.label}
</Text>
</View>
));
StepRow.displayName = "StepRow";
// =============================================================================
// Styles
// =============================================================================
const styles = StyleSheet.create({
overlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0,0,0,0.5)",
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 24,
},
card: {
backgroundColor: "#ffffff",
borderRadius: 16,
padding: 24,
width: "100%",
maxWidth: 360,
shadowColor: "#000000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 8,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 20,
},
title: {
fontSize: 16,
fontWeight: "600",
color: "#111827",
flex: 1,
},
stepsContainer: {
marginBottom: 16,
},
stepRow: {
flexDirection: "row",
alignItems: "center",
marginBottom: 12,
},
stepCircle: {
width: 26,
height: 26,
borderRadius: 13,
alignItems: "center",
justifyContent: "center",
borderWidth: 2,
},
stepCircleCompleted: {
backgroundColor: "#059669",
borderColor: "#059669",
},
stepCircleActive: {
backgroundColor: "#ffffff",
borderColor: "#2563eb",
},
stepCirclePending: {
backgroundColor: "#ffffff",
borderColor: "#d1d5db",
},
stepCircleText: {
fontSize: 11,
fontWeight: "600",
color: "#6b7280",
},
stepCircleTextCompleted: {
color: "#ffffff",
},
stepLine: {
width: 2,
height: 12,
backgroundColor: "#d1d5db",
position: "absolute",
left: 12,
top: 26,
},
stepLineCompleted: {
backgroundColor: "#059669",
},
stepLabel: {
marginLeft: 12,
fontSize: 14,
color: "#9ca3af",
},
stepLabelCompleted: {
color: "#059669",
fontWeight: "500",
},
stepLabelActive: {
color: "#2563eb",
fontWeight: "600",
},
uploadBarContainer: {
marginBottom: 16,
},
barBackground: {
height: 6,
backgroundColor: "#e5e7eb",
borderRadius: 3,
overflow: "hidden",
},
barFill: {
height: "100%",
backgroundColor: "#2563eb",
borderRadius: 3,
},
uploadStats: {
flexDirection: "row",
justifyContent: "space-between",
marginTop: 4,
},
uploadPercent: {
fontSize: 12,
fontWeight: "600",
color: "#2563eb",
},
uploadParts: {
fontSize: 12,
color: "#6b7280",
},
spinner: {
marginBottom: 16,
},
cancelButton: {
paddingVertical: 12,
borderRadius: 8,
borderWidth: 1,
borderColor: "#d1d5db",
alignItems: "center",
},
cancelButtonText: {
color: "#374151",
fontSize: 14,
fontWeight: "500",
},
});
5. Badge etat — src/components/capture/CaptureStatusBadge.tsx
// ============================================================================
// src/components/capture/CaptureStatusBadge.tsx — PD-103 / M8
// ----------------------------------------------------------------------------
// Badge visuel pour l'etat courant de la FSM capture (§5.7).
// Pattern : SealProgressCard badges.
// ============================================================================
import React from "react";
import { View, Text, StyleSheet } from "react-native";
import type { CaptureState } from "../../capture/types";
// =============================================================================
// Props
// =============================================================================
export interface CaptureStatusBadgeProps {
readonly state: CaptureState;
}
// =============================================================================
// State → Visual mapping
// =============================================================================
interface BadgeStyle {
readonly backgroundColor: string;
readonly textColor: string;
readonly label: string;
}
const STATE_BADGE_MAP: Readonly<Record<CaptureState, BadgeStyle>> = {
CAPTURED: { backgroundColor: "#dbeafe", textColor: "#1e40af", label: "Capture" },
UPLOADING: { backgroundColor: "#fef3c7", textColor: "#92400e", label: "Envoi" },
UPLOAD_DEFERRED: { backgroundColor: "#e5e7eb", textColor: "#374151", label: "En attente" },
UPLOADED: { backgroundColor: "#d1fae5", textColor: "#065f46", label: "Envoye" },
PENDING_SEAL: { backgroundColor: "#fef3c7", textColor: "#92400e", label: "Scellement" },
SEALED: { backgroundColor: "#ecfdf5", textColor: "#065f46", label: "Scelle" },
ANCHOR_CONFIRMED: { backgroundColor: "#ecfdf5", textColor: "#065f46", label: "Ancre" },
CANCELLED: { backgroundColor: "#fee2e2", textColor: "#991b1b", label: "Annule" },
};
// =============================================================================
// Component
// =============================================================================
export function CaptureStatusBadge({ state }: Readonly<CaptureStatusBadgeProps>) {
const badge = STATE_BADGE_MAP[state];
return (
<View
style={[styles.badge, { backgroundColor: badge.backgroundColor }]}
testID={`capture-badge-${state}`}
>
<Text style={[styles.badgeText, { color: badge.textColor }]}>
{badge.label}
</Text>
</View>
);
}
// =============================================================================
// Styles
// =============================================================================
const styles = StyleSheet.create({
badge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
},
badgeText: {
fontSize: 11,
fontWeight: "600",
},
});
6. Liste captures differees — src/components/capture/DeferredCaptureList.tsx
// ============================================================================
// src/components/capture/DeferredCaptureList.tsx — PD-103 / M8
// ----------------------------------------------------------------------------
// Liste des captures en UPLOAD_DEFERRED avec possibilite de reprise.
//
// INV-103-24 : reprise uniquement si JWT valide + nouvelle URL pre-signee.
// INV-103-38 : TTL differe visible pour l'utilisateur.
// ============================================================================
import React, { useCallback } from "react";
import { View, Text, TouchableOpacity, FlatList, StyleSheet, Alert } from "react-native";
import { useTranslation } from "react-i18next";
import type { CaptureOrchestrator } from "../../capture/orchestrator";
import type { CaptureId, DeferredCapturePayload } from "../../capture/types";
import {
useCaptureStore,
selectDeferredCaptures,
} from "../../store/useCaptureStore";
import { CaptureStatusBadge } from "./CaptureStatusBadge";
// =============================================================================
// Props
// =============================================================================
export interface DeferredCaptureListProps {
readonly orchestrator: CaptureOrchestrator | null;
readonly jwtToken: string | null;
}
// =============================================================================
// Component
// =============================================================================
export function DeferredCaptureList({
orchestrator,
jwtToken,
}: Readonly<DeferredCaptureListProps>) {
const { t } = useTranslation("capture");
const deferredCaptures = useCaptureStore(selectDeferredCaptures);
const entries = Object.values(deferredCaptures);
const handleResume = useCallback(
async (captureId: CaptureId) => {
if (!orchestrator || !jwtToken) {
Alert.alert(
t("error.resumeTitle", "Reprise impossible"),
t("error.resumeNoAuth", "Authentification requise pour reprendre l'envoi."),
);
return;
}
const result = await orchestrator.resumeDeferred(captureId, jwtToken);
if (result.success) {
Alert.alert(
t("deferred.resumeSuccessTitle", "Envoi reussi"),
t("deferred.resumeSuccess", "La capture a ete envoyee avec succes."),
);
} else if (result.finalState === "UPLOAD_DEFERRED") {
Alert.alert(
t("deferred.resumeFailTitle", "Echec de reprise"),
t("deferred.resumeFail", "La connexion n'est pas disponible. Reessayez plus tard."),
);
}
},
[orchestrator, jwtToken, t],
);
if (entries.length === 0) return null;
return (
<View style={styles.container} testID="deferred-capture-list">
<Text style={styles.sectionTitle}>
{t("deferred.title", "Captures en attente")}
</Text>
<FlatList
data={entries}
keyExtractor={extractKey}
renderItem={({ item }) => (
<DeferredCaptureRow
payload={item}
onResume={handleResume}
t={t}
/>
)}
scrollEnabled={false}
/>
</View>
);
}
// =============================================================================
// Row sub-component (extracted per SonarLint pattern)
// =============================================================================
function extractKey(item: DeferredCapturePayload): string {
return item.captureId as string;
}
function DeferredCaptureRow({
payload,
onResume,
t,
}: {
readonly payload: DeferredCapturePayload;
readonly onResume: (id: CaptureId) => void;
readonly t: (key: string, defaultValue: string) => string;
}) {
const deferredDate = new Date(payload.deferredAt);
const dateStr = deferredDate.toLocaleDateString();
const timeStr = deferredDate.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
const sizeKb = Math.round(payload.metadata.sizeBytes / 1024);
return (
<View style={styles.row} testID={`deferred-row-${payload.captureId}`}>
<View style={styles.rowInfo}>
<View style={styles.rowHeader}>
<CaptureStatusBadge state="UPLOAD_DEFERRED" />
<Text style={styles.rowDate}>{dateStr} {timeStr}</Text>
</View>
<Text style={styles.rowSize}>{sizeKb} Ko</Text>
</View>
<TouchableOpacity
style={styles.resumeButton}
onPress={() => onResume(payload.captureId)}
testID={`deferred-resume-${payload.captureId}`}
>
<Text style={styles.resumeButtonText}>
{t("deferred.resume", "Reprendre")}
</Text>
</TouchableOpacity>
</View>
);
}
// =============================================================================
// Styles
// =============================================================================
const styles = StyleSheet.create({
container: {
marginTop: 32,
width: "100%",
},
sectionTitle: {
fontSize: 15,
fontWeight: "600",
color: "#374151",
marginBottom: 12,
},
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: "#ffffff",
borderRadius: 10,
padding: 12,
marginBottom: 8,
shadowColor: "#000000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 1,
},
rowInfo: {
flex: 1,
marginRight: 12,
},
rowHeader: {
flexDirection: "row",
alignItems: "center",
gap: 8,
marginBottom: 4,
},
rowDate: {
fontSize: 12,
color: "#6b7280",
},
rowSize: {
fontSize: 12,
color: "#9ca3af",
marginLeft: 36,
},
resumeButton: {
backgroundColor: "#2563eb",
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 8,
},
resumeButtonText: {
color: "#ffffff",
fontSize: 13,
fontWeight: "600",
},
});
Couverture invariants
| Invariant | Mecanisme UI | Composant |
| INV-103-01 | PNG brut affiche en preview, pas de transformation (Image resizeMode="contain") | CapturePreview |
| INV-103-02 | Aucun appel a Image Manipulator ou resize | CaptureScreen, CapturePreview |
| INV-103-04 | Toggle OCR desactivable dans preview (Switch) | CapturePreview |
| INV-103-07 | purgeStale() invoque via orchestrateur au declenchement | CaptureScreen (via M6) |
| INV-103-21 | Bouton annuler en preview → aucune persistance | CaptureScreen |
| INV-103-29 | AbortController.abort() + orchestrator.cancel() pendant upload | CaptureScreen |
| INV-103-31 | asCaptureId(crypto.randomUUID()) normalise lowercase | CaptureScreen |
Couverture criteres d'acceptation
| Critere | Mecanisme UI | Observable |
| CA-103-05 | Switch OCR dans CapturePreview avec label explicite | Toggle modifie ocrEnabled avant validation |
| CA-103-07 | Post-validation, flux lance automatiquement sans input supplementaire | handleValidate enchaine M6.execute() |
| CA-103-12 | purgeStale() invoque dans orchestrateur avant capture | Trace horodatee (observabilite M7) |
| CA-103-20 | Bouton annuler pendant upload → abort + purge | AbortController + orchestrator.cancel() |
Matrice de couverture tests
| Test-ID | Fichier(s) UI | Verification |
| TC-NOM-01 | CaptureScreen | Flux complet idle → preview → processing → result (UPLOADED) |
| TC-NOM-02 | CapturePreview | OCR switch off → champs OCR absents dans payload |
| TC-NOM-03 | CapturePreview, CaptureScreen | OCR switch on + erreur → flux continue |
| TC-NOM-04 | CaptureScreen | purgeStale invoque au declenchement (via M6) |
| TC-NOM-05 | CaptureProgressOverlay | Etat UPLOAD_DEFERRED affiche dans overlay |
| TC-NOM-14 | CaptureScreen | Annulation en UPLOADING → CANCELLED + abort S3 |
| TC-ERR-02 | CaptureScreen | Annulation en preview → retour idle, aucune persistance |
Hypotheses
| ID | Hypothese | Impact si faux |
| HT-M8-01 | react-native-view-shot (captureRef) produit un buffer PNG brut sans transformation (INV-103-01). | Si transformation implicite : interposer lecture directe du framebuffer. |
| HT-M8-02 | kekProvider et ocrService sont injectes via contexte applicatif (DI). | Si pas de DI existant : creer un CaptureContext provider. |
| HT-M8-03 | useAuth() expose jwtToken, deviceId, appVersion. | Si interface differente : adapter les imports. |
| HT-M8-04 | crypto.randomUUID() est disponible via react-native-get-random-values polyfill. | Si absent : ajouter le polyfill dans l'entry point. |
Decisions architecturales
architectural_decisions:
- decision: "react-native-view-shot (captureRef) pour la capture ecran"
rationale: "API Expo-compatible, produit PNG natif sans transformation, compatible INV-103-01."
alternatives_considered:
- "expo-screen-capture : API plus limitee, controle moindre sur le format de sortie"
- "Native module custom : overhead de maintenance pour un gain marginal"
trade_offs: "Dependance a une lib tierce ; comportement a valider a chaque upgrade SDK"
- decision: "AbortController pour annulation cooperative"
rationale: "Standard Web API, supporte par fetch et les timers async, permet annulation propre du pipeline M6 (INV-103-29)."
alternatives_considered:
- "Flag boolean dans le store : risque de race conditions avec le pipeline async"
trade_offs: "L'abort ne garantit pas l'annulation instantanee des appels reseau en cours"