Updated app with modfications and new UI for graphs
This commit is contained in:
@@ -249,43 +249,14 @@ Color _getColorFromName(String colorName) {
|
||||
void _openFolderTargetView(String folderName) async {
|
||||
final targetFiles = _groupedFiles[folderName] ?? [];
|
||||
|
||||
List<Map<String, dynamic>> 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
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+185
-127
@@ -149,19 +149,52 @@ class _PreviewScreenState extends State<PreviewScreen> {
|
||||
|
||||
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<void> _loadCurrentFolderInfo() async {
|
||||
try {
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final saveDirectory = Directory('${directory.path}/saved_images');
|
||||
|
||||
if (await saveDirectory.exists()) {
|
||||
final folders =
|
||||
saveDirectory.listSync().whereType<Directory>().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<void> _initializeScreen() async {
|
||||
await Future.wait([
|
||||
_loadDefaultTarget(), // Load default target for scale reference
|
||||
@@ -316,7 +349,7 @@ class _PreviewScreenState extends State<PreviewScreen> {
|
||||
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<PreviewScreen> {
|
||||
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<PreviewScreen> {
|
||||
_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<PreviewScreen> {
|
||||
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<PreviewScreen> {
|
||||
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<PreviewScreen> {
|
||||
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<PreviewScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 24),
|
||||
if (_mappedBullets.isNotEmpty)
|
||||
Column(
|
||||
children: [
|
||||
@@ -1711,46 +1801,32 @@ class _PreviewScreenState extends State<PreviewScreen> {
|
||||
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<PreviewScreen> {
|
||||
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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
+562
-129
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user