Files
impact/lib/features/capture/capture_screen.dart
qguillaume cec9838e9b refactor: perf getAllSessions, zéro warning analyze, docs à jour
- getAllSessions : élimine le N+1 (une requête par session + par analyse)
  au profit de 3 requêtes groupées (sessions, analyses IN, shots IN),
  découpées en paquets de 500 pour rester sous la limite SQLite. Assemblage
  en mémoire via maps — accueil/historique/stats ne ralentiront plus au fil
  des mois
- flutter analyze : 28 -> 0 problème. APIs dépréciées migrées (withOpacity
  -> withValues, scale -> scaleByDouble, DropdownButtonFormField.value ->
  initialValue, dialogBackgroundColor -> dialogTheme, activeColor ->
  activeThumbColor), use_build_context_synchronously corrigés dans
  l'armurerie (repository capturé avant await + gardes mounted), print ->
  debugPrint, identifiants TopLeft/... -> lowerCamelCase, blocs if, champ final
- _showShotDetails dédupliqué : bottom sheet extraite dans le widget partagé
  shot_details_sheet.dart, utilisée par l'écran d'analyse et l'éditeur d'impacts
- CLAUDE.md : sections Features réécrites (retrait détection auto/références,
  ajout capture/plotting manuel) ; description pubspec renseignée

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 10:40:25 +02:00

1086 lines
41 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:io';
import 'dart:math' as math;
import 'dart:async';
import 'dart:isolate';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:camera/camera.dart';
import 'package:provider/provider.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/target_type.dart';
import '../crop/crop_screen.dart';
import '../session/session_provider.dart';
import 'widgets/image_source_button.dart';
import '../../services/opencv_target_service.dart';
import '../../services/parallelism_service.dart'; // NOUVEAU
import 'package:image/image.dart' as img;
import 'package:path_provider/path_provider.dart';
/// Paramètres sérialisables pour convertir une frame caméra dans un Isolate.
class _FrameConvertParams {
final int width;
final int height;
final Uint8List yBytes;
final Uint8List uBytes;
final Uint8List vBytes;
final int yRowStride;
final int uvRowStride;
final int uvPixelStride;
final String outputPath;
_FrameConvertParams({
required this.width,
required this.height,
required this.yBytes,
required this.uBytes,
required this.vBytes,
required this.yRowStride,
required this.uvRowStride,
required this.uvPixelStride,
required this.outputPath,
});
}
/// Conversion YUV420 → RGB (sous-échantillonnée /2) + encodage JPEG, exécutée
/// dans un Isolate pour ne JAMAIS bloquer le thread UI pendant l'aperçu caméra
/// (c'était la cause principale du lag à la prise de photo). Retourne true si
/// le fichier d'analyse a bien été écrit.
bool _convertAndEncodeFrameIsolate(_FrameConvertParams p) {
try {
final int targetW = p.width ~/ 2;
final int targetH = p.height ~/ 2;
final image = img.Image(width: targetW, height: targetH);
for (int y = 0; y < targetH; y++) {
for (int x = 0; x < targetW; x++) {
final int srcX = x * 2;
final int srcY = y * 2;
final int yIndex = srcY * p.yRowStride + srcX;
final int uvIndex =
(srcY ~/ 2) * p.uvRowStride + (srcX ~/ 2) * p.uvPixelStride;
final int yVal = p.yBytes[yIndex];
final int uVal = p.uBytes[uvIndex] - 128;
final int vVal = p.vBytes[uvIndex] - 128;
final int r = (yVal + 1.402 * vVal).clamp(0, 255).toInt();
final int g = (yVal - 0.344136 * uVal - 0.714136 * vVal)
.clamp(0, 255)
.toInt();
final int b = (yVal + 1.772 * uVal).clamp(0, 255).toInt();
image.setPixelRgb(x, y, r, g, b);
}
}
// PERF : qualité 50 pour la frame d'analyse temps réel (jetable).
File(p.outputPath).writeAsBytesSync(img.encodeJpg(image, quality: 50));
return true;
} catch (_) {
return false;
}
}
class CaptureScreen extends StatefulWidget {
const CaptureScreen({super.key});
@override
State<CaptureScreen> createState() => _CaptureScreenState();
}
class _CaptureScreenState extends State<CaptureScreen>
with SingleTickerProviderStateMixin {
final ImagePicker _picker = ImagePicker();
final TargetType _selectedType = TargetType.concentric;
final OpenCVTargetService _opencvService = OpenCVTargetService();
// NOUVEAU : Service IMU de parallélisme
// L'angle passe à l'ORANGE dès 3° d'inclinaison (pitch ou roll).
// Petite hystérésis : il faut revenir sous 2° pour repasser au vert,
// ce qui évite le clignotement quand la main tremble autour de 3°.
final ParallelismService _parallelismService = ParallelismService(
alignThreshold: 2.0, // retour au vert : worstAngle ≤ 2°
misalignThreshold: 3.0, // passage à l'orange : worstAngle > 3°
);
String? _selectedImagePath;
bool _isLoading = false;
bool _isCapturing = false; // garde anti-double-capture pendant le traitement
// Chemin de la photo tout juste capturée, affichée FIGÉE pendant le
// traitement (downscale). Tant qu'il est null, la capture physique est
// encore en cours → l'utilisateur doit rester immobile. Une fois rempli,
// la photo est prise et l'utilisateur peut bouger.
String? _capturedPreviewPath;
// Caméra live
CameraController? _cameraController;
List<CameraDescription>? _cameras;
bool _showLiveCamera = false;
// Animation pulsation du viseur
late AnimationController _scanAnimationController;
// Détection OpenCV (cible circulaire) — on garde le résultat COMPLET
TargetDetectionResult? _targetResult; // NOUVEAU : centre + rayon de la cible
bool _isAnalyzingFrame = false;
// NOUVEAU : Données IMU en temps réel
ParallelismData? _parallelismData;
StreamSubscription<ParallelismData>? _parallelismSubscription;
@override
void initState() {
super.initState();
_scanAnimationController = AnimationController(
duration: const Duration(seconds: 2),
vsync: this,
)..repeat(reverse: true);
}
@override
void dispose() {
_cameraController?.dispose();
_scanAnimationController.dispose();
_parallelismSubscription?.cancel(); // NOUVEAU
_parallelismService.dispose(); // NOUVEAU
super.dispose();
}
// ─────────────────────────────────────────────────────────────────────────
// NOUVEAU : Démarre l'écoute IMU et met à jour l'UI en temps réel
// ─────────────────────────────────────────────────────────────────────────
void _startParallelismDetection() {
_parallelismSubscription?.cancel();
_parallelismService.start();
_parallelismSubscription = _parallelismService.stream.listen((data) {
if (!mounted || !_showLiveCamera) return;
// Le capteur émet à ~20 Hz, mais l'affichage ne dépend que du statut
// (couleur verte/orange) et des angles arrondis à 0,1° (message). On ne
// reconstruit donc QUE quand l'un d'eux change réellement → bien moins de
// rebuilds inutiles de la vue caméra.
final prev = _parallelismData;
final bool changed = prev == null ||
prev.status != data.status ||
prev.pitchDegrees.toStringAsFixed(1) !=
data.pitchDegrees.toStringAsFixed(1) ||
prev.rollDegrees.toStringAsFixed(1) !=
data.rollDegrees.toStringAsFixed(1);
_parallelismData = data;
if (changed) {
setState(() {});
}
});
}
void _stopParallelismDetection() {
_parallelismSubscription?.cancel();
_parallelismSubscription = null;
_parallelismService.stop();
_parallelismData = null;
}
// ─────────────────────────────────────────────────────────────────────────
// Calcule la couleur et le message depuis l'IMU + la détection de cible
// ─────────────────────────────────────────────────────────────────────────
/// La cible circulaire est-elle détectée et raisonnablement cadrée ?
bool get _targetReady {
final t = _targetResult;
if (t == null || !t.success) return false;
final bool centered =
t.centerX > 0.15 && t.centerX < 0.85 && t.centerY > 0.15 && t.centerY < 0.85;
final bool bigEnough = t.radius > 0.12;
return centered && bigEnough;
}
/// Couleur du cadre : dépend UNIQUEMENT du parallélisme IMU.
/// (La détection de cible sert seulement à dessiner le cercle, elle ne
/// bloque jamais le passage au vert.)
Color get _frameColor {
final bool imuAligned =
_parallelismData == null || _parallelismData!.isAligned;
return imuAligned ? const Color(0xFF00FF00) : Colors.orange;
}
/// Message d'aide selon le parallélisme IMU.
String get _alignmentMessage {
if (_parallelismData == null ||
_parallelismData!.status == ParallelismStatus.unknown) {
return 'ALIGNEZ LA CIBLE DANS LE CADRE';
}
// Aligné → message de validation (avec bonus si la cible est détectée)
if (_parallelismData!.isAligned) {
return _targetReady
? 'PARFAIT — CIBLE DÉTECTÉE, PRÊT'
: 'PARALLÈLE OK — PRÊT À PHOTOGRAPHIER';
}
// Mal aligné → message directif selon l'axe le plus dévié
final double pitch = _parallelismData!.pitchDegrees;
final double roll = _parallelismData!.rollDegrees;
if (pitch.abs() >= roll.abs()) {
return pitch > 0
? 'INCLINEZ VERS L\'AVANT (${pitch.toStringAsFixed(1)}°)'
: 'INCLINEZ VERS L\'ARRIÈRE (${(-pitch).toStringAsFixed(1)}°)';
} else {
return roll > 0
? 'INCLINEZ VERS LA GAUCHE (${roll.toStringAsFixed(1)}°)'
: 'INCLINEZ VERS LA DROITE (${(-roll).toStringAsFixed(1)}°)';
}
}
// ─────────────────────────────────────────────────────────────────────────
// Initialisation de la caméra (inchangée, sauf ajout IMU)
// ─────────────────────────────────────────────────────────────────────────
Future<void> _initLiveCamera() async {
final status = await Permission.camera.request();
if (!status.isGranted) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Accès à l\'appareil photo requis')),
);
}
return;
}
setState(() => _isLoading = true);
try {
if (_cameraController != null) {
await _cameraController!.dispose();
_cameraController = null;
}
_cameras = await availableCameras();
if (_cameras != null && _cameras!.isNotEmpty) {
// PERF : on capture en `veryHigh` (2160p) et non en `max`. Le pipeline
// redimensionne ensuite tout à 1080 px max → la résolution `max`
// (1248 MP) ne servait à rien et rallongeait fortement le décodage
// post-capture (« délai de quelques secondes »). `veryHigh` reste très
// au-dessus du 1080 px final : aucune perte de qualité, mais décodage
// bien plus rapide.
_cameraController = CameraController(
_cameras![0],
ResolutionPreset.veryHigh,
enableAudio: false,
imageFormatGroup: ImageFormatGroup.jpeg,
);
await _cameraController!.initialize();
setState(() {
_showLiveCamera = true;
_targetResult = null; // reset détection cible
_parallelismData = null; // NOUVEAU : reset IMU
});
_startAlignmentDetection();
_startParallelismDetection(); // NOUVEAU
}
} catch (e) {
debugPrint('Erreur caméra: $e');
} finally {
setState(() => _isLoading = false);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Détection OpenCV périodique (inchangée)
// ─────────────────────────────────────────────────────────────────────────
void _startAlignmentDetection() {
DateTime? lastAnalysis;
_cameraController!.startImageStream((CameraImage cameraImage) async {
if (_isAnalyzingFrame) return;
final now = DateTime.now();
// Cadence ~1 s : assez réactif pour suivre la cible sans saturer le CPU.
if (lastAnalysis != null &&
now.difference(lastAnalysis!).inMilliseconds < 1000) {
return;
}
lastAnalysis = now;
_isAnalyzingFrame = true;
try {
final tempDir = await getTemporaryDirectory();
final tempPath = '${tempDir.path}/frame_analysis.jpg';
// On copie les octets des plans MAINTENANT (le buffer caméra est
// recyclé dès la sortie du callback), puis on délègue TOUTE la
// conversion YUV→RGB + l'encodage JPEG à un Isolate → l'aperçu et le
// déclencheur restent fluides (plus de gel du thread UI).
final params = _FrameConvertParams(
width: cameraImage.width,
height: cameraImage.height,
yBytes: Uint8List.fromList(cameraImage.planes[0].bytes),
uBytes: Uint8List.fromList(cameraImage.planes[1].bytes),
vBytes: Uint8List.fromList(cameraImage.planes[2].bytes),
yRowStride: cameraImage.planes[0].bytesPerRow,
uvRowStride: cameraImage.planes[1].bytesPerRow,
uvPixelStride: cameraImage.planes[1].bytesPerPixel ?? 1,
outputPath: tempPath,
);
final bool ok =
await Isolate.run(() => _convertAndEncodeFrameIsolate(params));
if (!ok) {
_isAnalyzingFrame = false;
return;
}
final result = await _opencvService.detectTarget(tempPath);
try {
await File(tempPath).delete();
} catch (_) {}
if (mounted && _showLiveCamera) {
setState(() {
// On garde le résultat complet pour dessiner le cercle.
_targetResult = result.success ? result : null;
});
}
} catch (e) {
debugPrint('Erreur analyse frame: $e');
} finally {
_isAnalyzingFrame = false;
}
});
}
void _stopAlignmentDetection() {
try {
if (_cameraController != null &&
_cameraController!.value.isStreamingImages) {
_cameraController!.stopImageStream();
}
} catch (_) {}
_targetResult = null;
}
// ─────────────────────────────────────────────────────────────────────────
// Galerie (inchangée)
// ─────────────────────────────────────────────────────────────────────────
Future<void> _handleGallerySelection() async {
PermissionStatus status;
if (Platform.isAndroid) {
final deviceInfo = DeviceInfoPlugin();
final androidInfo = await deviceInfo.androidInfo;
status = androidInfo.version.sdkInt >= 33
? await Permission.photos.request()
: await Permission.storage.request();
} else {
status = await Permission.photos.request();
}
if (status.isGranted) {
_captureImageFromGallery();
} else if (status.isPermanentlyDenied) {
_showSettingsDialog();
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Accès à la galerie requis pour continuer'),
),
);
}
}
}
void _showSettingsDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Permission requise'),
content: const Text(
'L\'accès aux photos est nécessaire. Veuillez l\'activer dans les paramètres.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => openAppSettings(),
child: const Text('Paramètres'),
),
],
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// Build principal
// ─────────────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final sessionProvider = context.watch<SessionProvider>();
final title = sessionProvider.isSessionActive
? 'Cible ${sessionProvider.targetCount + 1}'
: 'Source';
if (_showLiveCamera &&
_cameraController != null &&
_cameraController!.value.isInitialized) {
return _buildLiveCameraView();
}
return Scaffold(
appBar: AppBar(title: Text(title)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: AppConstants.largePadding),
_buildSectionTitle('Source de l\'Image'),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: ImageSourceButton(
icon: Icons.camera_alt,
label: 'Scanner',
onPressed: _isLoading ? null : _initLiveCamera,
),
),
const SizedBox(width: 12),
Expanded(
child: ImageSourceButton(
icon: Icons.photo_library,
label: 'Galerie',
onPressed: _isLoading ? null : _handleGallerySelection,
),
),
],
),
const SizedBox(height: AppConstants.largePadding),
if (_isLoading)
const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: CircularProgressIndicator(),
),
)
else
_buildGuide(),
],
),
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// Vue caméra live — MODIFIÉE pour utiliser les données IMU
// ─────────────────────────────────────────────────────────────────────────
Widget _buildLiveCameraView() {
// Priorité : IMU (parallélisme physique) prime sur OpenCV (centrage)
// Si l'IMU dit "mal aligné" → orange, sinon → vert
final Color frameColor = _frameColor;
final String alignmentMessage = _alignmentMessage;
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
// 1. Flux vidéo caméra
// On utilise le ratio RÉEL du capteur (et non un 3/4 codé en dur) :
// sinon l'aperçu est étiré dès que la résolution choisie n'est pas
// en 4:3 (ex. veryHigh en 16:9 écrasait la cible en rectangle).
// `value.aspectRatio` est le ratio paysage du flux → on l'inverse
// pour l'affichage portrait.
Center(
child: AspectRatio(
aspectRatio: 1 / _cameraController!.value.aspectRatio,
child: CameraPreview(_cameraController!),
),
),
// 2. Coins du cadre et mire centrale (sans le cadre rectangulaire)
Center(
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.85,
height: MediaQuery.of(context).size.width * 0.85,
child: Stack(
children: [
_buildCameraCorner(topLeft: true, color: frameColor),
_buildCameraCorner(topRight: true, color: frameColor),
_buildCameraCorner(bottomLeft: true, color: frameColor),
_buildCameraCorner(bottomRight: true, color: frameColor),
Center(
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: frameColor, width: 1.2),
),
child: Stack(
children: [
Center(
child: Container(
width: 20,
height: 1.2,
color: frameColor,
),
),
Center(
child: Container(
width: 1.2,
height: 20,
color: frameColor,
),
),
],
),
),
),
],
),
),
),
// 3. Bouton retour
Positioned(
top: 40,
left: 20,
child: CircleAvatar(
backgroundColor: Colors.black54,
child: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () {
_stopAlignmentDetection();
_stopParallelismDetection(); // NOUVEAU
setState(() {
_showLiveCamera = false;
});
},
),
),
),
// 4. Message d'alignement principal (texte dynamique IMU)
Positioned(
top: 50,
left: 0,
right: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(20),
),
child: Text(
alignmentMessage,
style: TextStyle(
color: frameColor,
fontWeight: FontWeight.bold,
fontSize: 12,
letterSpacing: 1.2,
),
),
),
),
),
// NOUVEAU : Indicateur visuel des angles en bas à gauche
if (_parallelismData != null &&
_parallelismData!.status != ParallelismStatus.unknown)
Positioned(
bottom: 140,
left: 16,
child: _buildAngleIndicator(_parallelismData!),
),
// Indicateur d'analyse en cours
if (_isAnalyzingFrame)
Positioned(
top: 45,
right: 20,
child: Container(
width: 10,
height: 10,
decoration: const BoxDecoration(
color: Colors.white54,
shape: BoxShape.circle,
),
),
),
// 5. Bouton déclencheur
Positioned(
bottom: 40,
left: 0,
right: 0,
child: Center(
child: GestureDetector(
onTap: _isCapturing ? null : _takePictureManually,
child: Container(
height: 80,
width: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4),
),
child: Center(
child: Container(
height: 64,
width: 64,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
),
),
),
),
),
// 6. Overlay bloquant pendant la capture/traitement : empêche de
// bouger l'UI ou de relancer une capture tant que la photo n'est pas
// chargée (absorbe tous les gestes). Deux phases distinctes pour
// lever la confusion « puis-je bouger ? » :
// • _capturedPreviewPath == null → capture physique en cours :
// voile sur l'aperçu vivant + « Ne bougez pas ».
// • _capturedPreviewPath != null → photo prise, traitement en
// cours : on affiche l'image FIGÉE (le flux vivant disparaît)
// + « Photo prise ✓ — vous pouvez bouger ».
if (_isCapturing)
Positioned.fill(
child: AbsorbPointer(
child: _capturedPreviewPath == null
? _buildCapturingOverlay()
: _buildProcessingOverlay(_capturedPreviewPath!),
),
),
],
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// Overlay PHASE 1 : capture physique en cours → l'utilisateur doit rester
// immobile. Voile semi-transparent par-dessus l'aperçu vivant.
// ─────────────────────────────────────────────────────────────────────────
Widget _buildCapturingOverlay() {
return Container(
color: Colors.black.withValues(alpha: 0.45),
child: const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: Colors.white),
SizedBox(height: 16),
Text(
'NE BOUGEZ PAS — CAPTURE…',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
),
),
],
),
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// Overlay PHASE 2 : photo capturée, traitement en cours. On affiche l'image
// FIGÉE (l'aperçu vivant disparaît, plus d'ambiguïté) et on indique
// clairement que l'utilisateur peut maintenant bouger.
// ─────────────────────────────────────────────────────────────────────────
Widget _buildProcessingOverlay(String imagePath) {
return Stack(
fit: StackFit.expand,
children: [
// Image figée de la photo prise → recouvre totalement le flux caméra
Image.file(File(imagePath), fit: BoxFit.cover),
Container(
color: Colors.black.withValues(alpha: 0.55),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.check_circle,
color: Color(0xFF00FF00),
size: 52,
),
const SizedBox(height: 14),
const Text(
'PHOTO PRISE',
style: TextStyle(
color: Color(0xFF00FF00),
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
),
),
const SizedBox(height: 20),
const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2.5,
),
),
const SizedBox(height: 14),
Text(
'Traitement… vous pouvez bouger',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.85),
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
],
);
}
// ─────────────────────────────────────────────────────────────────────────
// NOUVEAU : Widget affichant les angles pitch/roll en temps réel
// ─────────────────────────────────────────────────────────────────────────
Widget _buildAngleIndicator(ParallelismData data) {
final Color color = data.isAligned ? const Color(0xFF00FF00) : Colors.orange;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withValues(alpha: 0.5), width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_buildAngleRow(
label: 'Pitch',
value: data.pitchDegrees,
color: color,
),
const SizedBox(height: 2),
_buildAngleRow(
label: 'Roll ',
value: data.rollDegrees,
color: color,
),
],
),
);
}
Widget _buildAngleRow({
required String label,
required double value,
required Color color,
}) {
final String sign = value >= 0 ? '+' : '';
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$label: ',
style: const TextStyle(
color: Colors.white60,
fontSize: 11,
fontFamily: 'monospace',
),
),
Text(
'$sign${value.toStringAsFixed(1)}°',
style: TextStyle(
color: color,
fontSize: 11,
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
),
),
],
);
}
// ─────────────────────────────────────────────────────────────────────────
// Coins du cadre (inchangés)
// ─────────────────────────────────────────────────────────────────────────
Widget _buildCameraCorner({
bool topLeft = false,
bool topRight = false,
bool bottomLeft = false,
bool bottomRight = false,
Color color = const Color(0xFF00FF00),
}) {
return Positioned(
top: (topLeft || topRight) ? 10 : null,
bottom: (bottomLeft || bottomRight) ? 10 : null,
left: (topLeft || bottomLeft) ? 10 : null,
right: (topRight || bottomRight) ? 10 : null,
child: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
border: Border(
top: (topLeft || topRight)
? BorderSide(color: color, width: 4)
: BorderSide.none,
bottom: (bottomLeft || bottomRight)
? BorderSide(color: color, width: 4)
: BorderSide.none,
left: (topLeft || bottomLeft)
? BorderSide(color: color, width: 4)
: BorderSide.none,
right: (topRight || bottomRight)
? BorderSide(color: color, width: 4)
: BorderSide.none,
),
),
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// Capture manuelle (MODIFIÉE : ajout dégradation post-capture pour perf)
// ─────────────────────────────────────────────────────────────────────────
Future<void> _takePictureManually() async {
if (_cameraController == null || !_cameraController!.value.isInitialized) {
return;
}
// Garde anti-double-capture : tout appui supplémentaire pendant le
// traitement est ignoré (évite les photos multiples / états incohérents).
if (_isCapturing) return;
setState(() {
_isCapturing = true;
_isLoading = true;
_capturedPreviewPath = null; // phase « capture en cours »
});
try {
// On stoppe le flux d'analyse (obligatoire avant takePicture) puis on
// capture IMMÉDIATEMENT, sans étape intermédiaire. Le reset de zoom
// inutile (aucun zoom possible sur cet écran) a été retiré pour que la
// photo parte sans délai perceptible.
_stopAlignmentDetection();
_stopParallelismDetection();
final XFile photo = await _cameraController!.takePicture();
// La photo est PHYSIQUEMENT capturée : on gèle l'aperçu en affichant
// l'image prise (le flux caméra « vivant » disparaît) et on bascule le
// message sur « vous pouvez bouger ». Tout ce qui suit n'est que du
// traitement et n'exige plus l'immobilité de l'utilisateur.
if (mounted) {
setState(() => _capturedPreviewPath = photo.path);
}
// Pas de redressement perspective automatique ici : il déformait l'image
// (écrasement en carré + rotation de l'axe de l'ellipse) et gelait le
// thread UI. Le recentrage ET la rotation se font manuellement à l'écran
// de centrage suivant, qui est précis et non destructeur.
String finalPath = photo.path;
// PERF : on dégrade fortement la photo juste après la capture.
// Une résolution réduite suffit pour la calibration et le plotting,
// et accélère nettement le décodage à chaque écran (chargement plotting).
try {
finalPath = await _downscaleForPipeline(finalPath);
} catch (e) {
debugPrint('Downscale ignoré: $e');
}
if (!mounted) return;
setState(() {
_selectedImagePath = finalPath;
_showLiveCamera = false;
});
_analyzeImage();
} catch (e) {
debugPrint('Erreur lors du clic photo: $e');
} finally {
if (mounted) {
setState(() {
_isLoading = false;
_isCapturing = false;
_capturedPreviewPath = null;
});
}
}
}
/// Recadre la photo sur la zone du viseur PUIS réduit sa résolution, en un
/// seul décodage dans un Isolate (thread UI fluide).
///
/// 1) Recadrage viseur : carré centré à 85 % du petit côté de la photo. C'est
/// EXACTEMENT le cadre des 4 coins verts/orange affiché à la capture
/// (`SizedBox` de `0.85 * largeur écran`, centré, l'aperçu caméra remplissant
/// la largeur). → l'écran de centrage ne reçoit QUE ce qui était visé entre
/// les coins, sans le fond capturé autour : plus d'impression de « dézoom ».
/// 2) Dégradation résolution : 1080 px de côté max + JPEG qualité 70, largement
/// suffisant pour la détection visuelle et bien plus rapide à recharger.
///
/// Baisser `maxSide` (ex. 900) ou `quality` (ex. 60) accélère encore au prix
/// d'un peu de finesse à l'écran.
Future<String> _downscaleForPipeline(String sourcePath) async {
// On résout le chemin de sortie sur le thread UI (path_provider a besoin
// du canal de plateforme), puis tout le travail lourd (décodage, crop,
// resize, réencodage JPEG) part dans un Isolate → aucun gel du thread UI.
final tempDir = await getTemporaryDirectory();
final outPath =
'${tempDir.path}/downscaled_${DateTime.now().millisecondsSinceEpoch}.jpg';
return Isolate.run(() {
final bytes = File(sourcePath).readAsBytesSync();
final decoded = img.decodeImage(bytes);
if (decoded == null) return sourcePath;
// 1) Recadrage sur la zone du viseur (carré centré à 85 % du petit côté).
final int side = (math.min(decoded.width, decoded.height) * 0.85).round();
final int cropX =
((decoded.width - side) / 2).round().clamp(0, decoded.width - 1);
final int cropY =
((decoded.height - side) / 2).round().clamp(0, decoded.height - 1);
img.Image result = img.copyCrop(
decoded,
x: cropX,
y: cropY,
width: math.min(side, decoded.width - cropX),
height: math.min(side, decoded.height - cropY),
);
// 2) Dégradation résolution si nécessaire.
const int maxSide = 1080;
final int longest = math.max(result.width, result.height);
if (longest > maxSide) {
final double ratio = maxSide / longest;
result = img.copyResize(
result,
width: (result.width * ratio).round(),
height: (result.height * ratio).round(),
interpolation: img.Interpolation.linear, // rapide
);
}
File(outPath).writeAsBytesSync(img.encodeJpg(result, quality: 70));
return outPath;
});
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers UI (inchangés)
// ─────────────────────────────────────────────────────────────────────────
Widget _buildSectionTitle(String title) {
return Text(
title,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
);
}
Widget _buildGuide() {
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
children: [
Icon(Icons.help_outline, size: 48, color: Colors.grey[400]),
const SizedBox(height: 12),
Text(
'Conseils pour une bonne analyse',
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
_buildGuideItem(
Icons.crop_free,
'Cadrez la cible entière dans l\'image',
),
_buildGuideItem(Icons.wb_sunny, 'Utilisez un bon éclairage'),
_buildGuideItem(Icons.straighten, 'Prenez la photo de face'),
_buildGuideItem(Icons.blur_off, 'Évitez les images floues'),
_buildGuideItem(
Icons.cleaning_services,
'Nettoyer votre objectif avec une chiffonnette',
),
],
),
),
);
}
Widget _buildGuideItem(IconData icon, String text) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(icon, size: 20, color: AppTheme.primaryColor),
const SizedBox(width: 12),
Expanded(child: Text(text)),
],
),
);
}
Future<void> _captureImageFromGallery() async {
setState(() => _isLoading = true);
try {
final XFile? image = await _picker.pickImage(
source: ImageSource.gallery,
// PERF : limite d'import plus agressive pour accélérer le pipeline
maxWidth: 1280,
maxHeight: 1280,
imageQuality: 75,
);
if (image != null) {
setState(() {
_selectedImagePath = image.path;
});
_analyzeImage();
}
} catch (e) {
debugPrint('Erreur galerie: $e');
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
void _analyzeImage() {
if (_selectedImagePath == null) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CropScreen(
imagePath: _selectedImagePath!,
targetType: _selectedType,
),
),
);
}
}