import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show rootBundle; import 'dart:convert'; import 'package:image/image.dart' as img; import 'package:shared_preferences/shared_preferences.dart'; import '../utils/translations.dart'; import '../utils/target_colors.dart'; class TargetViewScreen extends StatefulWidget { final List jsonFiles; final Function refreshFolders; final bool isCombinedView; const TargetViewScreen({ super.key, required this.jsonFiles, required this.refreshFolders, this.isCombinedView = false, }); @override State createState() => _TargetViewScreenState(); } class _TargetViewScreenState extends State { int _currentIndex = 0; List _bullets = []; List _scaledBullets = []; // Bullets scaled to display target size int _totalScore = 0; Offset? _targetCenter; Uint8List? _targetImageBytes; img.Image? _targetImageDecoded; String _date = ''; int? _highlightedBulletIndex; int? _pressedScoreGroup; late String _targetFilePath; late String targetAssetPath; late String _selectedTargetPath; // For display double _scaleFactorX = 1.0; // Scale factor for X coordinate double _scaleFactorY = 1.0; // Scale factor for Y coordinate Color _bulletColor = Colors.blue; // default/fallback bullet color String _selectedLanguage = 'English'; Color _backgroundColor = Colors.white; bool _showChartView = false; // Toggle between bullet view and chart view @override void initState() { super.initState(); targetAssetPath = 'assets/target.png'; // Default _initializeScreen(); } Future _initializeScreen() async { await Future.wait([ _loadBulletColor(), _loadLanguage(), _loadSelectedTarget(), ]); await _loadJsonAndImage(); } 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(); final selectedTarget = prefs.getString('selectedTarget') ?? 'assets/target.png'; // Always use default target for bullet positioning targetAssetPath = 'assets/target.png'; // Store selected target for display _selectedTargetPath = selectedTarget; _backgroundColor = _getBackgroundColorForTarget(selectedTarget); } Color _getBackgroundColorForTarget(String targetPath) { return TargetColors.getBackgroundColor(targetPath); } 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 } } Future _loadJsonAndImage() async { try { // Load default target for bullet positioning reference final defaultByteData = await rootBundle.load(targetAssetPath); final defaultBytes = defaultByteData.buffer.asUint8List(); img.Image? defaultTargetDecoded = img.decodeImage(defaultBytes); if (defaultTargetDecoded == null) { print("Failed to decode default target image."); return; } // Calculate target center from default target (for bullet positioning) _targetCenter = Offset( defaultTargetDecoded.width / 2, defaultTargetDecoded.height / 2 - 1, ); // Load selected target for display final displayByteData = await rootBundle.load(_selectedTargetPath); final displayBytes = displayByteData.buffer.asUint8List(); _targetImageDecoded = img.decodeImage(displayBytes); if (_targetImageDecoded == null) { print("Failed to decode display target image."); return; } _targetImageBytes = Uint8List.fromList(img.encodePng(_targetImageDecoded!)); if (widget.isCombinedView) { // In combined view, collect all bullets from all files final allBullets = []; String latestDate = 'Unknown Date'; for (var file in widget.jsonFiles) { final jsonString = await file.readAsString(); final data = jsonDecode(jsonString); final bulletsJson = data['bullets'] as List?; if (bulletsJson != null) { allBullets.addAll( bulletsJson.map((b) => Offset(b['x'].toDouble(), b['y'].toDouble())).toList(), ); } if (data['date'] != null) { latestDate = data['date']; } } // Parse and convert date format from yyyy-m-d to dd/MM/yyyy String rawDate = latestDate; if (rawDate != 'Unknown Date' && rawDate.contains('-')) { try { final parts = rawDate.split('-'); if (parts.length == 3) { final year = parts[0]; final month = parts[1].padLeft(2, '0'); final day = parts[2].padLeft(2, '0'); _date = '$day/$month/$year'; } else { _date = rawDate; } } catch (e) { _date = rawDate; } } else { _date = rawDate; } _bullets = allBullets; } else { // Single file view - load from current file final currentFile = widget.jsonFiles[_currentIndex]; final jsonString = await currentFile.readAsString(); final data = jsonDecode(jsonString); // Parse and convert date format from yyyy-m-d to dd/MM/yyyy String rawDate = data['date'] ?? 'Unknown Date'; if (rawDate != 'Unknown Date' && rawDate.contains('-')) { try { final parts = rawDate.split('-'); if (parts.length == 3) { final year = parts[0]; final month = parts[1].padLeft(2, '0'); final day = parts[2].padLeft(2, '0'); _date = '$day/$month/$year'; } else { _date = rawDate; } } catch (e) { _date = rawDate; } } else { _date = rawDate; } final bulletsJson = data['bullets'] as List; _bullets = bulletsJson.map((b) => Offset(b['x'].toDouble(), b['y'].toDouble())).toList(); } _targetFilePath = widget.isCombinedView ? widget.jsonFiles.first.path : widget.jsonFiles[_currentIndex].path; // Calculate scale factors based on image dimensions _scaleFactorX = _targetImageDecoded!.width / defaultTargetDecoded.width; _scaleFactorY = _targetImageDecoded!.height / defaultTargetDecoded.height; // Scale bullets to match display target size _scaledBullets = _bullets.map((bullet) { return Offset( bullet.dx * _scaleFactorX, bullet.dy * _scaleFactorY, ); }).toList(); _totalScore = _bullets .map((b) => _scoreForBullet(b, _targetCenter!)) .fold(0, (sum, score) => sum + score); setState(() {}); } catch (e) { print("Error loading JSON or image: $e"); } } int _scoreForBullet(Offset bullet, Offset center) { const bulletRadius = 25; final distance = (bullet - center).distance - bulletRadius; final rings = [14, 70, 125, 180, 235, 290, 345, 400, 455, 510]; for (int i = 0; i < rings.length; i++) { if (distance <= rings[i]) return 10 - i; } return 0; } Future _deleteTarget() async { try { final file = File(_targetFilePath); await file.delete(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Target deleted')), ); widget.refreshFolders(); Navigator.pop(context); } catch (e) { print("Error deleting target: $e"); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Failed to delete target')), ); } } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: _backgroundColor, appBar: AppBar( title: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.calendar_today, size: 20, color: Colors.white), const SizedBox(width: 8), Text(_date, style: const TextStyle(color: Colors.white)), ], ), backgroundColor: Colors.grey[900], centerTitle: true, iconTheme: const IconThemeData(color: Colors.white), actions: [ IconButton( icon: const Icon(Icons.delete), color: Colors.red, onPressed: () { showDialog( context: context, builder: (BuildContext 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, crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( _t('Delete Target'), style: const TextStyle( color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold, ), textAlign: TextAlign.center, ), const SizedBox(height: 16), Text( _t("Are you sure you want to delete this target?"), style: const TextStyle( color: Colors.white70, fontSize: 16), textAlign: TextAlign.center, ), const SizedBox(height: 24), // "Yes" Button SizedBox( width: double.infinity, child: ElevatedButton( onPressed: () { Navigator.of(context).pop(); _deleteTarget(); // call your delete function }, style: ElevatedButton.styleFrom( backgroundColor: Colors.redAccent, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: Text(_t("Yes, delete")), ), ), const SizedBox(height: 12), // "Cancel" Button SizedBox( width: double.infinity, child: ElevatedButton( onPressed: () => Navigator.of(context).pop(), style: ElevatedButton.styleFrom( backgroundColor: Colors.grey[800], foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: const Text("Cancel"), ), ), ], ), ), ); }, ); }, ), ], ), body: _targetImageDecoded == null ? const Center(child: CircularProgressIndicator()) : _showChartView ? _buildChartView() : Column( children: [ Expanded( child: LayoutBuilder( builder: (context, constraints) { final imgWidth = _targetImageDecoded!.width.toDouble(); final imgHeight = _targetImageDecoded!.height.toDouble(); final screenWidth = constraints.maxWidth; final screenHeight = constraints.maxHeight; 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; // Scale bullet size by target scale factors and display scale factors final dynamicBulletSize = (((_scaleFactorX + _scaleFactorY) / 2) * 53) * ((scaleX + scaleY) / 2); return Stack( children: [ Positioned( left: offsetX, top: offsetY, width: displayWidth, height: displayHeight, child: Image.memory(_targetImageBytes!, fit: BoxFit.fill), ), ..._scaledBullets.asMap().entries.map((entry) { final i = entry.key; final scaledBullet = entry.value; // Scaled bullets are already scaled to display target dimensions final dx = scaledBullet.dx * scaleX + offsetX; final dy = scaledBullet.dy * scaleY + offsetY; // Score is calculated using original bullet position and default target center final originalBullet = _bullets[i]; final score = _scoreForBullet( originalBullet, _targetCenter ?? Offset.zero); final isHighlighted = widget.isCombinedView ? (_pressedScoreGroup == score) : (i == _highlightedBulletIndex); return Positioned( left: dx - dynamicBulletSize / 2, top: dy - dynamicBulletSize / 2, child: Container( width: dynamicBulletSize, height: dynamicBulletSize, decoration: BoxDecoration( color: _bulletColor.withValues(alpha: 0.8), shape: BoxShape.circle, border: Border.all( color: isHighlighted ? Colors.red : Colors.black, width: isHighlighted ? 3 : 1, ), ), ), ); }).toList(), ], ); }, ), ), const SizedBox(height: 12), Container( color: _backgroundColor, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: Row( children: widget.isCombinedView ? _buildGroupedScoreButtons() : _buildIndividualScoreButtons(), ), ), const SizedBox(height: 12), ], ), bottomNavigationBar: BottomAppBar( color: Colors.grey[900], padding: EdgeInsets.zero, child: SizedBox( height: 60, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ // Total score Row( children: [ const Icon(Icons.track_changes, size: 20, color: Colors.white), const SizedBox(width: 12), Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( _t('Total score'), style: const TextStyle( fontSize: 11, color: Colors.white54, fontWeight: FontWeight.w500, ), ), Text( "$_totalScore", style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white, ), ), ], ), ], ), // Chart toggle button IconButton( onPressed: () { setState(() { _showChartView = !_showChartView; }); }, icon: Icon( _showChartView ? Icons.bar_chart : Icons.image, color: Colors.white, size: 24, ), padding: EdgeInsets.zero, constraints: const BoxConstraints(), splashRadius: 24, ), ], ), ), ), ), ); } Widget _buildChartView() { if (widget.isCombinedView) { // Folder view: each target position as a bar with combined scores return _buildFolderChartView(); } else { // Single file view: each shot as a bar return _buildSingleFileChartView(); } } Widget _buildFolderChartView() { // Calculate target scores from all files // Each file represents one target at position (fileIndex + 1) final Map targetScores = {}; for (int fileIdx = 0; fileIdx < widget.jsonFiles.length; fileIdx++) { final file = widget.jsonFiles[fileIdx]; final targetPosition = fileIdx + 1; // T1, T2, T3, etc. final data = jsonDecode(file.readAsStringSync()); final bullets = (data['bullets'] as List?) ?? []; // Sum scores for this target int targetScore = 0; for (int j = 0; j < bullets.length; j++) { final bullet = Offset(bullets[j]['x'].toDouble(), bullets[j]['y'].toDouble()); final score = _scoreForBullet(bullet, _targetCenter ?? Offset.zero); targetScore += score; } targetScores[targetPosition] = targetScore; } if (targetScores.isEmpty) { return Center( child: Text( _t('No data available'), style: const TextStyle(color: Colors.white70), ), ); } final sortedTargets = targetScores.entries.toList() ..sort((a, b) => a.key.compareTo(b.key)); final maxScore = sortedTargets.map((e) => e.value).reduce((a, b) => a > b ? a : b).toDouble(); return Padding( padding: const EdgeInsets.only(left: 16, right: 16, top: 16, bottom: 8), child: Card( color: Colors.white, elevation: 2, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: Padding( padding: const EdgeInsets.all(16), child: CustomPaint( painter: BarChartPainter( data: sortedTargets, maxScore: maxScore, bulletColor: _bulletColor, backgroundColor: Colors.white, selectedLanguage: _selectedLanguage, translateFunc: _t, ), size: Size.infinite, ), ), ), ); } Widget _buildSingleFileChartView() { // Single file view: each shot as a bar final Map shotScores = {}; for (int i = 0; i < _bullets.length; i++) { final score = _scoreForBullet(_bullets[i], _targetCenter ?? Offset.zero); shotScores[i + 1] = score; // Shot number starts at 1 } if (shotScores.isEmpty) { return Center( child: Text( _t('No data available'), style: const TextStyle(color: Colors.white70), ), ); } final sortedShots = shotScores.entries.toList() ..sort((a, b) => a.key.compareTo(b.key)); final maxScore = sortedShots.map((e) => e.value).reduce((a, b) => a > b ? a : b).toDouble(); return Padding( padding: const EdgeInsets.only(left: 16, right: 16, top: 16, bottom: 8), child: Card( color: Colors.white, elevation: 2, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: Padding( padding: const EdgeInsets.all(16), child: CustomPaint( painter: BarChartPainter( data: sortedShots, maxScore: maxScore, bulletColor: _bulletColor, isShotView: true, backgroundColor: Colors.white, selectedLanguage: _selectedLanguage, translateFunc: _t, xAxisLabelKey: 'Points', ), size: Size.infinite, ), ), ), ); } List _buildGroupedScoreButtons() { final Map scoreGroups = {}; for (final bullet in _bullets) { final score = _scoreForBullet(bullet, _targetCenter!); scoreGroups[score] = (scoreGroups[score] ?? 0) + 1; } // Sort scores descending (10 → 0) final sortedEntries = scoreGroups.entries.toList() ..sort((a, b) => b.key.compareTo(a.key)); return sortedEntries.map((entry) { final score = entry.key; final count = entry.value; return Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 6), child: GestureDetector( onTapDown: (_) => setState(() => _pressedScoreGroup = score), onTapUp: (_) => setState(() => _pressedScoreGroup = null), onTapCancel: () => setState(() => _pressedScoreGroup = null), 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: FittedBox( fit: BoxFit.scaleDown, child: Text( '${count}x', style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 14, color: Colors.black87, ), ), ), ), Expanded( child: Container( decoration: BoxDecoration( color: _pressedScoreGroup == score ? Colors.red[400] : _bulletColor.withValues(alpha: 0.85), borderRadius: const BorderRadius.only( bottomLeft: Radius.circular(16), bottomRight: Radius.circular(16), ), ), alignment: Alignment.center, child: FittedBox( fit: BoxFit.scaleDown, child: Text( '$score', style: const TextStyle( fontSize: 28, fontWeight: FontWeight.bold, color: Colors.white, ), ), ), ), ), ], ), ), ), ), ); }).toList(); } List _buildIndividualScoreButtons() { final scoredBullets = _bullets.asMap().entries.map((entry) { final index = entry.key; final bullet = entry.value; final score = _scoreForBullet(bullet, _targetCenter ?? Offset.zero); return {'index': index, 'score': score, 'bullet': bullet}; }).toList(); // Sort descending by score scoredBullets .sort((a, b) => (b['score'] as int).compareTo(a['score'] as int)); return scoredBullets.map((entry) { final int index = entry['index'] as int; final int score = entry['score'] as int; return Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 6), child: GestureDetector( onTapDown: (_) => setState(() => _highlightedBulletIndex = index), onTapUp: (_) => setState(() => _highlightedBulletIndex = null), onTapCancel: () => setState(() => _highlightedBulletIndex = null), 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( '${index + 1}', style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 14, color: Colors.black87, ), ), ), Expanded( child: Container( decoration: BoxDecoration( color: _highlightedBulletIndex == index ? Colors.red[400] : _bulletColor.withValues(alpha: 0.85), 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, ), ), ), ), ], ), ), ), ), ); }).toList(); } } class BarChartPainter extends CustomPainter { final List> data; final double maxScore; final Color bulletColor; final bool isShotView; final Color backgroundColor; final String selectedLanguage; final Function(String) translateFunc; final String? xAxisLabelKey; BarChartPainter({ required this.data, required this.maxScore, required this.bulletColor, this.isShotView = false, this.backgroundColor = Colors.white, this.selectedLanguage = 'English', required this.translateFunc, this.xAxisLabelKey, }); @override void paint(Canvas canvas, Size size) { const leftLabelWidth = 50.0; const padding = 24.0; const bottomPadding = 35.0; final chartWidth = size.width - leftLabelWidth - padding; final chartHeight = size.height - padding - bottomPadding; // Determine if background is light or dark final isLightBackground = _isLightColor(backgroundColor); final axisColor = isLightBackground ? Colors.black54 : Colors.white54; final gridColor = isLightBackground ? Colors.black.withValues(alpha: 0.08) : Colors.white.withValues(alpha: 0.08); final labelColor = isLightBackground ? Colors.black87 : Colors.white70; // Draw Y axis (left) - thinner canvas.drawLine( Offset(leftLabelWidth, padding), Offset(leftLabelWidth, size.height - bottomPadding), Paint() ..color = axisColor ..strokeWidth = 1, ); // Draw X axis (bottom) - thinner canvas.drawLine( Offset(leftLabelWidth, size.height - bottomPadding), Offset(size.width - padding, size.height - bottomPadding), Paint() ..color = axisColor ..strokeWidth = 1, ); // Draw Y axis label (Score) final yAxisLabel = TextPainter( text: TextSpan( text: translateFunc('Score'), style: TextStyle(color: axisColor, fontSize: 10), ), textDirection: TextDirection.ltr, ); yAxisLabel.layout(); yAxisLabel.paint( canvas, Offset(8, padding - 10), ); // Draw X axis label (Targets, Shots, or Points) late String xAxisLabelText; if (xAxisLabelKey != null) { xAxisLabelText = translateFunc(xAxisLabelKey!); } else { xAxisLabelText = isShotView ? translateFunc('Shots') : translateFunc('Targets'); } final xAxisLabel = TextPainter( text: TextSpan( text: xAxisLabelText, style: TextStyle(color: axisColor, fontSize: 10), ), textDirection: TextDirection.ltr, ); xAxisLabel.layout(); xAxisLabel.paint( canvas, Offset(size.width - padding - 40, size.height - 17), ); // Draw grid lines and X axis labels (score values) for (int i = 0; i <= 10; i++) { final xValue = (maxScore / 10) * i; final xPos = leftLabelWidth + (chartWidth * i / 10); // Grid line (lighter) canvas.drawLine( Offset(xPos, padding), Offset(xPos, size.height - bottomPadding), Paint() ..color = gridColor ..strokeWidth = 0.5, ); // X axis label (score) - smaller and lighter if (i % 2 == 0) { // Only show every 2nd label to avoid clutter final textPainter = TextPainter( text: TextSpan( text: '${xValue.toInt()}', style: TextStyle(color: labelColor.withValues(alpha: 0.6), fontSize: 10), ), textDirection: TextDirection.ltr, ); textPainter.layout(); textPainter.paint( canvas, Offset(xPos - textPainter.width / 2, size.height - bottomPadding + 8), ); } } // Draw horizontal bars if (data.isNotEmpty) { final barHeight = chartHeight / (data.length * 1.3); final spacing = barHeight * 0.3; for (int i = 0; i < data.length; i++) { final entry = data[i]; final targetNum = entry.key; final score = entry.value; final barWidth = (score / maxScore) * chartWidth; final yPos = padding + (i * (barHeight + spacing)) + spacing; final xStart = leftLabelWidth; // Draw bar with subtle shadow effect final shadowPaint = Paint() ..color = Colors.black.withValues(alpha: 0.1) ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2); canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromLTWH( xStart + 1, yPos + 1, barWidth, barHeight, ), const Radius.circular(6), ), shadowPaint, ); // Draw main bar with rounded corners canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromLTWH( xStart, yPos, barWidth, barHeight, ), const Radius.circular(6), ), Paint()..color = bulletColor, ); // Draw Y axis label (target name) final label = isShotView ? 'S$targetNum' : 'T$targetNum'; final labelPainter = TextPainter( text: TextSpan( text: label, style: TextStyle( color: labelColor, fontSize: 12, fontWeight: FontWeight.w600, ), ), textDirection: TextDirection.ltr, ); labelPainter.layout(); labelPainter.paint( canvas, Offset( leftLabelWidth - labelPainter.width - 10, yPos + barHeight / 2 - labelPainter.height / 2, ), ); // Draw score at end of bar (only if bar is wide enough) if (barWidth > 20) { final scorePainter = TextPainter( text: TextSpan( text: '${score.toInt()}', style: TextStyle( color: isLightBackground ? Colors.black87 : Colors.white, fontSize: 10, fontWeight: FontWeight.bold, ), ), textDirection: TextDirection.ltr, ); scorePainter.layout(); scorePainter.paint( canvas, Offset( xStart + barWidth + 8, yPos + barHeight / 2 - scorePainter.height / 2, ), ); } } } } @override bool shouldRepaint(BarChartPainter oldDelegate) { return oldDelegate.data != data || oldDelegate.maxScore != maxScore || oldDelegate.bulletColor != bulletColor || oldDelegate.backgroundColor != backgroundColor || oldDelegate.isShotView != isShotView || oldDelegate.selectedLanguage != selectedLanguage || oldDelegate.xAxisLabelKey != xAxisLabelKey; } bool _isLightColor(Color color) { // Calculate luminance to determine if background is light or dark final r = (color.r * 255.0).round(); final g = (color.g * 255.0).round(); final b = (color.b * 255.0).round(); final luminance = (r * 299 + g * 587 + b * 114) / 1000; return luminance > 128; } }