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'; 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(602.51, 600.43); final Size fixedRingTargetSize = const Size(355.94, 359.27); 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.withOpacity(_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([ _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 _loadSelectedTarget() async { final prefs = await SharedPreferences.getInstance(); // For detection mode (cameraWithDetections), always use default target // For presentation mode (mappedTarget), use selected target final selectedTarget = _mode == PreviewMode.mappedTarget ? (prefs.getString('selectedTarget') ?? 'assets/target.png') : '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!)); } setState(() { // Trigger rebuild with the new target }); } 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(0xFF000000); // Black case 'assets/targets/Target7.jpg': return const Color(0xFFFFD454); // Bright yellow case 'assets/targets/Target11.jpg': return const Color(0xFF1E5E81); // Dark teal blue case 'assets/targets/Target14.jpg': return const Color(0xFFFEFEFC); // Off-white case 'assets/targets/Target15.jpg': return const Color(0xFF98926E); // Olive green 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, Offset center) { const double bulletRadius = 25; final List rings = [14, 70, 125, 180, 235, 290, 345, 400, 455, 510]; final rng = score.clamp(0, 10); final double distance = rng == 0 ? rings.last + bulletRadius + 5 : rings[10 - rng]; final angle = (360 / widget.bulletCount) * (_manualBulletScores.length + 1); 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. Read the real image file final File realImageFile = File(widget.imagePath); final realBytes = await realImageFile.readAsBytes(); // 2. 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; } // 3. Cache decoded image as PNG for rendering _cachedRealImage = Uint8List.fromList(img.encodePng(_realImageDecoded!)); // 4. 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']; // 5. 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(); // 6. Map bullets to target and score each one final List> scoredBullets = bulletsCam.map((bullet) { final mappedBullet = _mapToTargetNormalized( bullet, ringCamCenter, fixedRingTargetCenter, sizeCam, fixedRingTargetSize, ); final score = _scoreForBullet( mappedBullet, _targetImageCenterForScoring ?? fixedRingTargetCenter, ); return {'bullet': mappedBullet, 'score': score}; }).toList(); // 7. 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); } // 8. Collect final mapped bullets final List mappedBullets = scoredBullets .map((e) => e['bullet'] as Offset) .toList(); // 9. Load target background image _targetImageDecoded = await _targetImageFuture; if (_targetImageDecoded != null) { _cachedTargetImage = Uint8List.fromList(img.encodePng(_targetImageDecoded!)); } // 10. Update state with mapped 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(); final center = _targetImageCenterForScoring ?? fixedRingTargetCenter; setState(() { for (final score in scores) { final offset = _generatePositionForScore(score, center); _mappedBullets.add(offset); _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')), ), ), ], ), ), ); }, ); } Future _saveTargetData() 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; 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 final targetData = { 'date': dateString, 'totalScore': _totalScore, 'bullets': _mappedBullets.map((b) => {'x': b.dx, 'y': b.dy}).toList(), }; // Write file final jsonFile = File(filePath); await jsonFile.writeAsString(jsonEncode(targetData)); // Close preview and signal refresh Navigator.pop(context, true); } catch (e) { print("Error saving target data: $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.withOpacity(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.withOpacity(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) { final updatedScore = _scoreForBullet( updated, _targetImageCenterForScoring ?? 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)); final String? lastFolder = folders.isNotEmpty ? folders.first.path.split('/').last : null; bool lastIsTournament = false; if (lastFolder != null) { final metadataFile = File('${folders.first.path}/metadata.json'); if (await metadataFile.exists()) { try { final metadata = jsonDecode(await metadataFile.readAsString()); lastIsTournament = metadata['isTournament'] == true; } catch (_) {} } } // 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: 18, 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: 18, 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), ), ], ), ), ), if (lastFolder != null) ...[ const SizedBox(height: 12), // 📁 Last Used Folder Button (highlighted subtly) SizedBox( width: double.infinity, child: Container( decoration: BoxDecoration( border: Border.all(color: Colors.white.withOpacity(0.2), width: 1), borderRadius: BorderRadius.circular(12), ), child: ElevatedButton( onPressed: () { Navigator.pop( context, jsonEncode({ 'folder': lastFolder, 'isTournament': lastIsTournament, }), ); }, style: ElevatedButton.styleFrom( backgroundColor: Colors.grey[800], foregroundColor: Colors.white, elevation: 0, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Icon( lastIsTournament ? Icons.emoji_events : Icons.track_changes, size: 18, color: Colors.white, ), const SizedBox(width: 8), Text( lastFolder, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white), ), ], ), const SizedBox(height: 4), Text( _t('Last used 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) { final normX = (bulletCam.dx - ringCam.dx) / sizeCam.width; final normY = (bulletCam.dy - ringCam.dy) / sizeCam.height; final mappedX = normX * sizeTarget.width + ringTarget.dx; final mappedY = normY * sizeTarget.height + 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 { final autoScore = _mappedBullets .take(widget.bulletCount - _manualBulletScores.length) .map((b) => _scoreForBullet(b, _targetImageCenterForScoring ?? 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), ), actions: [ IconButton( icon: Icon( _mode == PreviewMode.mappedTarget ? Icons.camera_alt : Icons.center_focus_strong, ), color: Colors.white, onPressed: () async { setState(() { _mode = _mode == PreviewMode.mappedTarget ? PreviewMode.cameraWithDetections : PreviewMode.mappedTarget; }); await _loadSelectedTarget(); // Reload target based on new mode }, tooltip: _mode == PreviewMode.mappedTarget ? _t("Show Camera Detections") : _t("Show Mapped Target"), ), IconButton( icon: const Icon(Icons.save), color: Colors.white, onPressed: _saveTargetData, tooltip: _t("Save Target Data"), ), ], ), 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.withOpacity(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(), 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; final double bulletSize = ((scaleX + scaleY) / 2) * 55; 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.withOpacity(_bulletOpacity) : isManual ? Colors.red.withOpacity(_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 Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: AbsorbPointer( absorbing: _isEditMode && _selectedBulletIndexInEdit != null, child: Row( children: 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( bullet, _targetImageCenterForScoring ?? 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: Container( decoration: BoxDecoration( color: _getBulletButtonColor(index), borderRadius: BorderRadius.circular(16), ), alignment: Alignment.center, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( '${index + 1}', style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 12, color: Colors.white, ), ), const SizedBox(height: 4), Text( '$score', style: const TextStyle( fontSize: 28, fontWeight: FontWeight.bold, color: Colors.white, ), ), ], ), ), ), ), ), ); }, ), ), ), ), ], ), const SizedBox(height: 12), ], ), _buildEditController(), if (_mappedBullets.isNotEmpty) Positioned( top: 16, right: 16, child: GestureDetector( onTap: () async { if (!_isEditMode && _mappedBullets.length < widget.bulletCount) { _manualScoreEntry(); return; } setState(() { _isEditMode = !_isEditMode; _selectedBulletIndexInEdit = null; }); }, child: Container( width: 56, height: 56, decoration: BoxDecoration( color: _isEditMode ? Colors.red : (_mappedBullets.length < widget.bulletCount ? Colors.redAccent : _bulletColor), borderRadius: BorderRadius.circular(16), // 👈 Rounded square boxShadow: const [ BoxShadow( color: Colors.black26, offset: Offset(0, 3), blurRadius: 6, ), ], ), child: Icon( _isEditMode ? Icons.check : (_mappedBullets.length < widget.bulletCount ? Icons.warning_amber_rounded : Icons.edit), color: Colors.white, size: 28, ), ), ), ), ], ), bottomNavigationBar: BottomAppBar( color: Colors.grey[900], padding: EdgeInsets.zero, child: SizedBox( height: 150, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ // Left side - Label with icon Padding( padding: const EdgeInsets.only(right: 16), child: FittedBox( fit: BoxFit.scaleDown, child: Row( children: [ const Icon(Icons.track_changes, size: 24, color: Colors.white), const SizedBox(width: 8), const Text( "Total Score:", style: TextStyle( fontSize: 28, fontWeight: FontWeight.bold, color: Colors.white, ), ), ], ), ), ), // Right side - Score number (no box) FittedBox( fit: BoxFit.scaleDown, child: Text( "$_totalScore", style: const TextStyle( fontSize: 28, fontWeight: FontWeight.bold, color: Colors.white, ), ), ), ], ), ), ), ); } }