premier app version beta

This commit is contained in:
2026-01-18 13:38:09 +01:00
commit 031d4a4e17
164 changed files with 13698 additions and 0 deletions

View File

@@ -0,0 +1,273 @@
import 'package:flutter/material.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_theme.dart';
import '../../../services/grouping_analyzer_service.dart';
class GroupingStats extends StatelessWidget {
final GroupingResult groupingResult;
final double targetCenterX;
final double targetCenterY;
const GroupingStats({
super.key,
required this.groupingResult,
required this.targetCenterX,
required this.targetCenterY,
});
@override
Widget build(BuildContext context) {
final offsetX = groupingResult.centerX - targetCenterX;
final offsetY = groupingResult.centerY - targetCenterY;
final offsetDescription = _getOffsetDescription(offsetX, offsetY);
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.center_focus_strong, color: AppTheme.groupingCenterColor),
const SizedBox(width: 8),
Text(
'Groupement',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
_buildQualityBadge(context),
],
),
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStat(
context,
'Diametre',
'${(groupingResult.diameter * 100).toStringAsFixed(1)}%',
icon: Icons.straighten,
),
_buildStat(
context,
'Dispersion',
'${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%',
icon: Icons.scatter_plot,
),
_buildStat(
context,
'Decalage',
offsetDescription,
icon: Icons.compare_arrows,
),
],
),
const SizedBox(height: 12),
_buildOffsetIndicator(context, offsetX, offsetY),
],
),
),
);
}
Widget _buildQualityBadge(BuildContext context) {
final rating = groupingResult.qualityRating;
final color = _getQualityColor(rating);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
...List.generate(5, (index) {
return Icon(
index < rating ? Icons.star : Icons.star_border,
size: 16,
color: color,
);
}),
const SizedBox(width: 4),
Text(
groupingResult.qualityDescription,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
],
),
);
}
Widget _buildStat(
BuildContext context,
String label,
String value, {
required IconData icon,
}) {
return Column(
children: [
Icon(icon, size: 20, color: Colors.grey),
const SizedBox(height: 4),
Text(
value,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
Widget _buildOffsetIndicator(BuildContext context, double offsetX, double offsetY) {
return Container(
height: 80,
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Stack(
children: [
// Grid lines
Center(
child: Container(
width: 1,
color: Colors.grey[300],
),
),
Center(
child: Container(
height: 1,
color: Colors.grey[300],
),
),
// Center point (target)
const Center(
child: Icon(Icons.add, size: 16, color: Colors.grey),
),
// Grouping center
LayoutBuilder(
builder: (context, constraints) {
// Scale offset for visualization (max 40 pixels from center)
final maxOffset = 40.0;
final scaledX = (offsetX * 200).clamp(-maxOffset, maxOffset);
final scaledY = (offsetY * 200).clamp(-maxOffset, maxOffset);
return Center(
child: Transform.translate(
offset: Offset(scaledX, scaledY),
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: AppTheme.groupingCenterColor,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
),
);
},
),
// Labels
Positioned(
top: 4,
left: 0,
right: 0,
child: Text(
'Haut',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
),
),
Positioned(
bottom: 4,
left: 0,
right: 0,
child: Text(
'Bas',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
),
),
Positioned(
left: 4,
top: 0,
bottom: 0,
child: Center(
child: Text(
'G',
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
),
),
),
Positioned(
right: 4,
top: 0,
bottom: 0,
child: Center(
child: Text(
'D',
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
),
),
),
],
),
);
}
Color _getQualityColor(int rating) {
switch (rating) {
case 5:
return AppTheme.successColor;
case 4:
return Colors.lightGreen;
case 3:
return AppTheme.warningColor;
case 2:
return Colors.orange;
default:
return AppTheme.errorColor;
}
}
String _getOffsetDescription(double offsetX, double offsetY) {
if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) {
return 'Centre';
}
String vertical = '';
String horizontal = '';
if (offsetY < -0.02) {
vertical = 'H';
} else if (offsetY > 0.02) {
vertical = 'B';
}
if (offsetX < -0.02) {
horizontal = 'G';
} else if (offsetX > 0.02) {
horizontal = 'D';
}
if (vertical.isNotEmpty && horizontal.isNotEmpty) {
return '$vertical-$horizontal';
}
return vertical.isNotEmpty ? vertical : horizontal;
}
}

View File

@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_theme.dart';
import '../../../data/models/target_type.dart';
import '../../../services/score_calculator_service.dart';
class ScoreCard extends StatelessWidget {
final int totalScore;
final int shotCount;
final ScoreResult? scoreResult;
final TargetType targetType;
const ScoreCard({
super.key,
required this.totalScore,
required this.shotCount,
this.scoreResult,
required this.targetType,
});
@override
Widget build(BuildContext context) {
final maxScore = targetType == TargetType.concentric ? 10 : 5;
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.scoreboard, color: AppTheme.primaryColor),
const SizedBox(width: 8),
Text(
'Score',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildScoreStat(
context,
'Total',
'$totalScore',
subtitle: '/ ${shotCount * maxScore}',
),
_buildScoreStat(
context,
'Impacts',
'$shotCount',
),
_buildScoreStat(
context,
'Moyenne',
shotCount > 0
? (totalScore / shotCount).toStringAsFixed(1)
: '-',
),
if (scoreResult != null)
_buildScoreStat(
context,
'Pourcentage',
'${scoreResult!.percentage.toStringAsFixed(0)}%',
),
],
),
if (scoreResult != null && scoreResult!.scoreDistribution.isNotEmpty) ...[
const SizedBox(height: 12),
const Divider(),
const SizedBox(height: 8),
Text(
'Distribution des scores',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
_buildScoreDistribution(context),
],
],
),
),
);
}
Widget _buildScoreStat(
BuildContext context,
String label,
String value, {
String? subtitle,
}) {
return Column(
children: [
Text(
value,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
if (subtitle != null)
Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey,
),
),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
Widget _buildScoreDistribution(BuildContext context) {
final maxScoreValue = targetType == TargetType.concentric ? 10 : 5;
final distribution = scoreResult!.scoreDistribution;
return Wrap(
spacing: 8,
runSpacing: 4,
children: List.generate(maxScoreValue + 1, (index) {
final score = maxScoreValue - index;
final count = distribution[score] ?? 0;
if (count == 0) return const SizedBox.shrink();
return Chip(
label: Text(
'x$count',
style: const TextStyle(fontSize: 12),
),
avatar: CircleAvatar(
radius: 12,
backgroundColor: _getScoreColor(score, maxScoreValue),
child: Text(
'$score',
style: const TextStyle(
fontSize: 10,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
}),
);
}
Color _getScoreColor(int score, int maxScore) {
if (score == maxScore) return Colors.amber;
if (score >= maxScore * 0.8) return Colors.orange;
if (score >= maxScore * 0.6) return Colors.blue;
if (score >= maxScore * 0.4) return Colors.green;
if (score >= maxScore * 0.2) return Colors.grey;
return Colors.grey[400]!;
}
}

View File

@@ -0,0 +1,561 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../../../data/models/target_type.dart';
class TargetCalibration extends StatefulWidget {
final double initialCenterX;
final double initialCenterY;
final double initialRadius;
final int initialRingCount;
final TargetType targetType;
final List<double>? initialRingRadii; // Individual ring radii multipliers
final Function(double centerX, double centerY, double radius, int ringCount, {List<double>? ringRadii}) onCalibrationChanged;
const TargetCalibration({
super.key,
required this.initialCenterX,
required this.initialCenterY,
required this.initialRadius,
this.initialRingCount = 10,
required this.targetType,
this.initialRingRadii,
required this.onCalibrationChanged,
});
@override
State<TargetCalibration> createState() => _TargetCalibrationState();
}
class _TargetCalibrationState extends State<TargetCalibration> {
late double _centerX;
late double _centerY;
late double _radius;
late int _ringCount;
late List<double> _ringRadii; // Multipliers for each ring (1.0 = normal)
bool _isDraggingCenter = false;
bool _isDraggingRadius = false;
int? _selectedRingIndex; // Index of the ring being adjusted individually
@override
void initState() {
super.initState();
_centerX = widget.initialCenterX;
_centerY = widget.initialCenterY;
_radius = widget.initialRadius;
_ringCount = widget.initialRingCount;
_initRingRadii();
}
void _initRingRadii() {
if (widget.initialRingRadii != null && widget.initialRingRadii!.length == _ringCount) {
_ringRadii = List.from(widget.initialRingRadii!);
} else {
// Initialize with default proportional radii
_ringRadii = List.generate(_ringCount, (i) => (i + 1) / _ringCount);
}
}
@override
void didUpdateWidget(TargetCalibration oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.initialRingCount != oldWidget.initialRingCount) {
_ringCount = widget.initialRingCount;
_initRingRadii();
}
if (widget.initialRadius != oldWidget.initialRadius && !_isDraggingRadius && _selectedRingIndex == null) {
// Update from slider - scale all rings proportionally
final scale = widget.initialRadius / _radius;
_radius = widget.initialRadius;
// Ring radii are relative, so they don't need to change
}
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final size = constraints.biggest;
return GestureDetector(
onTapDown: (details) => _onTapDown(details, size),
onPanStart: (details) => _onPanStart(details, size),
onPanUpdate: (details) => _onPanUpdate(details, size),
onPanEnd: (_) => _onPanEnd(),
child: CustomPaint(
size: size,
painter: _CalibrationPainter(
centerX: _centerX,
centerY: _centerY,
radius: _radius,
ringCount: _ringCount,
ringRadii: _ringRadii,
targetType: widget.targetType,
isDraggingCenter: _isDraggingCenter,
isDraggingRadius: _isDraggingRadius,
selectedRingIndex: _selectedRingIndex,
),
),
);
},
);
}
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;
// Check if tapping on center handle
final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
// Check if tapping on radius handle (on the right edge of the outermost circle)
final minDim = math.min(size.width, size.height);
final outerRadius = _radius * (_ringRadii.isNotEmpty ? _ringRadii.last : 1.0);
final radiusHandleX = _centerX + outerRadius * minDim / size.width;
final radiusHandleY = _centerY;
final distToRadiusHandle = _distance(tapX, tapY, radiusHandleX.clamp(0.0, 1.0), radiusHandleY.clamp(0.0, 1.0));
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;
});
}
}
}
void _onPanUpdate(DragUpdateDetails details, Size size) {
final deltaX = details.delta.dx / size.width;
final deltaY = details.delta.dy / size.height;
final minDim = math.min(size.width, size.height);
setState(() {
if (_isDraggingCenter) {
// Move center
_centerX = _centerX + deltaX;
_centerY = _centerY + deltaY;
} else if (_isDraggingRadius) {
// 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);
}
});
widget.onCalibrationChanged(_centerX, _centerY, _radius, _ringCount, ringRadii: _ringRadii);
}
void _onPanEnd() {
setState(() {
_isDraggingCenter = false;
_isDraggingRadius = false;
// Keep selected ring for visual feedback
});
}
double _distance(double x1, double y1, double x2, double y2) {
final dx = x1 - x2;
final dy = y1 - y2;
return (dx * dx + dy * dy);
}
}
class _CalibrationPainter extends CustomPainter {
final double centerX;
final double centerY;
final double radius;
final int ringCount;
final List<double> ringRadii;
final TargetType targetType;
final bool isDraggingCenter;
final bool isDraggingRadius;
final int? selectedRingIndex;
_CalibrationPainter({
required this.centerX,
required this.centerY,
required this.radius,
required this.ringCount,
required this.ringRadii,
required this.targetType,
required this.isDraggingCenter,
required this.isDraggingRadius,
this.selectedRingIndex,
});
@override
void paint(Canvas canvas, Size size) {
final centerPx = Offset(centerX * size.width, centerY * size.height);
final minDim = size.width < size.height ? size.width : size.height;
final baseRadiusPx = radius * minDim;
if (targetType == TargetType.concentric) {
_drawConcentricZones(canvas, size, centerPx, baseRadiusPx);
} else {
_drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx);
}
// Draw center handle
_drawCenterHandle(canvas, centerPx);
// Draw radius handle (for outer ring)
_drawRadiusHandle(canvas, size, centerPx, baseRadiusPx);
// Draw instructions
_drawInstructions(canvas, size);
}
void _drawConcentricZones(Canvas canvas, Size size, Offset center, double baseRadius) {
// Generate colors for zones
List<Color> zoneColors = [];
for (int i = 0; i < ringCount; i++) {
final ratio = i / ringCount;
if (ratio < 0.2) {
zoneColors.add(Colors.yellow.withValues(alpha: 0.3 - ratio * 0.5));
} else if (ratio < 0.4) {
zoneColors.add(Colors.orange.withValues(alpha: 0.25 - ratio * 0.3));
} else if (ratio < 0.6) {
zoneColors.add(Colors.blue.withValues(alpha: 0.2 - ratio * 0.2));
} else if (ratio < 0.8) {
zoneColors.add(Colors.green.withValues(alpha: 0.15 - ratio * 0.1));
} else {
zoneColors.add(Colors.white.withValues(alpha: 0.1));
}
}
final zonePaint = Paint()..style = PaintingStyle.fill;
final strokePaint = Paint()
..style = PaintingStyle.stroke
..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;
final zoneRadius = baseRadius * ringRadius;
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);
}
}
// Draw zone labels (only if within visible area)
final textPainter = TextPainter(
textDirection: TextDirection.ltr,
);
for (int i = 0; i < ringCount; i++) {
final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount;
final prevRingRadius = i > 0
? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount)
: 0.0;
final zoneRadius = baseRadius * (ringRadius + prevRingRadius) / 2;
// Score: center = 10, decrement by 1 for each ring
final score = 10 - i;
// Only draw label if it's within the visible area
final labelX = center.dx + zoneRadius;
if (labelX < 0 || labelX > size.width) continue;
textPainter.text = TextSpan(
text: '$score',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 12,
fontWeight: FontWeight.bold,
shadows: const [
Shadow(color: Colors.black, blurRadius: 2),
],
),
);
textPainter.layout();
// Draw label on the right side of each zone
final labelY = center.dy - textPainter.height / 2;
if (labelY >= 0 && labelY <= size.height) {
textPainter.paint(canvas, Offset(labelX - textPainter.width / 2, labelY));
}
}
}
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;
// Draw silhouette outline (simplified as rectangle for now)
final silhouetteWidth = radius * 0.8;
final silhouetteHeight = radius * 2;
paint.color = Colors.green.withValues(alpha: 0.5);
canvas.drawRect(
Rect.fromCenter(center: center, width: silhouetteWidth, height: silhouetteHeight),
paint,
);
}
void _drawCenterHandle(Canvas canvas, Offset center) {
// Outer circle
final outerPaint = Paint()
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
..style = PaintingStyle.stroke
..strokeWidth = 3;
canvas.drawCircle(center, 15, outerPaint);
// Inner dot
final innerPaint = Paint()
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
..style = PaintingStyle.fill;
canvas.drawCircle(center, 5, innerPaint);
// Crosshair
final crossPaint = Paint()
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
..strokeWidth = 2;
canvas.drawLine(Offset(center.dx - 20, center.dy), Offset(center.dx - 8, center.dy), crossPaint);
canvas.drawLine(Offset(center.dx + 8, center.dy), Offset(center.dx + 20, center.dy), crossPaint);
canvas.drawLine(Offset(center.dx, center.dy - 20), Offset(center.dx, center.dy - 8), crossPaint);
canvas.drawLine(Offset(center.dx, center.dy + 8), Offset(center.dx, center.dy + 20), crossPaint);
}
void _drawRadiusHandle(Canvas canvas, Size size, Offset center, double baseRadius) {
// Radius handle on the right edge of the outermost ring
final outerRingRadius = ringRadii.isNotEmpty ? ringRadii.last : 1.0;
final actualRadius = baseRadius * outerRingRadius;
final actualHandleX = center.dx + actualRadius;
final clampedHandleX = actualHandleX.clamp(20.0, size.width - 20);
final clampedHandleY = center.dy.clamp(20.0, size.height - 20);
final handlePos = Offset(clampedHandleX, clampedHandleY);
// Check if handle is clamped (radius extends beyond visible area)
final isClamped = actualHandleX > size.width - 20;
final paint = Paint()
..color = isDraggingRadius
? AppTheme.successColor
: (isClamped ? Colors.orange : AppTheme.warningColor)
..style = PaintingStyle.fill;
// Draw handle as a small circle with arrows
canvas.drawCircle(handlePos, 14, paint);
// Draw arrow indicators
final arrowPaint = Paint()
..color = Colors.white
..strokeWidth = 2
..style = PaintingStyle.stroke;
// Left arrow
canvas.drawLine(
Offset(handlePos.dx - 4, handlePos.dy),
Offset(handlePos.dx - 8, handlePos.dy - 4),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx - 4, handlePos.dy),
Offset(handlePos.dx - 8, handlePos.dy + 4),
arrowPaint,
);
// Right arrow
canvas.drawLine(
Offset(handlePos.dx + 4, handlePos.dy),
Offset(handlePos.dx + 8, handlePos.dy - 4),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx + 4, handlePos.dy),
Offset(handlePos.dx + 8, handlePos.dy + 4),
arrowPaint,
);
// Label
final textPainter = TextPainter(
text: TextSpan(
text: 'GLOBAL',
style: const TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(handlePos.dx - textPainter.width / 2, handlePos.dy + 16),
);
}
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';
}
final textPainter = TextPainter(
text: TextSpan(
text: instruction,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 12,
backgroundColor: Colors.black54,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset((size.width - textPainter.width) / 2, size.height - 30),
);
}
@override
bool shouldRepaint(covariant _CalibrationPainter oldDelegate) {
return centerX != oldDelegate.centerX ||
centerY != oldDelegate.centerY ||
radius != oldDelegate.radius ||
ringCount != oldDelegate.ringCount ||
isDraggingCenter != oldDelegate.isDraggingCenter ||
isDraggingRadius != oldDelegate.isDraggingRadius ||
selectedRingIndex != oldDelegate.selectedRingIndex ||
ringRadii != oldDelegate.ringRadii;
}
}

View File

@@ -0,0 +1,343 @@
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; // Individual ring radii multipliers
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;
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,
});
@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,
),
),
),
),
],
);
},
),
);
}).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;
_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,
});
@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;
// 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);
// Draw impact marker
final impactPaint = Paint()
..color = AppTheme.impactColor
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(x, y), 8, impactPaint);
// Draw score number
final textPainter = TextPainter(
text: TextSpan(
text: '${shot.score}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
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;
// 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);
// Draw reference marker (purple)
final refPaint = Paint()
..color = Colors.deepPurple
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(x, y), 10, refPaint);
// Draw "R" to indicate reference
final textPainter = TextPainter(
text: const TextSpan(
text: 'R',
style: TextStyle(
color: Colors.white,
fontSize: 12,
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;
}
}