Aller au contenu

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"