diff --git a/ShootingTargetSDK_v1.0.0_Android.zip b/ShootingTargetSDK_v1.0.0_Android.zip deleted file mode 100644 index 8800adc..0000000 Binary files a/ShootingTargetSDK_v1.0.0_Android.zip and /dev/null differ diff --git a/assets/targets/Target24.jpg b/assets/targets/Target24.jpg index 3571434..0e9dca3 100644 Binary files a/assets/targets/Target24.jpg and b/assets/targets/Target24.jpg differ diff --git a/lib/screens/book_screen.dart b/lib/screens/book_screen.dart index 4a818e8..fca5aa5 100644 --- a/lib/screens/book_screen.dart +++ b/lib/screens/book_screen.dart @@ -249,43 +249,14 @@ Color _getColorFromName(String colorName) { void _openFolderTargetView(String folderName) async { final targetFiles = _groupedFiles[folderName] ?? []; - List> allBullets = []; - String latestDate = 'Unknown'; - - for (var file in targetFiles) { - final jsonString = await file.readAsString(); - final data = jsonDecode(jsonString); - - final bullets = (data['bullets'] as List) - .map((b) => {'x': b['x'], 'y': b['y']}) - .toList(); - - allBullets.addAll(bullets); - - if (data['date'] != null) { - latestDate = data['date']; // You could sort if you want the most recent - } - } - - // Create combined JSON structure - final combinedJson = jsonEncode({ - 'date': latestDate, - 'bullets': allBullets, - }); - - // Save this JSON temporarily to disk - final tempDir = await getTemporaryDirectory(); - final combinedFile = File('${tempDir.path}/combined_${folderName}.json'); - await combinedFile.writeAsString(combinedJson); - - // Navigate to view screen with the combined file + // Navigate to view screen with all individual files Navigator.push( context, MaterialPageRoute( builder: (context) => TargetViewScreen( - jsonFiles: [combinedFile], // treat it like a normal file + jsonFiles: targetFiles, // Pass all individual files refreshFolders: _loadTargetDataFiles, - isCombinedView: true, // 👈 This line + isCombinedView: true, // 👈 This signals combined/folder view ), ), ); diff --git a/lib/screens/preview_screen.dart b/lib/screens/preview_screen.dart index a6c8fc7..fbde9ac 100644 --- a/lib/screens/preview_screen.dart +++ b/lib/screens/preview_screen.dart @@ -149,19 +149,52 @@ class _PreviewScreenState extends State { Color get _editColor => Colors.deepOrangeAccent; Color get _activeColor => _isEditMode ? _editColor : _bulletColor; - Color get _activeColorWithOpacity => _activeColor.withValues(alpha: _bulletOpacity); + Color get _activeColorWithOpacity => + _activeColor.withValues(alpha: _bulletOpacity); String _selectedLanguage = 'English'; Color _backgroundColor = Colors.grey[900]!; + String _currentFolderName = ''; + String _currentDate = ''; + @override void initState() { super.initState(); _realImageDetections = widget.detections; targetAssetPath = 'assets/target.png'; // Default + _loadCurrentFolderInfo(); _initializeScreen(); } + Future _loadCurrentFolderInfo() async { + try { + final directory = await getApplicationDocumentsDirectory(); + final saveDirectory = Directory('${directory.path}/saved_images'); + + if (await saveDirectory.exists()) { + final folders = + saveDirectory.listSync().whereType().toList(); + if (folders.isNotEmpty) { + // Sort by modification date to get the most recent folder + folders.sort( + (a, b) => b.statSync().changed.compareTo(a.statSync().changed)); + final lastFolder = folders.first.path.split('/').last; + final today = DateTime.now(); + final formattedDate = + "${today.day.toString().padLeft(2, '0')}-${today.month.toString().padLeft(2, '0')}-${today.year.toString().substring(2)}"; // DD-MM-YY format + + setState(() { + _currentFolderName = lastFolder; + _currentDate = formattedDate; + }); + } + } + } catch (e) { + print("Error loading current folder info: $e"); + } + } + Future _initializeScreen() async { await Future.wait([ _loadDefaultTarget(), // Load default target for scale reference @@ -316,7 +349,7 @@ class _PreviewScreenState extends State { return const Color(0xFF000000); // Black case 'assets/targets/Target24.jpg': - return const Color(0xFF000000); // Black + return const Color(0xFFb7b7b7); // Gray case 'assets/targets/Target25.jpg': return const Color(0xFF040308); // Very dark gray @@ -822,7 +855,8 @@ class _PreviewScreenState extends State { return null; } - folders.sort((a, b) => b.statSync().changed.compareTo(a.statSync().changed)); + folders + .sort((a, b) => b.statSync().changed.compareTo(a.statSync().changed)); return folders.first.path.split('/').last; } @@ -1283,6 +1317,29 @@ class _PreviewScreenState extends State { _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, + 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"), + ), + const SizedBox(width: 8), + ], ), body: _isLoading ? const Center(child: CircularProgressIndicator()) @@ -1290,6 +1347,38 @@ class _PreviewScreenState extends State { children: [ Column( children: [ + // Training/Tournament Banner + Container( + color: Colors.grey[300], + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + _currentFolderName.isNotEmpty + ? _currentFolderName + : 'No training selected', + style: const TextStyle( + color: Colors.black, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (_currentDate.isNotEmpty) + Text( + _currentDate, + style: const TextStyle( + color: Colors.black, + fontSize: 11, + ), + ), + ], + ), + ), Expanded( child: LayoutBuilder( builder: (context, constraints) { @@ -1362,7 +1451,8 @@ class _PreviewScreenState extends State { child: Align( alignment: Alignment.topLeft, child: Container( - color: Colors.green.withValues(alpha: 0.5), + color: Colors.green + .withValues(alpha: 0.5), padding: const EdgeInsets.symmetric( horizontal: 4, vertical: 2), child: Text( @@ -1422,11 +1512,11 @@ class _PreviewScreenState extends State { final Color bulletFillColor = isSelectedForEdit || (!_isEditMode && isHighlighted) - ? Colors.red - .withValues(alpha: _bulletOpacity) + ? Colors.red.withValues( + alpha: _bulletOpacity) : isManual - ? Colors.red - .withValues(alpha: _bulletOpacity) + ? Colors.red.withValues( + alpha: _bulletOpacity) : _activeColorWithOpacity; return Positioned( @@ -1459,7 +1549,7 @@ class _PreviewScreenState extends State { }, ), ), - const SizedBox(height: 12), + const SizedBox(height: 24), if (_mappedBullets.isNotEmpty) Column( children: [ @@ -1711,46 +1801,32 @@ class _PreviewScreenState extends State { 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, - ), - ), - ], - ), - ), + Container( + color: Colors.grey[300], + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.track_changes, size: 20, color: Colors.black), + const SizedBox(width: 8), + Text( + _t("Total score:"), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: Colors.black, ), - ], - ), + ), + const SizedBox(width: 12), + Text( + "$_totalScore", + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + ], ), ), // Bottom bar - Action Buttons @@ -1760,96 +1836,78 @@ class _PreviewScreenState extends State { 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( + // Plus button (left, no background) + Expanded( + child: Center( + child: IconButton( + icon: Stack( + alignment: Alignment.center, 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), + const Icon(Icons.folder, color: Colors.white, size: 24), + Positioned( + bottom: 0, + right: 0, + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, ), + padding: const EdgeInsets.all(2), + child: const Icon(Icons.add, color: Colors.grey, size: 10), ), ), ], ), + onPressed: _saveTargetDataWithDialog, ), ), ), - // Edit button - IconButton( - icon: Icon( - _isEditMode ? Icons.check : Icons.edit, - color: _isEditMode ? Colors.red : Colors.white, - size: 24, + // Save button (center with white background) + Expanded( + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: SizedBox( + width: 60, + height: 40, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: _saveToLastFolder, + borderRadius: BorderRadius.circular(8), + child: const Icon(Icons.save, + color: Colors.black, size: 20), + ), + ), + ), + ), + ), + ), + ), + // Edit button (right) + Expanded( + child: Center( + child: 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"), + ), ), - onPressed: () { - setState(() { - _isEditMode = !_isEditMode; - _selectedBulletIndexInEdit = null; - }); - }, - tooltip: _isEditMode ? _t("Done Editing") : _t("Edit Mode"), ), ], ), diff --git a/lib/screens/target_view.dart b/lib/screens/target_view.dart index be95812..08c7e0c 100644 --- a/lib/screens/target_view.dart +++ b/lib/screens/target_view.dart @@ -44,6 +44,7 @@ class _TargetViewScreenState extends State { 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() { @@ -164,33 +165,80 @@ class _TargetViewScreenState extends State { _targetImageBytes = Uint8List.fromList(img.encodePng(_targetImageDecoded!)); - final currentFile = widget.jsonFiles[_currentIndex]; - final jsonString = await currentFile.readAsString(); - final data = jsonDecode(jsonString); + if (widget.isCombinedView) { + // In combined view, collect all bullets from all files + final allBullets = []; + String latestDate = 'Unknown Date'; - // 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 { + 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; } - } catch (e) { + } else { _date = rawDate; } - } else { - _date = rawDate; - } - final bulletsJson = data['bullets'] as List; - _bullets = bulletsJson.map((b) => Offset(b['x'], b['y'])).toList(); - _targetFilePath = currentFile.path; + _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; @@ -344,130 +392,280 @@ class _TargetViewScreenState extends State { ), body: _targetImageDecoded == null ? const Center(child: CircularProgressIndicator()) - : 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; + : _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; + final aspectRatio = imgWidth / imgHeight; + final screenAspect = screenWidth / screenHeight; - double displayWidth, displayHeight, offsetX, offsetY; + 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; - } + 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); + 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, - ), - ), + return Stack( + children: [ + Positioned( + left: offsetX, + top: offsetY, + width: displayWidth, + height: displayHeight, + child: Image.memory(_targetImageBytes!, + fit: BoxFit.fill), ), - ); - }).toList(), - ], - ); - }, - ), + ..._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), + ], ), - 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: 150, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Combined text with icon and score (no box) - FittedBox( - fit: BoxFit.scaleDown, - child: Row( + 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: 28, color: Colors.white), - const SizedBox(width: 8), - Text( - "${_t('Total score')} $_totalScore", - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - color: Colors.white, - ), + 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, ), ), ), @@ -653,3 +851,238 @@ class _TargetViewScreenState extends State { }).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; + } +} diff --git a/lib/utils/target_colors.dart b/lib/utils/target_colors.dart index be021e5..4df6754 100644 --- a/lib/utils/target_colors.dart +++ b/lib/utils/target_colors.dart @@ -26,7 +26,7 @@ class TargetColors { 'assets/targets/Target21.jpg': Color(0xFFDA6868), // Muted red 'assets/targets/Target22.jpg': Color(0xFFFBFAF6), // Off-white 'assets/targets/Target23.jpg': Color(0xFF000000), // Black - 'assets/targets/Target24.jpg': Color(0xFF000000), // Black + 'assets/targets/Target24.jpg': Color(0xFF646462), // Grey 'assets/targets/Target25.jpg': Color(0xFF040308), // Very dark gray 'assets/targets/Target26.jpg': Color(0xFF161C2C), // Dark blue-gray 'assets/targets/Target27.jpg': Color(0xFF28A5DF), // Blue diff --git a/lib/utils/translations.dart b/lib/utils/translations.dart index c2503a8..6664f9f 100644 --- a/lib/utils/translations.dart +++ b/lib/utils/translations.dart @@ -337,10 +337,18 @@ class AppTranslations { 'English': 'Tournament Mode 🏆', 'Slovenian': 'Tekmovalni način 🏆', }, + 'Tournament Mode': { + 'English': 'Tournament Mode', + 'Slovenian': 'Tekmovalni način', + }, 'Mark as Tournament 🏆': { 'English': 'Mark as Tournament 🏆', 'Slovenian': 'Označi kot tekmovanje 🏆', }, + 'Mark as Tournament': { + 'English': 'Mark as Tournament', + 'Slovenian': 'Označi kot tekmovanje', + }, 'Apply': { 'English': 'Apply', 'Slovenian': 'Uporabi', @@ -764,6 +772,80 @@ class AppTranslations { 'English': 'Reload', 'Slovenian': 'Ponovno naloži', }, + + // Analytics + 'Analytics': { + 'English': 'Analytics', + 'Slovenian': 'Analitika', + }, + 'Compare Sessions': { + 'English': 'Compare Sessions', + 'Slovenian': 'Primerjaj treninge', + }, + 'Select 2-4 sessions': { + 'English': 'Select 2-4 sessions to compare', + 'Slovenian': 'Izberi 2-4 treninge za primerjavo', + }, + 'Select at least 2 sessions': { + 'English': 'Select at least 2 sessions', + 'Slovenian': 'Izberi vsaj 2 treninga', + }, + 'Max 4 sessions': { + 'English': 'Maximum 4 sessions can be compared', + 'Slovenian': 'Primerjati je mogoče največ 4 treninge', + }, + 'No sessions available': { + 'English': 'No sessions available', + 'Slovenian': 'Ni razpoložljivih treningov', + }, + 'No data for target': { + 'English': 'No data for this target', + 'Slovenian': 'Ni podatkov za to tarčo', + }, + 'Training View': { + 'English': 'Training View', + 'Slovenian': 'Pogled treninga', + }, + 'Training Comparison': { + 'English': 'Training Comparison', + 'Slovenian': 'Primerjava treningov', + }, + 'Tournament Comparison': { + 'English': 'Tournament Comparison', + 'Slovenian': 'Primerjava tekmovanj', + }, + 'Compare with': { + 'English': 'Compare with', + 'Slovenian': 'Primerjaj s', + }, + 'No folder selected': { + 'English': 'No folder selected', + 'Slovenian': 'Ni izbrane mape', + }, + 'Select': { + 'English': 'Select', + 'Slovenian': 'Izberi', + }, + 'Clear': { + 'English': 'Clear', + 'Slovenian': 'Počisti', + }, + 'Score': { + 'English': 'Score', + 'Slovenian': 'Rezultat', + }, + 'Targets': { + 'English': 'Targets', + 'Slovenian': 'Tarče', + }, + 'Shots': { + 'English': 'Shots', + 'Slovenian': 'Streli', + }, + 'Points': { + 'English': 'Points', + 'Slovenian': 'Točke', + }, }; static String translate(String key, String language) {