377 lines
12 KiB
Dart
377 lines
12 KiB
Dart
/// Overlay visuel de la cible.
|
|
///
|
|
/// Dessine les anneaux de la cible, les impacts détectés, le cercle de groupement
|
|
/// et les impacts de référence. Gère les interactions tactiles pour l'ajout
|
|
/// d'impacts et la sélection d'impacts existants.
|
|
library;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import '../../../core/theme/app_theme.dart';
|
|
import '../../../data/models/shot.dart';
|
|
import '../../../data/models/target_type.dart';
|
|
|
|
class TargetOverlay extends StatelessWidget {
|
|
final List<Shot> shots;
|
|
final double targetCenterX;
|
|
final double targetCenterY;
|
|
final double targetRadius;
|
|
final TargetType targetType;
|
|
final int ringCount;
|
|
final List<double>? 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<Shot>? referenceImpacts;
|
|
final double zoomScale;
|
|
|
|
const TargetOverlay({
|
|
super.key,
|
|
required this.shots,
|
|
required this.targetCenterX,
|
|
required this.targetCenterY,
|
|
required this.targetRadius,
|
|
required this.targetType,
|
|
this.ringCount = 10,
|
|
this.ringRadii,
|
|
this.onShotTapped,
|
|
this.onAddShot,
|
|
this.groupingCenterX,
|
|
this.groupingCenterY,
|
|
this.groupingDiameter,
|
|
this.referenceImpacts,
|
|
this.zoomScale = 1.0,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
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(),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TargetOverlayPainter extends CustomPainter {
|
|
final List<Shot> shots;
|
|
final double targetCenterX;
|
|
final double targetCenterY;
|
|
final double targetRadius;
|
|
final TargetType targetType;
|
|
final int ringCount;
|
|
final List<double>? ringRadii;
|
|
final double? groupingCenterX;
|
|
final double? groupingCenterY;
|
|
final double? groupingDiameter;
|
|
final List<Shot>? referenceImpacts;
|
|
final double zoomScale;
|
|
|
|
_TargetOverlayPainter({
|
|
required this.shots,
|
|
required this.targetCenterX,
|
|
required this.targetCenterY,
|
|
required this.targetRadius,
|
|
required this.targetType,
|
|
this.ringCount = 10,
|
|
this.ringRadii,
|
|
this.groupingCenterX,
|
|
this.groupingCenterY,
|
|
this.groupingDiameter,
|
|
this.referenceImpacts,
|
|
this.zoomScale = 1.0,
|
|
});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
// Draw target center indicator
|
|
_drawTargetCenter(canvas, size);
|
|
|
|
// Draw grouping circle
|
|
if (groupingCenterX != null && groupingCenterY != null && groupingDiameter != null && shots.length > 1) {
|
|
_drawGroupingCircle(canvas, size);
|
|
}
|
|
|
|
// Draw impacts
|
|
for (final shot in shots) {
|
|
_drawImpact(canvas, size, shot);
|
|
}
|
|
|
|
// Draw reference impacts (with different color)
|
|
if (referenceImpacts != null) {
|
|
for (final ref in referenceImpacts!) {
|
|
_drawReferenceImpact(canvas, size, ref);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _drawTargetCenter(Canvas canvas, Size size) {
|
|
final centerX = targetCenterX * size.width;
|
|
final centerY = targetCenterY * size.height;
|
|
final minDim = size.width < size.height ? size.width : size.height;
|
|
final maxRadius = targetRadius * minDim;
|
|
|
|
final strokePaint = Paint()
|
|
..color = Colors.green.withValues(alpha: 0.5)
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 1;
|
|
|
|
// Draw concentric rings based on ringCount (with individual radii if provided)
|
|
for (int i = 0; i < ringCount; i++) {
|
|
final ringMultiplier = (ringRadii != null && ringRadii!.length == ringCount)
|
|
? ringRadii![i]
|
|
: (i + 1) / ringCount;
|
|
final ringRadius = maxRadius * ringMultiplier;
|
|
canvas.drawCircle(Offset(centerX, centerY), ringRadius, strokePaint);
|
|
}
|
|
|
|
// Draw score labels on rings (only if within visible area)
|
|
final textPainter = TextPainter(
|
|
textDirection: TextDirection.ltr,
|
|
);
|
|
|
|
for (int i = 0; i < ringCount; i++) {
|
|
// Calculate zone center (midpoint between this ring and previous)
|
|
final currentMultiplier = (ringRadii != null && ringRadii!.length == ringCount)
|
|
? ringRadii![i]
|
|
: (i + 1) / ringCount;
|
|
final prevMultiplier = i == 0
|
|
? 0.0
|
|
: (ringRadii != null && ringRadii!.length == ringCount)
|
|
? ringRadii![i - 1]
|
|
: i / ringCount;
|
|
final zoneRadius = maxRadius * (currentMultiplier + prevMultiplier) / 2;
|
|
final score = 10 - i;
|
|
|
|
// Only draw label if it's within the visible area
|
|
final labelX = centerX + zoneRadius;
|
|
if (labelX < 0 || labelX > size.width) continue;
|
|
|
|
textPainter.text = TextSpan(
|
|
text: '$score',
|
|
style: TextStyle(
|
|
color: Colors.green.withValues(alpha: 0.8),
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.bold,
|
|
shadows: const [
|
|
Shadow(color: Colors.black, blurRadius: 2),
|
|
],
|
|
),
|
|
);
|
|
textPainter.layout();
|
|
|
|
// Draw label on the right side of each zone
|
|
final labelY = centerY - textPainter.height / 2;
|
|
if (labelY >= 0 && labelY <= size.height) {
|
|
textPainter.paint(canvas, Offset(labelX - textPainter.width / 2, labelY));
|
|
}
|
|
}
|
|
|
|
// Draw crosshair at center
|
|
final crosshairPaint = Paint()
|
|
..color = Colors.green.withValues(alpha: 0.7)
|
|
..strokeWidth = 1;
|
|
|
|
canvas.drawLine(
|
|
Offset(centerX - 10, centerY),
|
|
Offset(centerX + 10, centerY),
|
|
crosshairPaint,
|
|
);
|
|
canvas.drawLine(
|
|
Offset(centerX, centerY - 10),
|
|
Offset(centerX, centerY + 10),
|
|
crosshairPaint,
|
|
);
|
|
}
|
|
|
|
void _drawGroupingCircle(Canvas canvas, Size size) {
|
|
final centerX = groupingCenterX! * size.width;
|
|
final centerY = groupingCenterY! * size.height;
|
|
final diameter = groupingDiameter! * size.width.clamp(0, size.height);
|
|
|
|
// Draw filled circle
|
|
final fillPaint = Paint()
|
|
..color = AppTheme.groupingCircleColor
|
|
..style = PaintingStyle.fill;
|
|
canvas.drawCircle(Offset(centerX, centerY), diameter / 2, fillPaint);
|
|
|
|
// Draw outline
|
|
final strokePaint = Paint()
|
|
..color = AppTheme.groupingCenterColor
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 2;
|
|
canvas.drawCircle(Offset(centerX, centerY), diameter / 2, strokePaint);
|
|
|
|
// Draw center point
|
|
final centerPaint = Paint()
|
|
..color = AppTheme.groupingCenterColor
|
|
..style = PaintingStyle.fill;
|
|
canvas.drawCircle(Offset(centerX, centerY), 4, centerPaint);
|
|
}
|
|
|
|
void _drawImpact(Canvas canvas, Size size, Shot shot) {
|
|
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 = 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), innerRadius, impactPaint);
|
|
|
|
// Draw score number
|
|
final textPainter = TextPainter(
|
|
text: TextSpan(
|
|
text: '${shot.score}',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: fontSize,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
textDirection: TextDirection.ltr,
|
|
);
|
|
textPainter.layout();
|
|
textPainter.paint(
|
|
canvas,
|
|
Offset(x - textPainter.width / 2, y - textPainter.height / 2),
|
|
);
|
|
}
|
|
|
|
void _drawReferenceImpact(Canvas canvas, Size size, Shot ref) {
|
|
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 = 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), innerRadius, refPaint);
|
|
|
|
// Draw "R" to indicate reference
|
|
final textPainter = TextPainter(
|
|
text: TextSpan(
|
|
text: 'R',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: fontSize,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
textDirection: TextDirection.ltr,
|
|
);
|
|
textPainter.layout();
|
|
textPainter.paint(
|
|
canvas,
|
|
Offset(x - textPainter.width / 2, y - textPainter.height / 2),
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _TargetOverlayPainter oldDelegate) {
|
|
return shots != oldDelegate.shots ||
|
|
targetCenterX != oldDelegate.targetCenterX ||
|
|
targetCenterY != oldDelegate.targetCenterY ||
|
|
targetRadius != oldDelegate.targetRadius ||
|
|
ringCount != oldDelegate.ringCount ||
|
|
ringRadii != oldDelegate.ringRadii ||
|
|
groupingCenterX != oldDelegate.groupingCenterX ||
|
|
groupingCenterY != oldDelegate.groupingCenterY ||
|
|
groupingDiameter != oldDelegate.groupingDiameter ||
|
|
referenceImpacts != oldDelegate.referenceImpacts ||
|
|
zoomScale != oldDelegate.zoomScale;
|
|
}
|
|
}
|