Updated app with modfications and new UI for graphs

This commit is contained in:
2026-03-07 21:25:53 +01:00
parent d253a9c4a3
commit c58ca5cf15
7 changed files with 833 additions and 289 deletions
+562 -129
View File
@@ -44,6 +44,7 @@ class _TargetViewScreenState extends State<TargetViewScreen> {
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<TargetViewScreen> {
_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 = <Offset>[];
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<TargetViewScreen> {
),
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<int, int> 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<int, int> 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<TargetViewScreen> {
}).toList();
}
}
class BarChartPainter extends CustomPainter {
final List<MapEntry<int, int>> 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;
}
}