import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:image/image.dart' as img; import 'package:path_provider/path_provider.dart'; import 'yolo_detector.dart'; import 'dart:convert'; // For JSON encoding import 'dart:math'; import 'package:shared_preferences/shared_preferences.dart'; import '../utils/translations.dart'; // DEBUG: Set to true to show scoring rings visualization const bool _DEBUG_SHOW_SCORING_RINGS = false; class ScoringRingsPainter extends CustomPainter { final Offset center; final double scaleX; final double scaleY; final double targetScaleFactorX; final double targetScaleFactorY; ScoringRingsPainter({ required this.center, required this.scaleX, required this.scaleY, required this.targetScaleFactorX, required this.targetScaleFactorY, }); @override void paint(Canvas canvas, Size size) { // Colors for each ring (10 to 1) final List ringColors = [ Colors.red, // 10 Colors.orange, // 9 Colors.yellow, // 8 Colors.lime, // 7 Colors.green, // 6 Colors.cyan, // 5 Colors.blue, // 4 Colors.indigo, // 3 Colors.purple, // 2 Colors.pink, // 1 ]; // Scoring rings distances from center (in default 1200x1200 space) // Must match the rings in _generatePositionForScore final List rings = [14, 68, 123, 177, 232, 286, 342, 397, 455, 510]; // The canvas is already positioned at the image location // Center should be scaled by target scale factors first (default 1200x1200 to actual target size) // then by display scale factors (target image to screen) final scaledCenter = Offset( center.dx * targetScaleFactorX, center.dy * targetScaleFactorY, ); final displayCenter = Offset( scaledCenter.dx * scaleX, scaledCenter.dy * scaleY, ); // Draw each scoring ring with a different color for (int i = 0; i < rings.length; i++) { final paint = Paint() ..color = ringColors[i].withValues(alpha: 0.5) ..style = PaintingStyle.stroke ..strokeWidth = 2; final ringRadius = rings[i]; // Scale by target scale factors (default 1200x1200 to actual target size) // then by display scale factors (target image to screen) final scaledRadiusX = ringRadius * targetScaleFactorX * scaleX; final scaledRadiusY = ringRadius * targetScaleFactorY * scaleY; final scaledRadius = (scaledRadiusX + scaledRadiusY) / 2; canvas.drawCircle(displayCenter, scaledRadius, paint); } // Draw center point final centerPaint = Paint() ..color = Colors.red ..style = PaintingStyle.fill; canvas.drawCircle(displayCenter, 4, centerPaint); } @override bool shouldRepaint(ScoringRingsPainter oldDelegate) { return center != oldDelegate.center || scaleX != oldDelegate.scaleX || scaleY != oldDelegate.scaleY || targetScaleFactorX != oldDelegate.targetScaleFactorX || targetScaleFactorY != oldDelegate.targetScaleFactorY; } } class PreviewScreen extends StatefulWidget { final String imagePath; final List detections; final int bulletCount; // Add bulletCount parameter const PreviewScreen( {super.key, required this.imagePath, required this.detections, required this.bulletCount}); @override State createState() => _PreviewScreenState(); } enum PreviewMode { cameraWithDetections, mappedTarget } class _PreviewScreenState extends State { bool _isLoading = true; PreviewMode _mode = PreviewMode.mappedTarget; List _mappedBullets = []; late List _realImageDetections; img.Image? _realImageDecoded; img.Image? _targetImageDecoded; Future? _targetImageFuture; Offset? _targetImageCenterForScoring; Uint8List? _cachedTargetImage; Uint8List? _cachedRealImage; late String targetAssetPath; final Offset fixedRingTargetCenter = const Offset(601, 598); final Size fixedRingTargetSize = const Size(355.94, 359.27); // Scale factors for different target image dimensions double _scaleFactorX = 1.0; double _scaleFactorY = 1.0; img.Image? _defaultTargetDecoded; // Default target for reference dimensions Offset? _scaledTargetCenterForScoring; // Scaled center for scoring with display target int? _highlightedBulletIndex; final List _manualBulletScores = []; Color _bulletColor = Colors.blue; // default fallback double get _bulletOpacity => 0.8; bool _isEditMode = false; int? _selectedBulletIndexInEdit; Color get _editColor => Colors.deepOrangeAccent; Color get _activeColor => _isEditMode ? _editColor : _bulletColor; Color get _activeColorWithOpacity => _activeColor.withValues(alpha: _bulletOpacity); String _selectedLanguage = 'English'; Color _backgroundColor = Colors.grey[900]!; @override void initState() { super.initState(); _realImageDetections = widget.detections; targetAssetPath = 'assets/target.png'; // Default _initializeScreen(); } Future _initializeScreen() async { await Future.wait([ _loadDefaultTarget(), // Load default target for scale reference _loadSelectedTarget(), _loadBulletColor(), _loadLanguage(), ]); await _runDetectionAndMapping(); } String _t(String key) { return AppTranslations.translate(key, _selectedLanguage); } Future _loadLanguage() async { final prefs = await SharedPreferences.getInstance(); setState(() { _selectedLanguage = prefs.getString('language') ?? 'English'; }); } Future _loadDefaultTarget() async { try { final byteData = await rootBundle.load('assets/target.png'); final bytes = byteData.buffer.asUint8List(); _defaultTargetDecoded = img.decodeImage(bytes); } catch (e) { debugPrint("❌ Error loading default target: $e"); } } Future _loadSelectedTarget() async { final prefs = await SharedPreferences.getInstance(); // Always load the selected/skin target for display // This is used for background and for calculating scale factors final selectedTarget = prefs.getString('selectedTarget') ?? 'assets/target.png'; // Update the target path and trigger image load targetAssetPath = selectedTarget; _targetImageFuture = _loadAndDecodeTargetImage(); _backgroundColor = _getBackgroundColorForTarget(selectedTarget); // Await the image decoding to ensure it's populated before rebuilding _targetImageDecoded = await _targetImageFuture; if (_targetImageDecoded != null) { _cachedTargetImage = Uint8List.fromList(img.encodePng(_targetImageDecoded!)); // Calculate scale factors based on target dimensions _calculateScaleFactors(); } setState(() { // Trigger rebuild with the new target }); } void _calculateScaleFactors() { if (_defaultTargetDecoded == null || _targetImageDecoded == null) { _scaleFactorX = 1.0; _scaleFactorY = 1.0; _scaledTargetCenterForScoring = null; return; } _scaleFactorX = _targetImageDecoded!.width / _defaultTargetDecoded!.width; _scaleFactorY = _targetImageDecoded!.height / _defaultTargetDecoded!.height; // Scale the center point for scoring if (_targetImageCenterForScoring != null) { _scaledTargetCenterForScoring = Offset( _targetImageCenterForScoring!.dx * _scaleFactorX, _targetImageCenterForScoring!.dy * _scaleFactorY, ); } debugPrint( "📏 Scale factors calculated: X=$_scaleFactorX, Y=$_scaleFactorY"); } Color _getBackgroundColorForTarget(String targetPath) { // Map each target to its background color (extracted from first pixel of each image) switch (targetPath) { case 'assets/target.png': return const Color(0xFFFFFFFF); // White case 'assets/targets/Target1.jpg': return const Color(0xFFE8ECD5); // Light beige case 'assets/targets/Target2.jpg': return const Color(0xFFC1DF87); // Light green case 'assets/targets/Target3.jpg': return const Color(0xFFFEFEFE); // Off-white case 'assets/targets/Target4.jpg': return const Color(0xFFD42931); // Red case 'assets/targets/Target5.jpg': return const Color(0xFF02050A); // Very dark blue/black case 'assets/targets/Target6.jpg': return const Color(0xFFF3E7D1); // Light beige case 'assets/targets/Target7.jpg': return const Color(0xFFFFD454); // Bright yellow case 'assets/targets/Target8.jpg': return const Color(0xFF1E5E81); // Dark teal blue case 'assets/targets/Target9.jpg': return const Color(0xFFFEFEFC); // Off-white case 'assets/targets/Target10.jpg': return const Color(0xFF98926E); // Olive green case 'assets/targets/Target11.jpg': return const Color(0xFF13132B); // Dark blue case 'assets/targets/Target12.jpg': return const Color(0xFFF5F4EF); // Off-white case 'assets/targets/Target13.jpg': return const Color(0xFF174AA5); // Blue case 'assets/targets/Target14.jpg': return const Color(0xFFFEE05E); // Light yellow case 'assets/targets/Target15.jpg': return const Color(0xFFFFFFFF); // White case 'assets/targets/Target16.jpg': return const Color(0xFF000000); // Black case 'assets/targets/Target17.jpg': return const Color(0xFF21304D); // Dark blue case 'assets/targets/Target18.jpg': return const Color(0xFF050608); // Very dark gray case 'assets/targets/Target19.jpg': return const Color(0xFFF68527); // Warm orange case 'assets/targets/Target20.jpg': return const Color(0xFFA9D9EF); // Light blue case 'assets/targets/Target21.jpg': return const Color(0xFFDA6868); // Muted red case 'assets/targets/Target22.jpg': return const Color(0xFFFBFAF6); // Off-white case 'assets/targets/Target23.jpg': return const Color(0xFF000000); // Black case 'assets/targets/Target24.jpg': return const Color(0xFF000000); // Black case 'assets/targets/Target25.jpg': return const Color(0xFF040308); // Very dark gray case 'assets/targets/Target26.jpg': return const Color(0xFF161C2C); // Dark blue-gray case 'assets/targets/Target27.jpg': return const Color(0xFF28A5DF); // Blue case 'assets/targets/Target28.jpg': return const Color(0xFFFFFFFF); // White case 'assets/targets/Target29.jpg': return const Color(0xFF180E27); // Dark purple case 'assets/targets/Target30.jpg': return const Color(0xFFFDFDFB); // Off-white case 'assets/targets/Target31.jpg': return const Color(0xFFC52736); // Red case 'assets/targets/Target32.jpg': return const Color(0xFFFCF3EE); // Light cream case 'assets/targets/Target33.jpg': return const Color(0xFFFCFCF3); // Very light off-white case 'assets/targets/Target34.jpg': return const Color(0xFFFFFFFD); // Almost white case 'assets/targets/Target35.jpg': return const Color(0xFFFFC5C4); // Light salmon default: return Colors.grey[900]!; // Fallback to dark gray } } Future _loadBulletColor() async { final prefs = await SharedPreferences.getInstance(); final selectedColor = prefs.getString('bulletColor') ?? 'Blue'; setState(() { _bulletColor = _getColorFromName(selectedColor); }); } Color _getColorFromName(String colorName) { switch (colorName.toLowerCase()) { case 'blue': return Colors.blue; case 'green': return Colors.green; case 'yellow': return Colors.yellow; case 'purple': return Colors.purple; case 'cyan': return Colors.cyan; case 'teal': return Colors.teal; case 'indigo': return Colors.indigo; case 'pink': return Colors.pink; case 'lime': return Colors.lime; case 'mint': return Colors.greenAccent; case 'orange': return Colors.orange; case 'violet': return Colors.deepPurpleAccent; case 'amber': return Colors.amber; case 'brown': return Colors.brown; case 'grey': return Colors.grey; default: return Colors.blue; // fallback } } Offset _generatePositionForScore(int score) { const double bulletRadius = 25; final List rings = [14, 68, 123, 177, 232, 286, 342, 397, 455, 510]; // Always generate positions in the default 1200x1200 coordinate system final center = fixedRingTargetCenter; final rng = score.clamp(0, 10); final double distance; if (rng == 0) { // Score 0: place outside all rings (bullet center beyond the outermost ring) distance = rings.last + bulletRadius; } else if (rng == 10) { // Score 10: place in the center ring (0-14) distance = rings[0] / 2; } else { // Scores 1-9: place in the middle of the ring final outerRadius = rings[10 - rng]; final innerRadius = rng < 10 ? rings[10 - rng - 1] : 0; distance = (outerRadius + innerRadius) / 2; } // Calculate angle based on which manual bullet this will be (add randomness for distribution) final bulletIndex = _manualBulletScores.length; final angleIncrement = 360.0 / widget.bulletCount; final baseAngle = angleIncrement * bulletIndex; // Add random offset within the angle increment to vary positioning final randomOffset = (Random().nextDouble() - 0.5) * angleIncrement * 0.5; final angle = baseAngle + randomOffset; final radians = angle * 3.14159 / 180; return Offset( center.dx + distance * cos(radians), center.dy + distance * sin(radians), ); } Future _loadAndDecodeTargetImage() async { final byteData = await rootBundle.load(targetAssetPath); final bytes = byteData.buffer.asUint8List(); final tempDir = await getTemporaryDirectory(); final targetFile = File('${tempDir.path}/target.png'); await targetFile.writeAsBytes(bytes); final decoded = img.decodeImage(bytes); if (decoded != null) { _targetImageCenterForScoring = Offset(decoded.width / 2, decoded.height / 2 - 1); } return decoded; } Future _runDetectionAndMapping() async { try { // 1. Load target background image FIRST to calculate scale factors _targetImageDecoded = await _targetImageFuture; if (_targetImageDecoded != null) { _cachedTargetImage = Uint8List.fromList(img.encodePng(_targetImageDecoded!)); // Calculate scale factors based on target image dimensions _calculateScaleFactors(); } // 2. Read the real image file final File realImageFile = File(widget.imagePath); final realBytes = await realImageFile.readAsBytes(); // 3. Decode the real image (ensure it's valid) _realImageDecoded = img.decodeImage(realBytes); if (_realImageDecoded == null) { debugPrint("❌ Failed to decode image from path: ${widget.imagePath}"); setState(() => _isLoading = false); return; } // 4. Cache decoded image as PNG for rendering _cachedRealImage = Uint8List.fromList(img.encodePng(_realImageDecoded!)); // 5. Find the detected ring center and size final ringCamData = _getRingCenterSize(_realImageDetections); if (ringCamData == null) { debugPrint("⚠️ Ring (classId 2) not detected in real image."); setState(() => _isLoading = false); return; } final Offset ringCamCenter = ringCamData['center']; final Size sizeCam = ringCamData['size']; // 6. Extract all bullet centers (classId 0) final List bulletsCam = _realImageDetections .where((r) => r.classId == 0) .map((r) => Offset((r.x1 + r.x2) / 2, (r.y1 + r.y2) / 2)) .toList(); // 7. Step 1: Map bullets to DEFAULT target coordinate system (1200x1200) final List> scoredBullets = bulletsCam.map((bullet) { final mappedToDefaultTarget = _mapToTargetNormalized( bullet, ringCamCenter, fixedRingTargetCenter, sizeCam, fixedRingTargetSize, ); final score = _scoreForBullet( mappedToDefaultTarget, fixedRingTargetCenter, // Always score relative to default target center ); return {'bullet': mappedToDefaultTarget, 'score': score}; }).toList(); // 8. Trim to bullet limit by removing highest scoring ones scoredBullets.sort((a, b) => b['score'].compareTo(a['score'])); while (scoredBullets.length > widget.bulletCount) { scoredBullets.removeAt(0); } // 9. Step 2: Remap bullets from default target to actual display target var mappedBullets = scoredBullets.map((bulletData) { final defaultTargetBullet = bulletData['bullet'] as Offset; // Scale bullet position based on actual target dimensions final displayBullet = Offset( defaultTargetBullet.dx * _scaleFactorX, defaultTargetBullet.dy * _scaleFactorY, ); return displayBullet; }).toList(); // 10. Update state with remapped bullets setState(() { _mappedBullets = mappedBullets; _isLoading = false; }); } catch (e, stack) { debugPrint("❌ Error during detection mapping: $e"); debugPrint(stack.toString()); setState(() { _mappedBullets = []; _isLoading = false; }); } } void _manualScoreEntry() { final missingCount = widget.bulletCount - _mappedBullets.length; final controllers = List.generate(missingCount, (_) => TextEditingController()); showDialog( context: context, builder: (context) { return Dialog( backgroundColor: Colors.grey[900], shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: Padding( padding: const EdgeInsets.all(24.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( _t('Enter Missing Bullet Scores'), style: const TextStyle( color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold, ), textAlign: TextAlign.center, ), const SizedBox(height: 20), ConstrainedBox( constraints: const BoxConstraints(maxHeight: 300), child: SingleChildScrollView( child: Column( children: List.generate(missingCount, (index) { return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: TextField( controller: controllers[index], keyboardType: TextInputType.number, style: const TextStyle(color: Colors.white), decoration: InputDecoration( labelText: '${_t('Bullet Score')} ${_mappedBullets.length + index + 1}', labelStyle: const TextStyle(color: Colors.white54), filled: true, fillColor: Colors.grey[800], enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: _bulletColor), ), ), ), ); }), ), ), ), const SizedBox(height: 16), SizedBox( width: double.infinity, child: ElevatedButton( onPressed: () { final scores = controllers .map((c) => int.tryParse(c.text.trim()) ?? 0) .toList(); setState(() { for (final score in scores) { final offset = _generatePositionForScore(score); // Scale position from default coordinate system to display target size final scaledOffset = Offset( offset.dx * _scaleFactorX, offset.dy * _scaleFactorY, ); _mappedBullets.add(scaledOffset); _manualBulletScores.add(score); } }); Navigator.pop(context); }, style: ElevatedButton.styleFrom( backgroundColor: _bulletColor, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: Text(_t('Add Bullets')), ), ), ], ), ), ); }, ); } void _showMissingBulletDialog(int bulletNum) { showDialog( context: context, builder: (context) { return Dialog( backgroundColor: Colors.grey[900], shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: Padding( padding: const EdgeInsets.all(24.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( '${_t('Bullet')} $bulletNum - ${_t('Bullet Score')}', style: const TextStyle( color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 24), // Score buttons 1-9 in 3x3 grid GridView.count( crossAxisCount: 3, shrinkWrap: true, mainAxisSpacing: 12, crossAxisSpacing: 12, childAspectRatio: 1.0, children: List.generate(9, (scoreIndex) { final score = 9 - scoreIndex; return ElevatedButton( onPressed: () { final bulletPosition = _generatePositionForScore(score); // Scale position from default coordinate system to display target size final scaledBulletPosition = Offset( bulletPosition.dx * _scaleFactorX, bulletPosition.dy * _scaleFactorY, ); setState(() { _mappedBullets.add(scaledBulletPosition); _manualBulletScores.add(score); _isEditMode = true; _selectedBulletIndexInEdit = _mappedBullets.length - 1; }); Navigator.pop(context); }, style: ElevatedButton.styleFrom( backgroundColor: _bulletColor, foregroundColor: Colors.white, padding: EdgeInsets.zero, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: Text( '$score', style: const TextStyle( fontSize: 24, fontWeight: FontWeight.bold, ), ), ); }), ), const SizedBox(height: 12), // Bottom row with 0 and 10 Row( children: [ Expanded( child: ElevatedButton( onPressed: () { final score = 0; final bulletPosition = _generatePositionForScore(score); // Scale position from default coordinate system to display target size final scaledBulletPosition = Offset( bulletPosition.dx * _scaleFactorX, bulletPosition.dy * _scaleFactorY, ); setState(() { _mappedBullets.add(scaledBulletPosition); _manualBulletScores.add(score); _isEditMode = true; _selectedBulletIndexInEdit = _mappedBullets.length - 1; }); Navigator.pop(context); }, style: ElevatedButton.styleFrom( backgroundColor: Colors.grey[600], foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: const Text( '0', style: TextStyle( fontSize: 24, fontWeight: FontWeight.bold, ), ), ), ), const SizedBox(width: 12), Expanded( child: ElevatedButton( onPressed: () { final score = 10; final bulletPosition = _generatePositionForScore(score); // Scale position from default coordinate system to display target size final scaledBulletPosition = Offset( bulletPosition.dx * _scaleFactorX, bulletPosition.dy * _scaleFactorY, ); setState(() { _mappedBullets.add(scaledBulletPosition); _manualBulletScores.add(score); _isEditMode = true; _selectedBulletIndexInEdit = _mappedBullets.length - 1; }); Navigator.pop(context); }, style: ElevatedButton.styleFrom( backgroundColor: Colors.grey[600], foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: const Text( '10', style: TextStyle( fontSize: 24, fontWeight: FontWeight.bold, ), ), ), ), ], ), ], ), ), ); }, ); } Future _getLastFolder() async { final directory = await getApplicationDocumentsDirectory(); final saveDirectory = Directory('${directory.path}/saved_images'); if (!await saveDirectory.exists()) { return null; } final folders = saveDirectory.listSync().whereType().toList(); if (folders.isEmpty) { return null; } folders.sort((a, b) => b.statSync().changed.compareTo(a.statSync().changed)); return folders.first.path.split('/').last; } Future _saveToLastFolder() async { try { final lastFolder = await _getLastFolder(); if (lastFolder == null) { // No last folder, show options dialog _saveTargetDataWithDialog(); return; } await _performSave(lastFolder, false); } catch (e) { print("Error saving to last folder: $e"); } } Future _saveTargetDataWithDialog() async { try { // Show a dialog to let the user select between a new folder or an existing one final result = await _showSaveOptionsDialog(); if (result == null) return; String? selectedFolder; bool isTournament = false; try { final decoded = jsonDecode(result); selectedFolder = decoded['folder']; isTournament = decoded['isTournament'] == true; } catch (_) { selectedFolder = result; // Treat plain string as folder name (existing folder) } if (selectedFolder == null) return; await _performSave(selectedFolder, isTournament); } catch (e) { print("Error saving target data: $e"); } } Future _performSave(String selectedFolder, bool isTournament) async { try { final directory = await getApplicationDocumentsDirectory(); final saveDirectory = Directory('${directory.path}/saved_images/$selectedFolder'); // Create folder if it doesn't exist if (!await saveDirectory.exists()) { await saveDirectory.create(recursive: true); } // Save tournament metadata if applicable if (isTournament) { final metadataFile = File('${saveDirectory.path}/metadata.json'); await metadataFile.writeAsString(jsonEncode({'isTournament': true})); } // Use timestamp for a unique file name final now = DateTime.now(); final dateString = "${now.day.toString().padLeft(2, '0')}/${now.month.toString().padLeft(2, '0')}/${now.year}"; final dateStringFile = "${now.year}-${now.month}-${now.day}"; final timeString = "${now.hour}-${now.minute}-${now.second}"; final fileName = "target_${dateStringFile}_$timeString.json"; final filePath = '${saveDirectory.path}/$fileName'; // Prepare target data to save // Convert bullets from display coordinates back to default target coordinates (1200x1200) final bulletsInDefaultCoordinates = _mappedBullets.map((b) { return { 'x': b.dx / _scaleFactorX, 'y': b.dy / _scaleFactorY, }; }).toList(); final targetData = { 'date': dateString, 'totalScore': _totalScore, 'bullets': bulletsInDefaultCoordinates, }; // Write file final jsonFile = File(filePath); await jsonFile.writeAsString(jsonEncode(targetData)); // Close preview and signal refresh Navigator.pop(context, true); } catch (e) { print("Error performing save: $e"); } } Widget _buildEditController() { if (!_isEditMode || _selectedBulletIndexInEdit == null) return const SizedBox.shrink(); return Positioned( bottom: 20, left: 0, right: 0, child: Center( child: Container( padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.75), borderRadius: BorderRadius.circular(16), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ // Up IconButton( icon: const Icon(Icons.keyboard_arrow_up, size: 36, color: Colors.white), onPressed: () => _moveBullet(const Offset(0, -3)), splashRadius: 24, ), // Left - Check - Right Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.keyboard_arrow_left, size: 36, color: Colors.white), onPressed: () => _moveBullet(const Offset(-3, 0)), splashRadius: 24, ), const SizedBox(width: 8), Container( decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.greenAccent.withValues(alpha: 0.2), border: Border.all(color: Colors.greenAccent, width: 2), ), child: IconButton( icon: const Icon(Icons.check, color: Colors.greenAccent, size: 28), onPressed: () => setState(() => _selectedBulletIndexInEdit = null), splashRadius: 22, ), ), const SizedBox(width: 8), IconButton( icon: const Icon(Icons.keyboard_arrow_right, size: 36, color: Colors.white), onPressed: () => _moveBullet(const Offset(3, 0)), splashRadius: 24, ), ], ), // Down IconButton( icon: const Icon(Icons.keyboard_arrow_down, size: 36, color: Colors.white), onPressed: () => _moveBullet(const Offset(0, 3)), splashRadius: 24, ), ], ), ), ), ); } void _moveBullet(Offset offsetDelta) { setState(() { final index = _selectedBulletIndexInEdit!; final current = _mappedBullets[index]; final updated = current + offsetDelta; _mappedBullets[index] = updated; // If this is a manually added bullet, update its score live final manualStartIndex = _mappedBullets.length - _manualBulletScores.length; if (index >= manualStartIndex) { // Scale bullet back to default coordinate system for scoring final defaultTargetBullet = Offset( updated.dx / _scaleFactorX, updated.dy / _scaleFactorY, ); final updatedScore = _scoreForBullet( defaultTargetBullet, fixedRingTargetCenter, ); _manualBulletScores[index - manualStartIndex] = updatedScore; } }); } Future _showSaveOptionsDialog() async { final directory = await getApplicationDocumentsDirectory(); final saveDirectory = Directory('${directory.path}/saved_images'); if (!await saveDirectory.exists()) { await saveDirectory.create(recursive: true); } final folders = saveDirectory.listSync().whereType().toList(); // Sort to get most recently modified folders .sort((a, b) => b.statSync().changed.compareTo(a.statSync().changed)); // Auto-generate next Training and Tournament folder names int trainingCount = 1; int tournamentCount = 1; // Get localized folder names final trainingWord = _t('Training'); final tournamentWord = _t('Tournament'); for (var folder in folders) { final name = folder.path.split('/').last; // Check for both English and Slovenian names if (name.startsWith('Training') || name.startsWith('Trening')) { final num = int.tryParse( name.replaceAll('Training ', '').replaceAll('Trening ', '')); if (num != null && num >= trainingCount) trainingCount = num + 1; } if (name.startsWith('Tournament') || name.startsWith('Tekmovanje')) { final num = int.tryParse( name.replaceAll('Tournament ', '').replaceAll('Tekmovanje ', '')); if (num != null && num >= tournamentCount) tournamentCount = num + 1; } } final String nextTraining = '$trainingWord $trainingCount'; final String nextTournament = '$tournamentWord $tournamentCount'; return showDialog( context: context, builder: (context) { return Dialog( backgroundColor: Colors.grey[900], shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( _t('Save Target Data'), style: const TextStyle( color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 24), // 🎯 Training Button (New folder) SizedBox( width: double.infinity, child: ElevatedButton( onPressed: () { Navigator.pop( context, jsonEncode( {'folder': nextTraining, 'isTournament': false}), ); }, style: ElevatedButton.styleFrom( backgroundColor: _bulletColor, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.track_changes, size: 16, color: Colors.white), const SizedBox(width: 8), Text( nextTraining, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white), ), ], ), const SizedBox(height: 4), Text( _t('New folder'), style: const TextStyle( fontSize: 12, color: Colors.white70), ), ], ), ), ), const SizedBox(height: 12), // 🏆 Tournament Button (New folder) SizedBox( width: double.infinity, child: ElevatedButton( onPressed: () { Navigator.pop( context, jsonEncode( {'folder': nextTournament, 'isTournament': true}), ); }, style: ElevatedButton.styleFrom( backgroundColor: _bulletColor, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.emoji_events, size: 16, color: Colors.white), const SizedBox(width: 8), Text( nextTournament, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white), ), ], ), const SizedBox(height: 4), Text( _t('New folder'), style: const TextStyle( fontSize: 12, color: Colors.white70), ), ], ), ), ), ], ), ), ); }, ); } Map? _getRingCenterSize(List results) { try { final ring = results.firstWhere((r) => r.classId == 2); final cx = (ring.x1 + ring.x2) / 2; final cy = (ring.y1 + ring.y2) / 2; final width = ring.x2 - ring.x1; final height = ring.y2 - ring.y1; return { 'center': Offset(cx, cy), 'size': Size(width, height), }; } catch (_) { return null; } } Offset _mapToTargetNormalized(Offset bulletCam, Offset ringCam, Offset ringTarget, Size sizeCam, Size sizeTarget) { // Calculate scale factor between detected ring and target ring final scaleX = sizeTarget.width / sizeCam.width; final scaleY = sizeTarget.height / sizeCam.height; // Translate bullet position relative to ring center final relativeX = bulletCam.dx - ringCam.dx; final relativeY = bulletCam.dy - ringCam.dy; // Scale the relative position and map to target final mappedX = (relativeX * scaleX) + ringTarget.dx; final mappedY = (relativeY * scaleY) + ringTarget.dy; return Offset(mappedX, mappedY); } int _scoreForBullet(Offset bullet, Offset centerOverride) { const double bulletRadius = 25; final double distanceToEdge = (bullet - centerOverride).distance - bulletRadius; final List rings = [14, 70, 125, 180, 235, 290, 345, 400, 455, 510]; for (int i = 0; i < rings.length; i++) { if (distanceToEdge <= rings[i]) { return 10 - i; } } return 0; } int get _totalScore { // Score is based on the default target coordinate system (1200x1200) // since bullets were mapped to that system and then scaled for display final autoScore = _mappedBullets .take(widget.bulletCount - _manualBulletScores.length) .map((b) { // Scale bullet back to default coordinate system for scoring final defaultTargetBullet = Offset( b.dx / _scaleFactorX, b.dy / _scaleFactorY, ); return _scoreForBullet(defaultTargetBullet, fixedRingTargetCenter); }).fold(0, (sum, score) => sum + score); final manualScore = _manualBulletScores.fold(0, (a, b) => a + b); return autoScore + manualScore; } Color _getBulletButtonColor(int index) { final isSelected = _isEditMode ? index == _selectedBulletIndexInEdit : index == _highlightedBulletIndex; if (isSelected) return Colors.red; final manualStart = _mappedBullets.length - _manualBulletScores.length; final isManual = index >= manualStart; if (isManual) return Colors.redAccent; // ⬅️ Light red for manual bullets if (_isEditMode) return _editColor; return _bulletColor; } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: _backgroundColor, appBar: AppBar( backgroundColor: Colors.grey[900], iconTheme: const IconThemeData(color: Colors.white), title: Text( _t('Detection Preview'), style: const TextStyle(color: Colors.white), ), ), body: _isLoading ? const Center(child: CircularProgressIndicator()) : Stack( children: [ Column( children: [ Expanded( child: LayoutBuilder( builder: (context, constraints) { final screenWidth = constraints.maxWidth; final screenHeight = constraints.maxHeight; final displayImage = _mode == PreviewMode.mappedTarget ? _cachedTargetImage : _cachedRealImage; final imgData = _mode == PreviewMode.mappedTarget ? _targetImageDecoded : _realImageDecoded; if (displayImage == null || imgData == null) { return const Center( child: Text("Image not available")); } final imgWidth = imgData.width.toDouble(); final imgHeight = imgData.height.toDouble(); final aspectRatio = imgWidth / imgHeight; final screenAspect = screenWidth / screenHeight; double displayWidth, displayHeight, offsetX, offsetY; if (aspectRatio > screenAspect) { displayWidth = screenWidth; displayHeight = screenWidth / aspectRatio; offsetX = 0; offsetY = (screenHeight - displayHeight) / 2; } else { displayHeight = screenHeight; displayWidth = screenHeight * aspectRatio; offsetY = 0; offsetX = (screenWidth - displayWidth) / 2; } final scaleX = displayWidth / imgWidth; final scaleY = displayHeight / imgHeight; final imageWidget = Image.memory(displayImage, fit: BoxFit.fill); return Stack( children: [ Positioned( left: offsetX, top: offsetY, width: displayWidth, height: displayHeight, child: imageWidget, ), if (_mode == PreviewMode.cameraWithDetections) ..._realImageDetections.map((d) { final left = d.x1 * scaleX + offsetX; final top = d.y1 * scaleY + offsetY; final width = (d.x2 - d.x1) * scaleX; final height = (d.y2 - d.y1) * scaleY; return Positioned( left: left, top: top, width: width, height: height, child: Container( decoration: BoxDecoration( border: Border.all( color: Colors.green, width: 2), ), child: Align( alignment: Alignment.topLeft, child: Container( color: Colors.green.withValues(alpha: 0.5), padding: const EdgeInsets.symmetric( horizontal: 4, vertical: 2), child: Text( "ID:${d.classId} ${(d.confidence * 100).toStringAsFixed(1)}%", style: const TextStyle( color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold, ), ), ), ), ), ); }).toList(), // DEBUG: Show scoring rings in both modes to verify alignment if (_DEBUG_SHOW_SCORING_RINGS) Positioned( left: offsetX, top: offsetY, width: displayWidth, height: displayHeight, child: CustomPaint( painter: ScoringRingsPainter( center: fixedRingTargetCenter, scaleX: scaleX, scaleY: scaleY, targetScaleFactorX: _scaleFactorX, targetScaleFactorY: _scaleFactorY, ), ), ), if (_mode == PreviewMode.mappedTarget) ..._mappedBullets .asMap() .entries .take(widget.bulletCount) .map((entry) { final index = entry.key; final b = entry.value; final dx = b.dx * scaleX + offsetX; final dy = b.dy * scaleY + offsetY; // Scale bullet size by target scale factors and display scale factors final double bulletSize = (((_scaleFactorX + _scaleFactorY) / 2) * 53) * ((scaleX + scaleY) / 2); final isHighlighted = index == _highlightedBulletIndex; final isSelectedForEdit = index == _selectedBulletIndexInEdit; final manualStart = _mappedBullets.length - _manualBulletScores.length; final isManual = index >= manualStart; final Color bulletFillColor = isSelectedForEdit || (!_isEditMode && isHighlighted) ? Colors.red .withValues(alpha: _bulletOpacity) : isManual ? Colors.red .withValues(alpha: _bulletOpacity) : _activeColorWithOpacity; return Positioned( left: dx - bulletSize / 2, top: dy - bulletSize / 2, child: Container( width: bulletSize, height: bulletSize, decoration: BoxDecoration( color: bulletFillColor, shape: BoxShape.circle, border: Border.all( color: (isSelectedForEdit || (!_isEditMode && isHighlighted)) ? Colors.red : Colors.black, width: (isSelectedForEdit || (!_isEditMode && isHighlighted)) ? 3 : 1, ), ), ), ); }).toList(), ], ); }, ), ), const SizedBox(height: 12), if (_mappedBullets.isNotEmpty) Column( children: [ // Score buttons for detected and missing bullets Padding( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6), child: AbsorbPointer( absorbing: _isEditMode && _selectedBulletIndexInEdit != null, child: Row( children: [ // Detected bullets ...List.generate( _mappedBullets.length, (index) { final bullet = _mappedBullets[index]; final isManual = index >= (_mappedBullets.length - _manualBulletScores.length); final score = isManual ? _manualBulletScores[index - (_mappedBullets.length - _manualBulletScores.length)] : _scoreForBullet( // Convert bullet back to default coordinate system for scoring Offset( bullet.dx / _scaleFactorX, bullet.dy / _scaleFactorY, ), fixedRingTargetCenter, ); return Expanded( child: Padding( padding: const EdgeInsets.symmetric( horizontal: 6), child: GestureDetector( onTap: () { if (_isEditMode) { setState(() => _selectedBulletIndexInEdit = index); } }, onTapDown: (_) { if (!_isEditMode) { setState(() => _highlightedBulletIndex = index); } }, onTapUp: (_) { if (!_isEditMode) { setState(() => _highlightedBulletIndex = null); } }, onTapCancel: () { if (!_isEditMode) { setState(() => _highlightedBulletIndex = null); } }, child: AnimatedContainer( duration: const Duration( milliseconds: 200), height: 100, decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), boxShadow: const [ BoxShadow( color: Colors.black26, offset: Offset(0, 2), blurRadius: 6, ), ], ), child: Column( children: [ Container( height: 30, decoration: BoxDecoration( color: Colors.grey[300], borderRadius: const BorderRadius .only( topLeft: Radius.circular(16), topRight: Radius.circular(16), ), ), alignment: Alignment.center, child: Text( '${index + 1}', style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 14, color: Colors.black87, ), ), ), Expanded( child: Container( decoration: BoxDecoration( color: _getBulletButtonColor( index), borderRadius: const BorderRadius .only( bottomLeft: Radius.circular( 16), bottomRight: Radius.circular( 16), ), ), alignment: Alignment.center, child: Text( '$score', style: const TextStyle( fontSize: 28, fontWeight: FontWeight.bold, color: Colors.white, ), ), ), ), ], ), ), ), ), ); }, ), // Missing bullets ...List.generate( widget.bulletCount - _mappedBullets.length, (index) { final bulletNum = _mappedBullets.length + index + 1; return Expanded( child: Padding( padding: const EdgeInsets.symmetric( horizontal: 6), child: GestureDetector( onTap: () { _showMissingBulletDialog( bulletNum); }, child: Container( height: 100, decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), boxShadow: const [ BoxShadow( color: Colors.black26, offset: Offset(0, 2), blurRadius: 6, ), ], ), child: Column( children: [ Container( height: 30, decoration: BoxDecoration( color: Colors.grey[300], borderRadius: const BorderRadius .only( topLeft: Radius.circular(16), topRight: Radius.circular(16), ), ), alignment: Alignment.center, child: Text( '$bulletNum', style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 14, color: Colors.black87, ), ), ), Expanded( child: Container( decoration: BoxDecoration( color: Colors.redAccent, borderRadius: const BorderRadius .only( bottomLeft: Radius.circular( 16), bottomRight: Radius.circular( 16), ), ), alignment: Alignment.center, child: const Text( '?', style: TextStyle( fontSize: 32, fontWeight: FontWeight.bold, color: Colors.white, ), ), ), ), ], ), ), ), ), ); }, ), ], ), ), ), ], ), const SizedBox(height: 12), ], ), _buildEditController(), ], ), bottomNavigationBar: Column( mainAxisSize: MainAxisSize.min, children: [ // Top bar - Total Score BottomAppBar( color: Colors.grey[900], padding: EdgeInsets.zero, child: SizedBox( height: 60, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: FittedBox( fit: BoxFit.scaleDown, child: Row( children: [ const Icon(Icons.track_changes, size: 24, color: Colors.white), const SizedBox(width: 8), Text( _t("Total score:"), style: const TextStyle( fontSize: 24, fontWeight: FontWeight.bold, color: Colors.white, ), ), const SizedBox(width: 16), Text( "$_totalScore", style: const TextStyle( fontSize: 24, fontWeight: FontWeight.bold, color: Colors.white, ), ), ], ), ), ), ], ), ), ), // Bottom bar - Action Buttons BottomAppBar( color: Colors.grey[900], padding: EdgeInsets.zero, child: SizedBox( height: 60, child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ // Toggle view button IconButton( icon: Icon( _mode == PreviewMode.mappedTarget ? Icons.camera_alt : Icons.center_focus_strong, color: Colors.white, size: 24, ), onPressed: () async { setState(() { _mode = _mode == PreviewMode.mappedTarget ? PreviewMode.cameraWithDetections : PreviewMode.mappedTarget; }); await _loadSelectedTarget(); }, tooltip: _mode == PreviewMode.mappedTarget ? _t("Show Camera Detections") : _t("Show Mapped Target"), ), // Split Save button Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: SizedBox( width: 120, height: 40, child: Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), ), child: Row( children: [ // Save to last folder Expanded( child: Material( color: Colors.transparent, child: InkWell( onTap: _saveToLastFolder, borderRadius: const BorderRadius.only( topLeft: Radius.circular(8), bottomLeft: Radius.circular(8), ), child: const Icon(Icons.save, color: Colors.black, size: 20), ), ), ), // Divider Container( width: 1, color: Colors.grey[300], ), // New folder Expanded( child: Material( color: Colors.transparent, child: InkWell( onTap: _saveTargetDataWithDialog, borderRadius: const BorderRadius.only( topRight: Radius.circular(8), bottomRight: Radius.circular(8), ), child: Icon(Icons.add, color: Colors.black, size: 24), ), ), ), ], ), ), ), ), // Edit button IconButton( icon: Icon( _isEditMode ? Icons.check : Icons.edit, color: _isEditMode ? Colors.red : Colors.white, size: 24, ), onPressed: () { setState(() { _isEditMode = !_isEditMode; _selectedBulletIndexInEdit = null; }); }, tooltip: _isEditMode ? _t("Done Editing") : _t("Edit Mode"), ), ], ), ), ), ], ), ); } }