Files
impact/lib/features/capture/capture_screen.dart
2026-06-12 11:33:41 +02:00

1043 lines
39 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 '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/image_crop_service.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';
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 ImageCropService _cropService = ImageCropService();
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 _isCameraInitialized = false;
bool _showLiveCamera = false;
// Animation pulsation du viseur
late AnimationController _scanAnimationController;
late Animation<double> _scanAnimation;
// Détection OpenCV (cible circulaire) — on garde le résultat COMPLET
bool? _alignmentStatus;
TargetDetectionResult? _targetResult; // NOUVEAU : centre + rayon de la cible
Timer? _detectionTimer;
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);
_scanAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
CurvedAnimation(parent: _scanAnimationController, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_cameraController?.dispose();
_scanAnimationController.dispose();
_detectionTimer?.cancel();
_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) {
setState(() {
_parallelismData = data;
});
}
});
}
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;
_isCameraInitialized = false;
}
_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(() {
_isCameraInitialized = true;
_showLiveCamera = true;
_alignmentStatus = null;
_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() {
_detectionTimer?.cancel();
_detectionTimer = null;
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';
final img.Image? converted = _convertCameraImage(cameraImage);
if (converted == null) {
_isAnalyzingFrame = false;
return;
}
// PERF : qualité 50 pour la frame d'analyse temps réel (jetable)
await File(tempPath).writeAsBytes(img.encodeJpg(converted, quality: 50));
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;
if (!result.success) {
_alignmentStatus = null;
} else {
final bool isCentered =
result.centerX > 0.25 && result.centerX < 0.75 &&
result.centerY > 0.25 && result.centerY < 0.75;
final bool isSizedCorrectly = result.radius > 0.15;
_alignmentStatus = isCentered && isSizedCorrectly;
}
});
}
} catch (e) {
debugPrint('Erreur analyse frame: $e');
} finally {
_isAnalyzingFrame = false;
}
});
}
void _stopAlignmentDetection() {
_detectionTimer?.cancel();
_detectionTimer = null;
try {
if (_cameraController != null &&
_cameraController!.value.isStreamingImages) {
_cameraController!.stopImageStream();
}
} catch (_) {}
_alignmentStatus = null;
_targetResult = null;
}
img.Image? _convertCameraImage(CameraImage cameraImage) {
try {
final int width = cameraImage.width;
final int height = cameraImage.height;
final int targetW = width ~/ 2;
final int targetH = height ~/ 2;
final yPlane = cameraImage.planes[0];
final uPlane = cameraImage.planes[1];
final vPlane = cameraImage.planes[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 * yPlane.bytesPerRow + srcX;
final int uvIndex = (srcY ~/ 2) * uPlane.bytesPerRow +
(srcX ~/ 2) * uPlane.bytesPerPixel!;
final int yVal = yPlane.bytes[yIndex];
final int uVal = uPlane.bytes[uvIndex] - 128;
final int vVal = vPlane.bytes[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);
}
}
return image;
} catch (e) {
debugPrint('Erreur conversion frame: $e');
return 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;
});
}
}
}
/// Réduit fortement la résolution de la photo pour accélérer tous les
/// écrans suivants (crop, calibration, plotting). 1080 px de côté max
/// + JPEG qualité 70 : largement suffisant pour la détection visuelle.
///
/// Baisser `maxSide` (ex. 900) ou `quality` (ex. 60) rend le chargement
/// encore plus rapide 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, 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;
const int maxSide = 1080;
final int longest = math.max(decoded.width, decoded.height);
if (longest <= maxSide) {
return sourcePath; // déjà assez petite, rien à faire
}
final double ratio = maxSide / longest;
final resized = img.copyResize(
decoded,
width: (decoded.width * ratio).round(),
height: (decoded.height * ratio).round(),
interpolation: img.Interpolation.linear, // rapide
);
File(outPath).writeAsBytesSync(img.encodeJpg(resized, 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,
),
),
);
}
}