1091 lines
41 KiB
Dart
1091 lines
41 KiB
Dart
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
|
||
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);
|
||
}
|
||
|
||
@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) 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`
|
||
// (12–48 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() {
|
||
_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';
|
||
|
||
// 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() {
|
||
_detectionTimer?.cancel();
|
||
_detectionTimer = null;
|
||
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,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
} |