diff --git a/lib/features/analysis/analysis_screen.dart b/lib/features/analysis/analysis_screen.dart index 88581c4..287d098 100644 --- a/lib/features/analysis/analysis_screen.dart +++ b/lib/features/analysis/analysis_screen.dart @@ -55,6 +55,42 @@ class _AnalysisScreenContent extends StatefulWidget { class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { bool _isCalibrating = false; bool _isSelectingReferences = false; + bool _isFullscreenEditMode = false; + final TransformationController _transformationController = TransformationController(); + final GlobalKey _imageKey = GlobalKey(); + double _currentZoomScale = 1.0; + + @override + void initState() { + super.initState(); + _transformationController.addListener(_onTransformChanged); + } + + @override + void dispose() { + _transformationController.removeListener(_onTransformChanged); + _transformationController.dispose(); + super.dispose(); + } + + void _onTransformChanged() { + final newScale = _transformationController.value.getMaxScaleOnAxis(); + if (newScale != _currentZoomScale) { + setState(() { + _currentZoomScale = newScale; + }); + } + } + + void _toggleFullscreenEditMode() { + setState(() { + _isFullscreenEditMode = !_isFullscreenEditMode; + if (!_isFullscreenEditMode) { + // Reset zoom when exiting fullscreen mode + _transformationController.value = Matrix4.identity(); + } + }); + } @override Widget build(BuildContext context) { @@ -148,6 +184,11 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { } Widget _buildSuccessContent(BuildContext context, AnalysisProvider provider) { + // Mode plein écran pour l'édition des impacts + if (_isFullscreenEditMode) { + return _buildFullscreenEditContent(context, provider); + } + return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -282,51 +323,28 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { // Target image with overlay or calibration AspectRatio( aspectRatio: provider.imageAspectRatio, - child: Stack( - fit: StackFit.expand, - children: [ - Image.file( - File(provider.imagePath!), - fit: BoxFit.fill, - ), - if (_isCalibrating) - TargetCalibration( - initialCenterX: provider.targetCenterX, - initialCenterY: provider.targetCenterY, - initialRadius: provider.targetRadius, - initialRingCount: provider.ringCount, - initialRingRadii: provider.ringRadii, - targetType: provider.targetType!, - onCalibrationChanged: (centerX, centerY, radius, ringCount, {List? ringRadii}) { - provider.adjustTargetPosition(centerX, centerY, radius, ringCount: ringCount, ringRadii: ringRadii); - }, + child: _isCalibrating + ? Stack( + fit: StackFit.expand, + children: [ + Image.file( + File(provider.imagePath!), + fit: BoxFit.fill, + ), + TargetCalibration( + initialCenterX: provider.targetCenterX, + initialCenterY: provider.targetCenterY, + initialRadius: provider.targetRadius, + initialRingCount: provider.ringCount, + initialRingRadii: provider.ringRadii, + targetType: provider.targetType!, + onCalibrationChanged: (centerX, centerY, radius, ringCount, {List? ringRadii}) { + provider.adjustTargetPosition(centerX, centerY, radius, ringCount: ringCount, ringRadii: ringRadii); + }, + ), + ], ) - else - TargetOverlay( - shots: provider.shots, - targetCenterX: provider.targetCenterX, - targetCenterY: provider.targetCenterY, - targetRadius: provider.targetRadius, - targetType: provider.targetType!, - ringCount: provider.ringCount, - ringRadii: provider.ringRadii, - onShotTapped: (shot) => _isSelectingReferences - ? null - : _showShotOptions(context, provider, shot.id), - onAddShot: (x, y) { - if (_isSelectingReferences) { - provider.addReferenceImpact(x, y); - } else { - provider.addShot(x, y); - } - }, - groupingCenterX: provider.groupingResult?.centerX, - groupingCenterY: provider.groupingResult?.centerY, - groupingDiameter: provider.groupingResult?.diameter, - referenceImpacts: _isSelectingReferences ? provider.referenceImpacts : null, - ), - ], - ), + : _buildZoomableImageWithOverlay(context, provider), ), // Info cards (hidden during calibration) @@ -428,6 +446,191 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { ); } + Widget _buildZoomableImageWithOverlay(BuildContext context, AnalysisProvider provider) { + return ClipRect( + child: Stack( + fit: StackFit.expand, + children: [ + // Image zoomable avec InteractiveViewer + InteractiveViewer( + transformationController: _transformationController, + minScale: 1.0, + maxScale: 5.0, + panEnabled: true, + scaleEnabled: true, + constrained: true, + boundaryMargin: EdgeInsets.zero, + interactionEndFrictionCoefficient: 0.0000135, + child: Stack( + fit: StackFit.expand, + children: [ + Image.file( + File(provider.imagePath!), + fit: BoxFit.fill, + key: _imageKey, + ), + // Overlay qui se transforme avec l'image + TargetOverlay( + shots: provider.shots, + targetCenterX: provider.targetCenterX, + targetCenterY: provider.targetCenterY, + targetRadius: provider.targetRadius, + targetType: provider.targetType!, + ringCount: provider.ringCount, + ringRadii: provider.ringRadii, + zoomScale: _currentZoomScale, + onShotTapped: (shot) => _isSelectingReferences + ? null + : _showShotOptions(context, provider, shot.id), + onAddShot: (x, y) { + if (_isSelectingReferences) { + provider.addReferenceImpact(x, y); + } else { + provider.addShot(x, y); + } + }, + groupingCenterX: provider.groupingResult?.centerX, + groupingCenterY: provider.groupingResult?.centerY, + groupingDiameter: provider.groupingResult?.diameter, + referenceImpacts: _isSelectingReferences ? provider.referenceImpacts : null, + ), + ], + ), + ), + // Bouton pour passer en mode plein écran d'édition + Positioned( + right: 8, + bottom: 8, + child: FloatingActionButton.small( + heroTag: 'fullscreenEdit', + onPressed: _toggleFullscreenEditMode, + backgroundColor: Colors.black54, + child: const Icon(Icons.fullscreen, color: Colors.white), + ), + ), + ], + ), + ); + } + + Widget _buildFullscreenEditContent(BuildContext context, AnalysisProvider provider) { + return Stack( + fit: StackFit.expand, + children: [ + // Image zoomable en plein écran + InteractiveViewer( + transformationController: _transformationController, + minScale: 1.0, + maxScale: 5.0, + panEnabled: true, + scaleEnabled: true, + constrained: true, + boundaryMargin: EdgeInsets.zero, + interactionEndFrictionCoefficient: 0.0000135, + child: Center( + child: AspectRatio( + aspectRatio: provider.imageAspectRatio, + child: Stack( + fit: StackFit.expand, + children: [ + Image.file( + File(provider.imagePath!), + fit: BoxFit.fill, + key: _imageKey, + ), + // Overlay qui se transforme avec l'image + TargetOverlay( + shots: provider.shots, + targetCenterX: provider.targetCenterX, + targetCenterY: provider.targetCenterY, + targetRadius: provider.targetRadius, + targetType: provider.targetType!, + ringCount: provider.ringCount, + ringRadii: provider.ringRadii, + zoomScale: _currentZoomScale, + onShotTapped: (shot) => _showShotOptions(context, provider, shot.id), + onAddShot: (x, y) { + provider.addShot(x, y); + }, + groupingCenterX: provider.groupingResult?.centerX, + groupingCenterY: provider.groupingResult?.centerY, + groupingDiameter: provider.groupingResult?.diameter, + ), + ], + ), + ), + ), + ), + // Barre d'info en haut + Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + color: Colors.black87, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: SafeArea( + bottom: false, + child: Row( + children: [ + const Icon(Icons.edit, color: Colors.white, size: 20), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'Mode édition - Touchez pour ajouter des impacts', + style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), + ), + Text( + '${provider.shotCount} impacts', + style: TextStyle(color: Colors.white.withValues(alpha: 0.8)), + ), + ], + ), + ), + ), + ), + // Bouton pour quitter le mode plein écran + Positioned( + right: 16, + bottom: 16, + child: FloatingActionButton( + heroTag: 'exitFullscreen', + onPressed: _toggleFullscreenEditMode, + backgroundColor: AppTheme.primaryColor, + child: const Icon(Icons.fullscreen_exit, color: Colors.white), + ), + ), + // Indicateur de zoom en bas à gauche + Positioned( + left: 16, + bottom: 16, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.zoom_in, color: Colors.white, size: 18), + const SizedBox(width: 6), + Text( + '${(_currentZoomScale * 100).toStringAsFixed(0)}%', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ], + ); + } + Widget _buildInstructionItem(IconData icon, String text) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), diff --git a/lib/features/analysis/widgets/target_calibration.dart b/lib/features/analysis/widgets/target_calibration.dart index 85f357f..a68d6aa 100644 --- a/lib/features/analysis/widgets/target_calibration.dart +++ b/lib/features/analysis/widgets/target_calibration.dart @@ -1,8 +1,7 @@ /// Outil de calibration de la cible. /// -/// Permet l'ajustement interactif du centre, du rayon global et du nombre d'anneaux. -/// Supporte la calibration individuelle de chaque anneau et le redimensionnement -/// proportionnel via un slider global. +/// Permet l'ajustement interactif du centre et du rayon global. +/// Les anneaux sont répartis proportionnellement. library; import 'dart:math' as math; @@ -16,7 +15,7 @@ class TargetCalibration extends StatefulWidget { final double initialRadius; final int initialRingCount; final TargetType targetType; - final List? initialRingRadii; // Individual ring radii multipliers + final List? initialRingRadii; final Function(double centerX, double centerY, double radius, int ringCount, {List? ringRadii}) onCalibrationChanged; const TargetCalibration({ @@ -39,11 +38,10 @@ class _TargetCalibrationState extends State { late double _centerY; late double _radius; late int _ringCount; - late List _ringRadii; // Multipliers for each ring (1.0 = normal) + late List _ringRadii; bool _isDraggingCenter = false; bool _isDraggingRadius = false; - int? _selectedRingIndex; // Index of the ring being adjusted individually @override void initState() { @@ -71,11 +69,8 @@ class _TargetCalibrationState extends State { _ringCount = widget.initialRingCount; _initRingRadii(); } - if (widget.initialRadius != oldWidget.initialRadius && !_isDraggingRadius && _selectedRingIndex == null) { - // Update from slider - scale all rings proportionally - final scale = widget.initialRadius / _radius; + if (widget.initialRadius != oldWidget.initialRadius && !_isDraggingRadius) { _radius = widget.initialRadius; - // Ring radii are relative, so they don't need to change } } @@ -86,7 +81,6 @@ class _TargetCalibrationState extends State { final size = constraints.biggest; return GestureDetector( - onTapDown: (details) => _onTapDown(details, size), onPanStart: (details) => _onPanStart(details, size), onPanUpdate: (details) => _onPanUpdate(details, size), onPanEnd: (_) => _onPanEnd(), @@ -101,7 +95,6 @@ class _TargetCalibrationState extends State { targetType: widget.targetType, isDraggingCenter: _isDraggingCenter, isDraggingRadius: _isDraggingRadius, - selectedRingIndex: _selectedRingIndex, ), ), ); @@ -109,41 +102,6 @@ class _TargetCalibrationState extends State { ); } - void _onTapDown(TapDownDetails details, Size size) { - final tapX = details.localPosition.dx / size.width; - final tapY = details.localPosition.dy / size.height; - - // Check if tapping on a specific ring - final ringIndex = _findRingAtPosition(tapX, tapY, size); - if (ringIndex != null && ringIndex != _selectedRingIndex) { - setState(() { - _selectedRingIndex = ringIndex; - }); - } - } - - int? _findRingAtPosition(double tapX, double tapY, Size size) { - final minDim = math.min(size.width, size.height); - final distFromCenter = math.sqrt( - math.pow((tapX - _centerX) * size.width, 2) + - math.pow((tapY - _centerY) * size.height, 2) - ); - - // Check each ring from outside to inside - for (int i = _ringCount - 1; i >= 0; i--) { - final ringRadius = _radius * _ringRadii[i] * minDim; - final prevRingRadius = i > 0 ? _radius * _ringRadii[i - 1] * minDim : 0.0; - - // Check if tap is on this ring's edge (within tolerance) - final tolerance = 15.0; - if ((distFromCenter - ringRadius).abs() < tolerance) { - return i; - } - } - - return null; - } - void _onPanStart(DragStartDetails details, Size size) { final tapX = details.localPosition.dx / size.width; final tapY = details.localPosition.dy / size.height; @@ -161,27 +119,16 @@ class _TargetCalibrationState extends State { if (distToCenter < 0.05) { setState(() { _isDraggingCenter = true; - _selectedRingIndex = null; }); } else if (distToRadiusHandle < 0.05) { setState(() { _isDraggingRadius = true; - _selectedRingIndex = null; }); - } else { - // Check if dragging a specific ring - final ringIndex = _findRingAtPosition(tapX, tapY, size); - if (ringIndex != null) { - setState(() { - _selectedRingIndex = ringIndex; - }); - } else if (distToCenter < _radius + 0.02) { - // Tapping inside the target - move center - setState(() { - _isDraggingCenter = true; - _selectedRingIndex = null; - }); - } + } else if (distToCenter < _radius + 0.02) { + // Tapping inside the target - move center + setState(() { + _isDraggingCenter = true; + }); } } @@ -199,26 +146,6 @@ class _TargetCalibrationState extends State { // Adjust outer radius (scales all rings proportionally) final newRadius = _radius + deltaX * (size.width / minDim); _radius = newRadius.clamp(0.05, 3.0); - } else if (_selectedRingIndex != null) { - // Adjust individual ring - final currentPos = details.localPosition; - final distFromCenter = math.sqrt( - math.pow(currentPos.dx - _centerX * size.width, 2) + - math.pow(currentPos.dy - _centerY * size.height, 2) - ); - - // Calculate new ring radius as proportion of base radius - final newRingRadius = distFromCenter / (minDim * _radius); - - // Get constraints from adjacent rings - final minAllowed = _selectedRingIndex! > 0 - ? _ringRadii[_selectedRingIndex! - 1] + 0.02 - : 0.05; - final maxAllowed = _selectedRingIndex! < _ringCount - 1 - ? _ringRadii[_selectedRingIndex! + 1] - 0.02 - : 1.5; - - _ringRadii[_selectedRingIndex!] = newRingRadius.clamp(minAllowed, maxAllowed); } }); @@ -229,7 +156,6 @@ class _TargetCalibrationState extends State { setState(() { _isDraggingCenter = false; _isDraggingRadius = false; - // Keep selected ring for visual feedback }); } @@ -249,7 +175,6 @@ class _CalibrationPainter extends CustomPainter { final TargetType targetType; final bool isDraggingCenter; final bool isDraggingRadius; - final int? selectedRingIndex; _CalibrationPainter({ required this.centerX, @@ -260,7 +185,6 @@ class _CalibrationPainter extends CustomPainter { required this.targetType, required this.isDraggingCenter, required this.isDraggingRadius, - this.selectedRingIndex, }); @override @@ -309,11 +233,6 @@ class _CalibrationPainter extends CustomPainter { ..strokeWidth = 1 ..color = Colors.white.withValues(alpha: 0.6); - final selectedStrokePaint = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 3 - ..color = Colors.cyan; - // Draw from outside to inside for (int i = ringCount - 1; i >= 0; i--) { final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount; @@ -321,16 +240,7 @@ class _CalibrationPainter extends CustomPainter { zonePaint.color = zoneColors[i]; canvas.drawCircle(center, zoneRadius, zonePaint); - - // Highlight selected ring - if (selectedRingIndex == i) { - canvas.drawCircle(center, zoneRadius, selectedStrokePaint); - - // Draw drag handle on selected ring - _drawRingHandle(canvas, size, center, zoneRadius, i); - } else { - canvas.drawCircle(center, zoneRadius, strokePaint); - } + canvas.drawCircle(center, zoneRadius, strokePaint); } // Draw zone labels (only if within visible area) @@ -373,52 +283,6 @@ class _CalibrationPainter extends CustomPainter { } } - void _drawRingHandle(Canvas canvas, Size size, Offset center, double ringRadius, int ringIndex) { - // Draw handle at the right edge of the selected ring - final handleX = center.dx + ringRadius; - final handleY = center.dy; - - if (handleX < 0 || handleX > size.width) return; - - final handlePos = Offset(handleX, handleY); - - // Handle background - final handlePaint = Paint() - ..color = Colors.cyan - ..style = PaintingStyle.fill; - canvas.drawCircle(handlePos, 12, handlePaint); - - // Arrow indicators - final arrowPaint = Paint() - ..color = Colors.white - ..strokeWidth = 2 - ..style = PaintingStyle.stroke; - - // Outward arrow - canvas.drawLine( - Offset(handlePos.dx + 3, handlePos.dy), - Offset(handlePos.dx + 7, handlePos.dy - 4), - arrowPaint, - ); - canvas.drawLine( - Offset(handlePos.dx + 3, handlePos.dy), - Offset(handlePos.dx + 7, handlePos.dy + 4), - arrowPaint, - ); - - // Inward arrow - canvas.drawLine( - Offset(handlePos.dx - 3, handlePos.dy), - Offset(handlePos.dx - 7, handlePos.dy - 4), - arrowPaint, - ); - canvas.drawLine( - Offset(handlePos.dx - 3, handlePos.dy), - Offset(handlePos.dx - 7, handlePos.dy + 4), - arrowPaint, - ); - } - void _drawSilhouetteZones(Canvas canvas, Size size, Offset center, double radius) { // Simplified silhouette zones final paint = Paint()..style = PaintingStyle.stroke..strokeWidth = 2; @@ -511,9 +375,9 @@ class _CalibrationPainter extends CustomPainter { // Label final textPainter = TextPainter( - text: TextSpan( - text: 'GLOBAL', - style: const TextStyle( + text: const TextSpan( + text: 'RAYON', + style: TextStyle( color: Colors.white, fontSize: 8, fontWeight: FontWeight.bold, @@ -529,12 +393,7 @@ class _CalibrationPainter extends CustomPainter { } void _drawInstructions(Canvas canvas, Size size) { - String instruction; - if (selectedRingIndex != null) { - instruction = 'Anneau ${10 - selectedRingIndex!} selectionne - Glissez pour ajuster'; - } else { - instruction = 'Touchez un anneau pour l\'ajuster individuellement'; - } + const instruction = 'Deplacez le centre ou ajustez le rayon'; final textPainter = TextPainter( text: TextSpan( @@ -562,7 +421,6 @@ class _CalibrationPainter extends CustomPainter { ringCount != oldDelegate.ringCount || isDraggingCenter != oldDelegate.isDraggingCenter || isDraggingRadius != oldDelegate.isDraggingRadius || - selectedRingIndex != oldDelegate.selectedRingIndex || ringRadii != oldDelegate.ringRadii; } } diff --git a/lib/features/analysis/widgets/target_overlay.dart b/lib/features/analysis/widgets/target_overlay.dart index a185d8b..5c2d27e 100644 --- a/lib/features/analysis/widgets/target_overlay.dart +++ b/lib/features/analysis/widgets/target_overlay.dart @@ -17,13 +17,14 @@ class TargetOverlay extends StatelessWidget { final double targetRadius; final TargetType targetType; final int ringCount; - final List? ringRadii; // Individual ring radii multipliers + final List? ringRadii; final void Function(Shot shot)? onShotTapped; final void Function(double x, double y)? onAddShot; final double? groupingCenterX; final double? groupingCenterY; final double? groupingDiameter; final List? referenceImpacts; + final double zoomScale; const TargetOverlay({ super.key, @@ -40,70 +41,80 @@ class TargetOverlay extends StatelessWidget { this.groupingCenterY, this.groupingDiameter, this.referenceImpacts, + this.zoomScale = 1.0, }); @override Widget build(BuildContext context) { - return GestureDetector( - onTapUp: (details) { - if (onAddShot != null) { - final RenderBox box = context.findRenderObject() as RenderBox; - final localPosition = details.localPosition; - final relX = localPosition.dx / box.size.width; - final relY = localPosition.dy / box.size.height; - onAddShot!(relX, relY); - } - }, - child: CustomPaint( - painter: _TargetOverlayPainter( - shots: shots, - targetCenterX: targetCenterX, - targetCenterY: targetCenterY, - targetRadius: targetRadius, - targetType: targetType, - ringCount: ringCount, - ringRadii: ringRadii, - groupingCenterX: groupingCenterX, - groupingCenterY: groupingCenterY, - groupingDiameter: groupingDiameter, - referenceImpacts: referenceImpacts, - ), - child: Stack( - children: shots.map((shot) { - return Positioned( - left: 0, - top: 0, - right: 0, - bottom: 0, - child: LayoutBuilder( - builder: (context, constraints) { - final x = shot.x * constraints.maxWidth; - final y = shot.y * constraints.maxHeight; - return Stack( - children: [ - Positioned( - left: x - 15, - top: y - 15, - child: GestureDetector( - onTap: () => onShotTapped?.call(shot), - child: Container( - width: 30, - height: 30, - decoration: BoxDecoration( - color: Colors.transparent, - shape: BoxShape.circle, + return LayoutBuilder( + builder: (context, constraints) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTapUp: (details) { + if (onAddShot != null) { + // Utiliser les constraints pour un calcul précis + final relX = details.localPosition.dx / constraints.maxWidth; + final relY = details.localPosition.dy / constraints.maxHeight; + onAddShot!(relX, relY); + } + }, + child: CustomPaint( + painter: _TargetOverlayPainter( + shots: shots, + targetCenterX: targetCenterX, + targetCenterY: targetCenterY, + targetRadius: targetRadius, + targetType: targetType, + ringCount: ringCount, + ringRadii: ringRadii, + groupingCenterX: groupingCenterX, + groupingCenterY: groupingCenterY, + groupingDiameter: groupingDiameter, + referenceImpacts: referenceImpacts, + zoomScale: zoomScale, + ), + child: Stack( + children: shots.map((shot) { + return Positioned( + left: 0, + top: 0, + right: 0, + bottom: 0, + child: LayoutBuilder( + builder: (context, innerConstraints) { + final x = shot.x * innerConstraints.maxWidth; + final y = shot.y * innerConstraints.maxHeight; + // Zone de tap qui s'adapte au zoom (taille fixe à l'écran) + final tapSize = 30 / zoomScale; + final halfTapSize = tapSize / 2; + return Stack( + children: [ + Positioned( + left: x - halfTapSize, + top: y - halfTapSize, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => onShotTapped?.call(shot), + child: Container( + width: tapSize, + height: tapSize, + decoration: const BoxDecoration( + color: Colors.transparent, + shape: BoxShape.circle, + ), + ), ), ), - ), - ), - ], - ); - }, - ), - ); - }).toList(), - ), - ), + ], + ); + }, + ), + ); + }).toList(), + ), + ), + ); + }, ); } } @@ -120,6 +131,7 @@ class _TargetOverlayPainter extends CustomPainter { final double? groupingCenterY; final double? groupingDiameter; final List? referenceImpacts; + final double zoomScale; _TargetOverlayPainter({ required this.shots, @@ -133,6 +145,7 @@ class _TargetOverlayPainter extends CustomPainter { this.groupingCenterY, this.groupingDiameter, this.referenceImpacts, + this.zoomScale = 1.0, }); @override @@ -266,26 +279,32 @@ class _TargetOverlayPainter extends CustomPainter { final x = shot.x * size.width; final y = shot.y * size.height; + // Tailles fixes divisées par le zoom pour rester constantes à l'écran + final outerRadius = 10 / zoomScale; + final innerRadius = 8 / zoomScale; + final strokeWidth = 3 / zoomScale; + final fontSize = 10 / zoomScale; + // Draw outer circle (white outline for visibility) final outlinePaint = Paint() ..color = AppTheme.impactOutlineColor ..style = PaintingStyle.stroke - ..strokeWidth = 3; - canvas.drawCircle(Offset(x, y), 10, outlinePaint); + ..strokeWidth = strokeWidth; + canvas.drawCircle(Offset(x, y), outerRadius, outlinePaint); // Draw impact marker final impactPaint = Paint() ..color = AppTheme.impactColor ..style = PaintingStyle.fill; - canvas.drawCircle(Offset(x, y), 8, impactPaint); + canvas.drawCircle(Offset(x, y), innerRadius, impactPaint); // Draw score number final textPainter = TextPainter( text: TextSpan( text: '${shot.score}', - style: const TextStyle( + style: TextStyle( color: Colors.white, - fontSize: 10, + fontSize: fontSize, fontWeight: FontWeight.bold, ), ), @@ -302,26 +321,32 @@ class _TargetOverlayPainter extends CustomPainter { final x = ref.x * size.width; final y = ref.y * size.height; + // Tailles fixes divisées par le zoom pour rester constantes à l'écran + final outerRadius = 12 / zoomScale; + final innerRadius = 10 / zoomScale; + final strokeWidth = 3 / zoomScale; + final fontSize = 12 / zoomScale; + // Draw outer circle (white outline for visibility) final outlinePaint = Paint() ..color = Colors.white ..style = PaintingStyle.stroke - ..strokeWidth = 3; - canvas.drawCircle(Offset(x, y), 12, outlinePaint); + ..strokeWidth = strokeWidth; + canvas.drawCircle(Offset(x, y), outerRadius, outlinePaint); // Draw reference marker (purple) final refPaint = Paint() ..color = Colors.deepPurple ..style = PaintingStyle.fill; - canvas.drawCircle(Offset(x, y), 10, refPaint); + canvas.drawCircle(Offset(x, y), innerRadius, refPaint); // Draw "R" to indicate reference final textPainter = TextPainter( - text: const TextSpan( + text: TextSpan( text: 'R', style: TextStyle( color: Colors.white, - fontSize: 12, + fontSize: fontSize, fontWeight: FontWeight.bold, ), ), @@ -345,6 +370,7 @@ class _TargetOverlayPainter extends CustomPainter { groupingCenterX != oldDelegate.groupingCenterX || groupingCenterY != oldDelegate.groupingCenterY || groupingDiameter != oldDelegate.groupingDiameter || - referenceImpacts != oldDelegate.referenceImpacts; + referenceImpacts != oldDelegate.referenceImpacts || + zoomScale != oldDelegate.zoomScale; } } diff --git a/lib/features/capture/capture_screen.dart b/lib/features/capture/capture_screen.dart index c688f91..6c513e8 100644 --- a/lib/features/capture/capture_screen.dart +++ b/lib/features/capture/capture_screen.dart @@ -11,7 +11,7 @@ import 'package:image_picker/image_picker.dart'; import '../../core/constants/app_constants.dart'; import '../../core/theme/app_theme.dart'; import '../../data/models/target_type.dart'; -import '../analysis/analysis_screen.dart'; +import '../crop/crop_screen.dart'; import 'widgets/target_type_selector.dart'; import 'widgets/image_source_button.dart'; @@ -246,7 +246,7 @@ class _CaptureScreenState extends State { Navigator.push( context, MaterialPageRoute( - builder: (_) => AnalysisScreen( + builder: (_) => CropScreen( imagePath: _selectedImagePath!, targetType: _selectedType, ), diff --git a/lib/features/crop/crop_screen.dart b/lib/features/crop/crop_screen.dart new file mode 100644 index 0000000..b25995b --- /dev/null +++ b/lib/features/crop/crop_screen.dart @@ -0,0 +1,343 @@ +/// Écran de recadrage d'image en format carré (1:1). +/// +/// Permet à l'utilisateur de déplacer et zoomer l'image pour sélectionner +/// la zone à recadrer. Le carré de recadrage est fixe au centre de l'écran. +library; + +import 'dart:io'; +import 'dart:math' as math; +import 'dart:ui' as ui; +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; + + const CropScreen({ + super.key, + required this.imagePath, + required this.targetType, + }); + + @override + State createState() => _CropScreenState(); +} + +class _CropScreenState extends State { + final ImageCropService _cropService = ImageCropService(); + + bool _isLoading = false; + bool _imageLoaded = false; + Size? _imageSize; + + // Position et échelle de l'image + Offset _offset = Offset.zero; + double _scale = 1.0; + double _baseScale = 1.0; + Offset _startFocalPoint = Offset.zero; + Offset _startOffset = Offset.zero; + + // Dimensions calculées + double _cropSize = 0; + Size _viewportSize = Size.zero; + + @override + void initState() { + super.initState(); + _loadImageDimensions(); + } + + Future _loadImageDimensions() async { + final file = File(widget.imagePath); + final bytes = await file.readAsBytes(); + final codec = await ui.instantiateImageCodec(bytes); + final frame = await codec.getNextFrame(); + + setState(() { + _imageSize = Size( + frame.image.width.toDouble(), + frame.image.height.toDouble(), + ); + _imageLoaded = true; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + title: const Text('Recadrer'), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + actions: [ + if (!_isLoading) + IconButton( + icon: const Icon(Icons.check), + onPressed: _onCropConfirm, + ), + ], + ), + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (!_imageLoaded || _imageSize == null) { + return const Center( + child: CircularProgressIndicator(color: Colors.white), + ); + } + + if (_isLoading) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(color: Colors.white), + const SizedBox(height: 16), + Text( + 'Recadrage en cours...', + style: TextStyle(color: Colors.white.withValues(alpha: 0.8)), + ), + ], + ), + ); + } + + return LayoutBuilder( + builder: (context, constraints) { + _viewportSize = Size(constraints.maxWidth, constraints.maxHeight); + + // Taille du carré de crop (90% de la plus petite dimension) + _cropSize = math.min(constraints.maxWidth, constraints.maxHeight) * 0.85; + + // Calculer l'échelle initiale si pas encore fait + if (_scale == 1.0 && _offset == Offset.zero) { + _initializeImagePosition(); + } + + return GestureDetector( + onScaleStart: _onScaleStart, + onScaleUpdate: _onScaleUpdate, + onScaleEnd: _onScaleEnd, + child: Stack( + children: [ + // Image transformée + Positioned.fill( + child: Center( + child: Transform( + transform: Matrix4.identity() + ..setTranslationRaw(_offset.dx, _offset.dy, 0) + ..scale(_scale, _scale, 1.0), + alignment: Alignment.center, + child: Image.file( + File(widget.imagePath), + fit: BoxFit.contain, + width: _viewportSize.width, + height: _viewportSize.height, + ), + ), + ), + ), + + // Overlay de recadrage + Positioned.fill( + child: IgnorePointer( + child: CropOverlay( + cropSize: _cropSize, + showGrid: true, + ), + ), + ), + + // Instructions en bas + Positioned( + left: 0, + right: 0, + bottom: 24, + child: _buildInstructions(), + ), + ], + ), + ); + }, + ); + } + + void _initializeImagePosition() { + if (_imageSize == null) return; + + final imageAspect = _imageSize!.width / _imageSize!.height; + final viewportAspect = _viewportSize.width / _viewportSize.height; + + // Calculer la taille de l'image affichée (avec BoxFit.contain) + double displayWidth, displayHeight; + if (imageAspect > viewportAspect) { + displayWidth = _viewportSize.width; + displayHeight = _viewportSize.width / imageAspect; + } else { + displayHeight = _viewportSize.height; + displayWidth = _viewportSize.height * imageAspect; + } + + // Échelle pour que le plus petit côté de l'image remplisse le carré de crop + final minDisplayDim = math.min(displayWidth, displayHeight); + _scale = _cropSize / minDisplayDim; + + // S'assurer d'un scale minimum + if (_scale < 1.0) _scale = 1.0; + } + + void _onScaleStart(ScaleStartDetails details) { + _baseScale = _scale; + _startFocalPoint = details.focalPoint; + _startOffset = _offset; + } + + void _onScaleUpdate(ScaleUpdateDetails details) { + setState(() { + // Mise à jour du scale + _scale = (_baseScale * details.scale).clamp(0.5, 5.0); + + // Mise à jour de la position + final delta = details.focalPoint - _startFocalPoint; + _offset = _startOffset + delta; + }); + } + + void _onScaleEnd(ScaleEndDetails details) { + // Optionnel: contraindre l'image pour qu'elle couvre toujours le carré de crop + } + + Widget _buildInstructions() { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 24), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.touch_app, + color: Colors.white.withValues(alpha: 0.8), + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Déplacez et zoomez pour cadrer la cible', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 14, + ), + ), + ], + ), + ); + } + + Future _onCropConfirm() async { + if (_imageSize == null) return; + + setState(() { + _isLoading = true; + }); + + try { + // Calculer la zone de crop en coordonnées normalisées de l'image + final cropRect = _calculateCropRect(); + + // Recadrer l'image + final croppedPath = await _cropService.cropToSquare( + widget.imagePath, + cropRect, + ); + + if (!mounted) return; + + // Naviguer vers l'écran d'analyse avec l'image recadrée + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => AnalysisScreen( + imagePath: croppedPath, + targetType: widget.targetType, + ), + ), + ); + } catch (e) { + if (!mounted) return; + + setState(() { + _isLoading = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur lors du recadrage: $e'), + backgroundColor: AppTheme.errorColor, + ), + ); + } + } + + CropRect _calculateCropRect() { + if (_imageSize == null) { + return const CropRect(x: 0, y: 0, width: 1, height: 1); + } + + final imageAspect = _imageSize!.width / _imageSize!.height; + final viewportAspect = _viewportSize.width / _viewportSize.height; + + // Calculer la taille de l'image affichée (avec BoxFit.contain) + double displayWidth, displayHeight; + if (imageAspect > viewportAspect) { + displayWidth = _viewportSize.width; + displayHeight = _viewportSize.width / imageAspect; + } else { + displayHeight = _viewportSize.height; + displayWidth = _viewportSize.height * imageAspect; + } + + // Taille de l'image après scale + final scaledWidth = displayWidth * _scale; + final scaledHeight = displayHeight * _scale; + + // Position du centre de l'image dans le viewport + final imageCenterX = _viewportSize.width / 2 + _offset.dx; + final imageCenterY = _viewportSize.height / 2 + _offset.dy; + + // Position du coin supérieur gauche de l'image + final imageLeft = imageCenterX - scaledWidth / 2; + final imageTop = imageCenterY - scaledHeight / 2; + + // Position du carré de crop (centré dans le viewport) + final cropLeft = (_viewportSize.width - _cropSize) / 2; + final cropTop = (_viewportSize.height - _cropSize) / 2; + + // Convertir en coordonnées relatives à l'image affichée + final relCropLeft = (cropLeft - imageLeft) / scaledWidth; + final relCropTop = (cropTop - imageTop) / scaledHeight; + final relCropSize = _cropSize / scaledWidth; + final relCropSizeY = _cropSize / scaledHeight; + + return CropRect( + x: relCropLeft.clamp(0.0, 1.0), + y: relCropTop.clamp(0.0, 1.0), + width: relCropSize.clamp(0.0, 1.0 - relCropLeft.clamp(0.0, 1.0)), + height: relCropSizeY.clamp(0.0, 1.0 - relCropTop.clamp(0.0, 1.0)), + ); + } +} diff --git a/lib/features/crop/widgets/crop_overlay.dart b/lib/features/crop/widgets/crop_overlay.dart new file mode 100644 index 0000000..075e2e4 --- /dev/null +++ b/lib/features/crop/widgets/crop_overlay.dart @@ -0,0 +1,178 @@ +/// Overlay visuel pour le recadrage d'image. +/// +/// Affiche un masque semi-transparent avec une zone carrée transparente +/// au centre pour indiquer la zone de recadrage. +library; + +import 'package:flutter/material.dart'; + +class CropOverlay extends StatelessWidget { + /// Taille du carré de recadrage (côté en pixels) + final double cropSize; + + /// Afficher la grille des tiers + final bool showGrid; + + const CropOverlay({ + super.key, + required this.cropSize, + this.showGrid = true, + }); + + @override + Widget build(BuildContext context) { + return CustomPaint( + size: Size.infinite, + painter: _CropOverlayPainter( + cropSize: cropSize, + showGrid: showGrid, + ), + ); + } +} + +class _CropOverlayPainter extends CustomPainter { + final double cropSize; + final bool showGrid; + + _CropOverlayPainter({ + required this.cropSize, + required this.showGrid, + }); + + @override + void paint(Canvas canvas, Size size) { + // Calculer la position du carré centré + final cropRect = Rect.fromCenter( + center: Offset(size.width / 2, size.height / 2), + width: cropSize, + height: cropSize, + ); + + // Dessiner le masque semi-transparent + final maskPaint = Paint() + ..color = Colors.black.withValues(alpha: 0.6) + ..style = PaintingStyle.fill; + + // Créer un path pour le masque (tout sauf le carré central) + final maskPath = Path() + ..addRect(Rect.fromLTWH(0, 0, size.width, size.height)) + ..addRect(cropRect) + ..fillType = PathFillType.evenOdd; + + canvas.drawPath(maskPath, maskPaint); + + // Dessiner la bordure du carré de recadrage + final borderPaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + + canvas.drawRect(cropRect, borderPaint); + + // Dessiner les coins accentués + _drawCorners(canvas, cropRect); + + // Dessiner la grille des tiers si activée + if (showGrid) { + _drawGrid(canvas, cropRect); + } + } + + void _drawCorners(Canvas canvas, Rect rect) { + final cornerPaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 4 + ..strokeCap = StrokeCap.round; + + const cornerLength = 20.0; + + // Coin supérieur gauche + canvas.drawLine( + Offset(rect.left, rect.top + cornerLength), + Offset(rect.left, rect.top), + cornerPaint, + ); + canvas.drawLine( + Offset(rect.left, rect.top), + Offset(rect.left + cornerLength, rect.top), + cornerPaint, + ); + + // Coin supérieur droit + canvas.drawLine( + Offset(rect.right - cornerLength, rect.top), + Offset(rect.right, rect.top), + cornerPaint, + ); + canvas.drawLine( + Offset(rect.right, rect.top), + Offset(rect.right, rect.top + cornerLength), + cornerPaint, + ); + + // Coin inférieur gauche + canvas.drawLine( + Offset(rect.left, rect.bottom - cornerLength), + Offset(rect.left, rect.bottom), + cornerPaint, + ); + canvas.drawLine( + Offset(rect.left, rect.bottom), + Offset(rect.left + cornerLength, rect.bottom), + cornerPaint, + ); + + // Coin inférieur droit + canvas.drawLine( + Offset(rect.right - cornerLength, rect.bottom), + Offset(rect.right, rect.bottom), + cornerPaint, + ); + canvas.drawLine( + Offset(rect.right, rect.bottom), + Offset(rect.right, rect.bottom - cornerLength), + cornerPaint, + ); + } + + void _drawGrid(Canvas canvas, Rect rect) { + final gridPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.4) + ..style = PaintingStyle.stroke + ..strokeWidth = 1; + + final thirdWidth = rect.width / 3; + final thirdHeight = rect.height / 3; + + // Lignes verticales + canvas.drawLine( + Offset(rect.left + thirdWidth, rect.top), + Offset(rect.left + thirdWidth, rect.bottom), + gridPaint, + ); + canvas.drawLine( + Offset(rect.left + thirdWidth * 2, rect.top), + Offset(rect.left + thirdWidth * 2, rect.bottom), + gridPaint, + ); + + // Lignes horizontales + canvas.drawLine( + Offset(rect.left, rect.top + thirdHeight), + Offset(rect.right, rect.top + thirdHeight), + gridPaint, + ); + canvas.drawLine( + Offset(rect.left, rect.top + thirdHeight * 2), + Offset(rect.right, rect.top + thirdHeight * 2), + gridPaint, + ); + } + + @override + bool shouldRepaint(covariant _CropOverlayPainter oldDelegate) { + return cropSize != oldDelegate.cropSize || showGrid != oldDelegate.showGrid; + } +} diff --git a/lib/services/image_crop_service.dart b/lib/services/image_crop_service.dart new file mode 100644 index 0000000..52d9254 --- /dev/null +++ b/lib/services/image_crop_service.dart @@ -0,0 +1,153 @@ +/// Service de recadrage d'images. +/// +/// Permet de recadrer une image en format carré (1:1) et de la sauvegarder +/// dans un fichier temporaire pour utilisation ultérieure. +library; + +import 'dart:io'; +import 'dart:math' as math; +import 'package:image/image.dart' as img; +import 'package:path_provider/path_provider.dart'; +import 'package:uuid/uuid.dart'; + +/// Représente une zone de recadrage normalisée (0.0 à 1.0) +class CropRect { + /// Position X du coin supérieur gauche (0.0 à 1.0) + final double x; + + /// Position Y du coin supérieur gauche (0.0 à 1.0) + final double y; + + /// Largeur de la zone (0.0 à 1.0) + final double width; + + /// Hauteur de la zone (0.0 à 1.0) + final double height; + + const CropRect({ + required this.x, + required this.y, + required this.width, + required this.height, + }); + + @override + String toString() => 'CropRect(x: $x, y: $y, w: $width, h: $height)'; +} + +/// Service pour recadrer les images +class ImageCropService { + final Uuid _uuid = const Uuid(); + + /// Taille de sortie maximale pour les images recadrées + static const int maxOutputSize = 1024; + + /// Recadre une image en format carré + /// + /// [sourcePath] - Chemin vers l'image source + /// [cropRect] - Zone de recadrage normalisée (0.0 à 1.0) + /// [outputSize] - Taille de sortie en pixels (carré) + /// + /// Retourne le chemin vers l'image recadrée dans le dossier temporaire + Future cropToSquare( + String sourcePath, + CropRect cropRect, { + int outputSize = maxOutputSize, + }) async { + // Charger l'image source + final file = File(sourcePath); + final bytes = await file.readAsBytes(); + final originalImage = img.decodeImage(bytes); + + if (originalImage == null) { + throw Exception('Impossible de décoder l\'image: $sourcePath'); + } + + // Calculer les coordonnées en pixels + final srcX = (cropRect.x * originalImage.width).round(); + final srcY = (cropRect.y * originalImage.height).round(); + final srcWidth = (cropRect.width * originalImage.width).round(); + final srcHeight = (cropRect.height * originalImage.height).round(); + + // S'assurer que les dimensions sont valides + final clampedX = srcX.clamp(0, originalImage.width - 1); + final clampedY = srcY.clamp(0, originalImage.height - 1); + final clampedWidth = math.min(srcWidth, originalImage.width - clampedX); + final clampedHeight = math.min(srcHeight, originalImage.height - clampedY); + + // Recadrer l'image + img.Image cropped = img.copyCrop( + originalImage, + x: clampedX, + y: clampedY, + width: clampedWidth, + height: clampedHeight, + ); + + // Redimensionner à la taille de sortie si nécessaire + if (cropped.width != outputSize || cropped.height != outputSize) { + cropped = img.copyResize( + cropped, + width: outputSize, + height: outputSize, + interpolation: img.Interpolation.cubic, + ); + } + + // Sauvegarder dans le dossier temporaire + final tempDir = await getTemporaryDirectory(); + final fileName = 'cropped_${_uuid.v4()}.jpg'; + final outputPath = '${tempDir.path}/$fileName'; + + final outputFile = File(outputPath); + await outputFile.writeAsBytes(img.encodeJpg(cropped, quality: 90)); + + return outputPath; + } + + /// Calcule la zone de recadrage carrée maximale centrée sur l'image + /// + /// [imageWidth] - Largeur de l'image en pixels + /// [imageHeight] - Hauteur de l'image en pixels + /// + /// Retourne un CropRect normalisé pour un carré centré + CropRect getDefaultSquareCrop(int imageWidth, int imageHeight) { + final aspectRatio = imageWidth / imageHeight; + + if (aspectRatio > 1) { + // Image plus large que haute - centrer horizontalement + final squareWidth = imageHeight / imageWidth; + final x = (1 - squareWidth) / 2; + return CropRect(x: x, y: 0, width: squareWidth, height: 1); + } else if (aspectRatio < 1) { + // Image plus haute que large - centrer verticalement + final squareHeight = imageWidth / imageHeight; + final y = (1 - squareHeight) / 2; + return CropRect(x: 0, y: y, width: 1, height: squareHeight); + } else { + // Déjà carré + return const CropRect(x: 0, y: 0, width: 1, height: 1); + } + } + + /// Nettoie les fichiers temporaires de crop anciens + Future cleanupOldCrops({Duration maxAge = const Duration(hours: 24)}) async { + try { + final tempDir = await getTemporaryDirectory(); + final dir = Directory(tempDir.path); + final now = DateTime.now(); + + await for (final entity in dir.list()) { + if (entity is File && entity.path.contains('cropped_')) { + final stat = await entity.stat(); + final age = now.difference(stat.modified); + if (age > maxAge) { + await entity.delete(); + } + } + } + } catch (e) { + // Ignorer les erreurs de nettoyage + } + } +}