import 'dart:io'; import 'dart:math' as math; import 'package:flutter/material.dart'; import '../../core/theme/app_theme.dart'; import '../../data/models/target_type.dart'; import '../../services/image_crop_service.dart'; import '../analysis/analysis_screen.dart'; import 'widgets/crop_overlay.dart'; class CropScreen extends StatefulWidget { final String imagePath; final TargetType targetType; final double? initialScale; final Offset? initialOffset; const CropScreen({ super.key, required this.imagePath, required this.targetType, this.initialScale, this.initialOffset, }); @override State createState() => _CropScreenState(); } class _CropScreenState extends State { final ImageCropService _cropService = ImageCropService(); // États de transformation double _scale = 1.0; double _baseScale = 1.0; Offset _offset = Offset.zero; Offset _startOffset = Offset.zero; Offset _startFocalPoint = Offset.zero; // PRÉCISION MAXIMUM : Amplitude restreinte de -15.0 à 15.0 degrés double _rotation = 0.0; bool _isLoading = false; bool _imageLoaded = false; Size? _imageSize; late Size _viewportSize; late double _cropSize; @override void initState() { super.initState(); _loadImageInfo(); } Future _loadImageInfo() async { final file = File(widget.imagePath); final decodedImage = await decodeImageFromList(await file.readAsBytes()); if (mounted) { setState(() { _imageSize = Size( decodedImage.width.toDouble(), decodedImage.height.toDouble(), ); _imageLoaded = true; }); } } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFF101214), appBar: AppBar( backgroundColor: const Color(0xFF101214), elevation: 0, centerTitle: true, title: const Text( 'Centrage de la cible', style: TextStyle(color: Colors.white, fontSize: 18), ), leading: IconButton( icon: const Icon(Icons.arrow_back, color: Colors.white), onPressed: () => Navigator.pop(context), ), actions: [ if (_rotation != 0.0 || _scale != 1.0 || _offset != Offset.zero) IconButton( icon: const Icon(Icons.refresh, color: Colors.white70), tooltip: 'Réinitialiser l\'orientation', onPressed: () { setState(() { _rotation = 0.0; _scale = 1.0; _offset = Offset.zero; _initializeImagePosition(); }); }, ), ], ), body: _isLoading ? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8))) : Column( children: [ // TEXTE D'AIDE — placé en haut, sous le titre, au-dessus de l'image. Padding( padding: const EdgeInsets.fromLTRB(20, 12, 20, 4), child: Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.center_focus_strong, color: Color(0xFF00FF00), size: 20), const SizedBox(width: 8), Flexible( child: Text( 'Alignez et pivotez la cible sur la croix', style: TextStyle(color: Colors.white.withValues(alpha: 0.8), fontSize: 14, fontWeight: FontWeight.w500), ), ), ], ), const SizedBox(height: 4), Text( 'Glissez à un doigt pour déplacer, pincez pour zoomer', style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 12), ), ], ), ), // Zone interactive de crop, CARRÉE. La photo la remplit entièrement // (BoxFit.cover) → aucun bord noir dans la zone. Le débord hors cadre // est récupérable en déplaçant/zoomant. La sortie d'analyse reste // carrée, donc la cible n'est pas déformée. Expanded( child: Padding( padding: const EdgeInsets.all(20), child: Center( child: AspectRatio( aspectRatio: 1.0, child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.white10), ), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: _imageLoaded ? _buildInteractiveCrop() : const Center(child: CircularProgressIndicator()), ), ), ), ), ), ), const SizedBox(height: 8), // CROIX DIRECTIONNELLE — déplace la photo pixel par pixel. _buildDirectionalPad(), const SizedBox(height: 16), // JAUGE DE ROTATION HAUTE PRÉCISION BRIDÉE À 15° Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( 'Rotation de l\'image', style: TextStyle(color: Colors.white70, fontSize: 13, fontWeight: FontWeight.w600), ), Text( '${_rotation.toStringAsFixed(1)}°', style: const TextStyle(color: Color(0xFF00FF00), fontWeight: FontWeight.bold), ), ], ), Row( children: [ const Icon(Icons.rotate_left, color: Colors.white38, size: 20), Expanded( child: Slider( value: _rotation, min: -15.0, max: 15.0, divisions: 300, label: '${_rotation.toStringAsFixed(1)}°', activeColor: const Color(0xFF1A73E8), inactiveColor: Colors.white12, onChanged: (double value) { setState(() { _rotation = value; }); }, ), ), const Icon(Icons.rotate_right, color: Colors.white38, size: 20), const SizedBox(width: 4), IconButton( icon: const Icon(Icons.restart_alt, color: Colors.white54, size: 20), onPressed: () { setState(() { _rotation = 0.0; }); }, ), ], ), ], ), ), // Boutons du bas Padding( padding: const EdgeInsets.fromLTRB(20, 10, 20, 30), child: Row( children: [ Expanded( child: OutlinedButton( onPressed: () => Navigator.pop(context), style: OutlinedButton.styleFrom( side: const BorderSide(color: Colors.white24), padding: const EdgeInsets.symmetric(vertical: 15), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), child: const Text('Retour', style: TextStyle(color: Colors.white)), ), ), const SizedBox(width: 16), Expanded( child: ElevatedButton( onPressed: _onCropConfirm, style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF1A73E8), foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 15), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), child: const Text('Suivant', style: TextStyle(fontWeight: FontWeight.bold)), ), ), ], ), ), ], ), ); } Widget _buildInteractiveCrop() { return LayoutBuilder( builder: (context, constraints) { _viewportSize = Size(constraints.maxWidth, constraints.maxHeight); // La photo remplit toute la zone carrée (BoxFit.cover). La fenêtre de // visée = toute la zone visible → aucun bord noir autour du cadre. _cropSize = math.min(_viewportSize.width, _viewportSize.height); if (_scale == 1.0 && _offset == Offset.zero && _rotation == 0.0) { _initializeImagePosition(); } return GestureDetector( onScaleStart: _onScaleStart, onScaleUpdate: _onScaleUpdate, child: Stack( children: [ // 1. L'image brute avec ses transformations (Reste calé sur BoxFit.contain d'origine) Positioned.fill( child: Center( child: Transform( transform: Matrix4.identity() ..setTranslationRaw(_offset.dx, _offset.dy, 0) ..scaleByDouble(_scale, _scale, _scale, 1.0) ..rotateZ(_rotation * (math.pi / 180)), alignment: Alignment.center, child: Image.file( File(widget.imagePath), fit: BoxFit.cover, width: _viewportSize.width, height: _viewportSize.height, ), ), ), ), // 2. Les lignes vertes de visée prolongées Positioned.fill( child: IgnorePointer( child: Stack( children: [ Center( child: Container( width: double.infinity, height: 1.5, color: const Color(0xFF00FF00).withValues(alpha: 0.6), ), ), Center( child: Container( width: 1.5, height: double.infinity, color: const Color(0xFF00FF00).withValues(alpha: 0.6), ), ), ], ), ), ), // 3. MASQUE OPAQUE EXTÉRIEUR - COINS DROITS ET CARRÉS Positioned.fill( child: IgnorePointer( child: ColorFiltered( colorFilter: ColorFilter.mode( const Color(0xFF101214), BlendMode.srcOut, ), child: Stack( children: [ Center( child: Container( width: _cropSize, height: _cropSize, color: Colors.black, ), ), ], ), ), ), ), // 4. Ton overlay avec les coins blancs Positioned.fill( child: IgnorePointer( child: CropOverlay(cropSize: _cropSize, showGrid: false), ), ), ], ), ); }, ); } void _initializeImagePosition() { if (_imageSize == null) return; // 2. Calcul du scale initial basé sur la dimension de l'image (et non du viewport) _scale = widget.initialScale ?? 1.0; if (_scale < 1.0 && widget.initialScale == null) _scale = 1.0; // 3. Réinitialisation propre de l'offset au centre de la zone d'affichage if (widget.initialOffset != null) { _offset = widget.initialOffset!; } else { _offset = Offset.zero; // Force l'image à se centrer parfaitement sur la croix verte } } // Croix directionnelle compacte pour déplacer la photo pixel par pixel. // Les symboles « − » et « + » de part et d'autre sont purement décoratifs. Widget _buildDirectionalPad() { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _buildCropSignLabel('−'), const SizedBox(width: 10), Column( mainAxisSize: MainAxisSize.min, children: [ _buildCropDirButton(Icons.keyboard_arrow_up, () => _nudge(0, -1)), Row( mainAxisSize: MainAxisSize.min, children: [ _buildCropDirButton(Icons.keyboard_arrow_left, () => _nudge(-1, 0)), const SizedBox(width: 28), _buildCropDirButton(Icons.keyboard_arrow_right, () => _nudge(1, 0)), ], ), _buildCropDirButton(Icons.keyboard_arrow_down, () => _nudge(0, 1)), ], ), const SizedBox(width: 10), _buildCropSignLabel('+'), ], ); } Widget _buildCropDirButton(IconData icon, VoidCallback onPressed) { return Container( decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(6)), margin: const EdgeInsets.all(2), child: IconButton( icon: Icon(icon, color: Colors.white, size: 22), onPressed: onPressed, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 32, minHeight: 32), ), ); } Widget _buildCropSignLabel(String text) { return Text( text, style: const TextStyle(color: Colors.white54, fontSize: 24, fontWeight: FontWeight.bold), ); } void _nudge(double dx, double dy) { setState(() { _offset = _offset + Offset(dx, dy); }); } void _onScaleStart(ScaleStartDetails details) { _baseScale = _scale; _startFocalPoint = details.focalPoint; _startOffset = _offset; } void _onScaleUpdate(ScaleUpdateDetails details) { setState(() { _scale = (_baseScale * details.scale).clamp(0.8, 8.0); final delta = details.focalPoint - _startFocalPoint; _offset = _startOffset + delta; }); } Future _onCropConfirm() async { setState(() => _isLoading = true); try { // Facteur d'échelle affichage/source (BoxFit.cover) — identique à celui // utilisé pour afficher l'image dans l'aperçu : l'image remplit la zone, // le débord est rogné, donc l'échelle est le MAX des deux ratios d'axe. final double displayPerSourcePx = math.max( _viewportSize.width / _imageSize!.width, _viewportSize.height / _imageSize!.height, ); // Découpe calée sur la fenêtre de visée : le DÉPLACEMENT (pan) et la // ROTATION sont pris en compte, le ZOOM est ignoré, et les débordements // sont remplis en noir → la position choisie est respectée à l'identique. final croppedImagePath = await _cropService.cropViewport( sourcePath: widget.imagePath, offsetDx: _offset.dx, offsetDy: _offset.dy, displayPerSourcePx: displayPerSourcePx, cropSizeDisplay: _cropSize, // Le zoom sert UNIQUEMENT à viser le bon point (mapping du décalage) ; // il n'agrandit pas le rendu de sortie. zoomScale: _scale, rotationDegrees: _rotation, ); const targetCenterX = 0.5; const targetCenterY = 0.5; if (!mounted) return; Navigator.pushReplacement( context, MaterialPageRoute( builder: (_) => AnalysisScreen( imagePath: croppedImagePath, // AJOUT : on conserve la SOURCE non rognée pour les retours arrière. // Sans cela, revenir au crop repartait de l'image déjà rognée à 85%, // provoquant un zoom cumulatif (0.85 x 0.85 x ...) à chaque aller-retour. originalImagePath: widget.imagePath, targetType: widget.targetType, initialCenterX: targetCenterX, initialCenterY: targetCenterY, cropScale: 1.0, cropOffset: Offset.zero, cropRotation: 0.0, ), ), ); } catch (e) { if (mounted) { setState(() => _isLoading = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Erreur de découpe : $e'), backgroundColor: AppTheme.errorColor), ); } } } }