first commit

This commit is contained in:
2025-04-05 11:47:40 +02:00
commit 1fb8c28c2e
99 changed files with 7656 additions and 0 deletions
+809
View File
@@ -0,0 +1,809 @@
import 'dart:io';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; // For DateFormat
import 'package:path_provider/path_provider.dart'; // For getApplicationDocumentsDirectory
import 'target_view.dart'; // Import the TargetViewScreen
import 'package:shared_preferences/shared_preferences.dart';
class BookScreen extends StatefulWidget {
const BookScreen({super.key});
@override
State<BookScreen> createState() => _BookScreenState();
}
class _BookScreenState extends State<BookScreen> with WidgetsBindingObserver {
Map<String, List<File>> _groupedFiles = {}; // Files grouped by folder
Map<String, DateTime> _folderCreationDates = {}; // Folder creation dates
Map<String, int> _folderScores = {}; // Folder scores (sum of scores of all target files inside)
String? _expandedFolder;
Color _bulletColor = Colors.blue; // default fallback
Map<String, bool> _folderTypes = {}; // true = tournament, false = training
String _folderFilter = 'all';
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this); // Register to listen for lifecycle events
_loadBulletColor();
_loadTargetDataFiles(); // Initial load when the screen is first created
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this); // Unregister from lifecycle events when disposed
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed) {
// Reload data when the screen is resumed or comes into view
_loadTargetDataFiles();
}
}
Future<void> _loadBulletColor() async {
final prefs = await SharedPreferences.getInstance();
final selected = prefs.getString('bulletColor') ?? 'Blue';
setState(() {
_bulletColor = _getColorFromName(selected);
});
}
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 'sky':
return Colors.orange;
case 'violet':
return Colors.deepPurpleAccent;
default:
return Colors.blue; // fallback
}
}
// Load the target data files from the directory
void _loadTargetDataFiles() async {
try {
final directory = await getApplicationDocumentsDirectory();
final dataDirectory = Directory('${directory.path}/saved_images');
if (!await dataDirectory.exists()) {
await dataDirectory.create(recursive: true);
}
List<FileSystemEntity> folders = dataDirectory.listSync().whereType<Directory>().toList();
Map<String, List<File>> groupedFiles = {};
Map<String, DateTime> folderDates = {};
Map<String, int> folderScores = {};
Map<String, bool> folderTypes = {}; // ✅
for (var folder in folders) {
String folderName = folder.path.split('/').last;
DateTime folderDate = folder.statSync().modified;
// ✅ Read isTournament flag
bool isTournament = false;
final metadataFile = File('${folder.path}/metadata.json');
if (await metadataFile.exists()) {
try {
final metadata = jsonDecode(await metadataFile.readAsString());
isTournament = metadata['isTournament'] == true;
} catch (e) {
debugPrint("Error reading metadata for $folderName: $e");
}
}
folderTypes[folderName] = isTournament;
List<File> targetFiles = Directory(folder.path)
.listSync()
.whereType<File>()
.where((file) =>
file.path.endsWith(".json") &&
!file.path.endsWith("metadata.json"))
.toList();
int totalScore = 0;
for (var file in targetFiles) {
final jsonString = await file.readAsString();
final data = jsonDecode(jsonString);
final score = _getScoreFromJson(data);
totalScore += score;
}
groupedFiles[folderName] = targetFiles;
folderDates[folderName] = folderDate;
folderScores[folderName] = totalScore;
}
final sortedFolderNames = folderDates.keys.toList()
..sort((a, b) => folderDates[b]!.compareTo(folderDates[a]!));
final sortedGroupedFiles = {
for (var folderName in sortedFolderNames) folderName: groupedFiles[folderName]!
};
final sortedFolderScores = {
for (var folderName in sortedFolderNames) folderName: folderScores[folderName]!
};
final sortedFolderDates = {
for (var folderName in sortedFolderNames) folderName: folderDates[folderName]!
};
final sortedFolderTypes = {
for (var folderName in sortedFolderNames) folderName: folderTypes[folderName]!
};
setState(() {
_groupedFiles = sortedGroupedFiles;
_folderCreationDates = sortedFolderDates;
_folderScores = sortedFolderScores;
_folderTypes = sortedFolderTypes; // ✅ FIXED
});
} catch (e) {
debugPrint("Error loading target data files: $e");
}
}
// Function to safely get the score from the JSON data
int _getScoreFromJson(Map<String, dynamic> data) {
if (data.containsKey('totalScore') && data['totalScore'] is int) {
return data['totalScore'];
}
return 0; // Default to 0 if the score is not present or invalid
}
// Delete a file
Future<void> _deleteFile(File file) async {
bool? confirmDeletion = await _showConfirmationDialog(
context,
'Are you sure you want to delete this file?',
);
if (confirmDeletion == true) {
try {
await file.delete();
_loadTargetDataFiles(); // Refresh after deleting the file
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Error deleting file: $e")));
}
}
}
// Open file preview in the TargetViewScreen
void _openFilePreview(File targetFile) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => TargetViewScreen(
jsonFiles: [targetFile], // Pass the selected file in a list
refreshFolders: _loadTargetDataFiles, // Pass the refreshFolders callback
),
),
);
}
// Open folder's combined target view in the TargetViewScreen
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
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => TargetViewScreen(
jsonFiles: [combinedFile], // treat it like a normal file
refreshFolders: _loadTargetDataFiles,
isCombinedView: true, // 👈 This line
),
),
);
}
List<String> _filteredFolderNames() {
if (_folderFilter == 'all') return _groupedFiles.keys.toList();
final isTournamentFilter = _folderFilter == 'tournament';
return _groupedFiles.keys
.where((folderName) => _folderTypes[folderName] == isTournamentFilter)
.toList();
}
// Rename folder
Future<void> _renameFolder(String oldFolderName) async {
final newNameController = TextEditingController();
bool isRenameEnabled = false;
bool isTournament = _folderTypes[oldFolderName] == true;
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (context) {
return Dialog(
backgroundColor: Colors.grey[900],
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: StatefulBuilder(
builder: (context, setState) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text(
'Folder Settings',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 20),
TextField(
controller: newNameController,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
filled: true,
fillColor: Colors.grey[800],
hintText: 'Enter new folder name',
hintStyle: const TextStyle(color: Colors.white54),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: _bulletColor),
),
),
onChanged: (value) {
setState(() {
isRenameEnabled = value.trim().isNotEmpty;
});
},
),
const SizedBox(height: 16),
GestureDetector(
onTap: () {
setState(() {
isTournament = !isTournament;
});
},
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: isTournament
? _bulletColor.withOpacity(0.2)
: Colors.grey[850],
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isTournament
? _bulletColor
: Colors.grey[700]!,
width: 1.5,
),
),
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 14),
child: Center(
child: Text(
isTournament
? 'Tournament Mode 🏆'
: 'Mark as Tournament 🏆',
style: TextStyle(
color: isTournament
? _bulletColor
: Colors.white70,
fontWeight: FontWeight.w500,
fontSize: 16,
),
),
),
),
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[800],
foregroundColor: Colors.white,
padding:
const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text('Cancel'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: () => Navigator.of(context).pop({
'name': newNameController.text.trim(),
'isTournament': isTournament,
}),
style: ElevatedButton.styleFrom(
backgroundColor: _bulletColor,
foregroundColor: Colors.white,
padding:
const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text('Apply'),
),
),
],
)
],
);
},
),
),
);
},
);
// ✅ Handle result (rename + update tag)
if (result != null) {
final newName = result['name'];
final isTournament = result['isTournament'] ?? false;
final directory = await getApplicationDocumentsDirectory();
final oldFolderPath =
Directory('${directory.path}/saved_images/$oldFolderName');
final targetFolderName =
(newName != null && newName.isNotEmpty) ? newName : oldFolderName;
final newFolderPath =
Directory('${directory.path}/saved_images/$targetFolderName');
if (targetFolderName != oldFolderName) {
if (await newFolderPath.exists()) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('A folder named "$targetFolderName" already exists.')),
);
return;
} else {
await oldFolderPath.rename(newFolderPath.path);
}
}
final metadataFile = File('${newFolderPath.path}/metadata.json');
if (isTournament) {
await metadataFile.writeAsString(jsonEncode({'isTournament': true}));
} else {
if (await metadataFile.exists()) {
await metadataFile.delete();
}
}
_loadTargetDataFiles(); // refresh UI
}
}
// Delete a folder
Future<void> _deleteFolder(String folderName) async {
bool? confirmDeletion = await _showConfirmationDialog(
context,
'Are you sure you want to delete the folder "$folderName" and all of its contents?',
);
if (confirmDeletion == true) {
try {
final directory = await getApplicationDocumentsDirectory();
final folderPath = Directory('${directory.path}/saved_images/$folderName');
if (await folderPath.exists()) {
await folderPath.delete(recursive: true);
}
_loadTargetDataFiles(); // Refresh file/folder list
if (_expandedFolder == folderName) {
setState(() {
_expandedFolder = null;
});
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Folder "$folderName" deleted successfully.'),
),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Error deleting folder: $e")),
);
}
}
}
Future<bool?> _showConfirmationDialog(BuildContext context, String message) async {
return showDialog<bool>(
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: [
const Text(
'Confirm Deletion',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
message,
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(true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.redAccent,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text("Yes, delete"),
),
),
const SizedBox(height: 12),
// "No" Button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Navigator.of(context).pop(false),
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"),
),
),
],
),
),
);
},
);
}
// Build the folder row with folder name, trash, and rename options
// Build the folder row with folder name, trash, rename options, and view all targets icon
Widget _buildFolderRow(String folderName) {
final numberOfTargets = _groupedFiles[folderName]!.length;
final totalScore = _folderScores[folderName];
return Container(
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
decoration: BoxDecoration(
color: Colors.grey[300], // Lighter background for folder
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
contentPadding: const EdgeInsets.all(0),
title: Row(
children: [
Text(folderName, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
if (_folderTypes[folderName] == true)
const Padding(
padding: EdgeInsets.only(left: 6),
child: Text('🏆', style: TextStyle(fontSize: 18)),
),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), // Add padding between title and info
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Icon(Icons.insert_drive_file, color: Colors.black45),
const SizedBox(width: 4), // Small space between the icon and the text
Text(
"$numberOfTargets / 🎯 $totalScore",
style: const TextStyle(fontSize: 16, color: Colors.black),
),
],
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.my_location, color: Colors.grey),
onPressed: () => _openFolderTargetView(folderName),
),
IconButton(
icon: Icon(Icons.edit, color: _bulletColor),
onPressed: () => _renameFolder(folderName),
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () => _deleteFolder(folderName),
),
],
),
onTap: () {
setState(() {
// If the folder is already expanded, collapse it, otherwise expand it
_expandedFolder = _expandedFolder == folderName ? null : folderName;
});
},
),
if (_expandedFolder == folderName)
// When expanded, display the file row inside the folder
..._groupedFiles[folderName]!
.asMap()
.entries
.map((entry) => _buildFileRow(entry.value, folderName, entry.key))
.toList(),
],
),
);
}
// Build the row for files in the folder with everything in a single row
Widget _buildFileRow(File file, String folderName, int index) {
final fileName = "${index + 1} Target"; // Use number starting from 1
final folderDate = _folderCreationDates[folderName];
final formattedDate = folderDate != null
? DateFormat('yyyy-MM-dd').format(folderDate)
: 'Unknown Date'; // or any default fallback string
final score = _getScoreFromJson(jsonDecode(file.readAsStringSync())); // Read the score for the file
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), // Reduced vertical padding for compact height
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), // Tight margin
decoration: BoxDecoration(
color: Colors.grey[200], // Light grey background for files
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey[300]!, width: 1), // Light border around the file
),
child: InkWell(
onTap: () => _openFilePreview(file), // Open the file preview when the row is tapped
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, // Distribute space across the row
children: [
// File name
Text(fileName, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
// Date
Text(formattedDate, style: const TextStyle(fontSize: 12, color: Colors.black54)),
// Target emoji and score
Text("🎯 $score", style: const TextStyle(fontSize: 14, color: Colors.black54)),
// Delete button
IconButton(
icon: const Icon(Icons.delete, color: Color.fromARGB(255, 252, 102, 92)),
onPressed: () => _deleteFile(file), // Trigger file deletion
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
// 🔽 Filter dropdown at the top
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
child: GestureDetector(
onTap: () => _showFolderFilterPopup(context),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Colors.grey[300], // Same as folder background
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_folderFilter == 'all'
? 'Showing: All folders'
: _folderFilter == 'tournament'
? 'Showing: 🏆 Tournament folders'
: 'Showing: 🎯 Training folders',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const Icon(Icons.filter_list),
],
),
),
),
),
// 📁 Folder list or empty state
Expanded(
child: _groupedFiles.isEmpty
? const Center(child: Text("No saved target data found", style: TextStyle(fontSize: 18)))
: ListView.builder(
itemCount: _filteredFolderNames().length,
itemBuilder: (context, index) {
String folderName = _filteredFolderNames()[index];
return _buildFolderRow(folderName);
},
),
),
],
),
);
}
void _showFolderFilterPopup(BuildContext context) {
showDialog(
context: context,
builder: (BuildContext context) {
return Dialog(
backgroundColor: Colors.grey[900],
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text(
'Filter Folders',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
_buildFilterOption('all', '📂 All folders'),
const SizedBox(height: 12),
_buildFilterOption('tournament', '🏆 Tournament'),
const SizedBox(height: 12),
_buildFilterOption('training', '🎯 Training'),
],
),
),
);
},
);
}
Widget _buildFilterOption(String value, String label) {
final isSelected = _folderFilter == value;
return GestureDetector(
onTap: () {
setState(() {
_folderFilter = value;
});
Navigator.of(context).pop();
},
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: BoxDecoration(
color: isSelected ? _bulletColor.withOpacity(0.3) : Colors.grey[800],
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? _bulletColor : Colors.transparent,
width: 1.5,
),
),
child: Center(
child: Text(
label,
style: const TextStyle(color: Colors.white, fontSize: 16),
),
),
),
);
}
}
+67
View File
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
class CalendarScreen extends StatefulWidget {
const CalendarScreen({super.key});
@override
State<CalendarScreen> createState() => _CalendarScreenState();
}
class _CalendarScreenState extends State<CalendarScreen> {
late final WebViewController _controller;
bool _isLoading = true;
bool _hasError = false;
@override
void initState() {
super.initState();
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (_) => setState(() {
_isLoading = true;
_hasError = false;
}),
onPageFinished: (_) => setState(() => _isLoading = false),
onWebResourceError: (error) => setState(() {
_hasError = true;
_isLoading = false;
}),
),
)
..loadRequest(Uri.parse(
'https://calendar.google.com/calendar/u/0/embed?src=b9c267252d5be63d5cdc880be499885a88421eefd0e34e1392890daba0d8471e@group.calendar.google.com&ctz=Europe/Belgrade'));
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
WebViewWidget(controller: _controller),
if (_isLoading)
const Center(child: CircularProgressIndicator()),
if (_hasError)
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
"Failed to load Google Calendar.",
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
ElevatedButton(
onPressed: () => _controller.reload(),
child: const Text("Retry"),
),
],
),
),
],
),
);
}
}
+323
View File
@@ -0,0 +1,323 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:camera/camera.dart';
import 'package:image/image.dart' as img;
import 'package:shared_preferences/shared_preferences.dart';
import 'yolo_detector.dart';
import 'preview_screen.dart';
class CameraScreen extends StatefulWidget {
const CameraScreen({super.key});
@override
State<CameraScreen> createState() => _CameraScreenState();
}
class _CameraScreenState extends State<CameraScreen> {
CameraController? _controller;
Future<void>? _initializeControllerFuture;
late YOLODetector _yoloDetector;
Color _bulletColor = Colors.blue; // default fallback
// The variable to hold the number of bullets entered by the user (default set to 5)
int _numberOfBullets = 5;
@override
void initState() {
super.initState();
_loadBulletCount();
_initCamera();
_loadBulletColor(); // 👈
YOLODetector.create().then((detector) => _yoloDetector = detector);
}
Future<void> _loadBulletColor() async {
final prefs = await SharedPreferences.getInstance();
final selected = prefs.getString('bulletColor') ?? 'Blue';
setState(() {
_bulletColor = _getColorFromName(selected);
});
}
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 'sky':
return Colors.lightBlueAccent;
case 'violet':
return Colors.deepPurpleAccent;
default:
return Colors.blue;
}
}
Future<void> _loadBulletCount() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_numberOfBullets = prefs.getInt('bulletCount') ?? 5; // Load stored bullet count, default to 5
});
}
Future<void> _saveBulletCount(int count) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('bulletCount', count); // Save the bullet count
}
Future<void> _initCamera() async {
final cameras = await availableCameras();
final camera = cameras.first;
_controller = CameraController(
camera,
ResolutionPreset.high,
enableAudio: false,
);
_initializeControllerFuture = _controller!.initialize();
if (mounted) setState(() {});
}
@override
void dispose() {
_controller?.dispose();
_yoloDetector.close();
super.dispose();
}
Future<void> _takePicture() async {
try {
await _initializeControllerFuture;
final file = await _controller!.takePicture();
final imageFile = File(file.path);
final imageBytes = await imageFile.readAsBytes();
final decodedImage = img.decodeImage(imageBytes)!;
// Crop center square from full image
final width = decodedImage.width;
final height = decodedImage.height;
final size = width < height ? width : height;
final offsetX = (width - size) ~/ 2;
final offsetY = (height - size) ~/ 2;
final cropped = img.copyCrop(
decodedImage,
x: offsetX,
y: offsetY,
width: size,
height: size,
);
final croppedFile = File('${file.path}_square.jpg')
..writeAsBytesSync(img.encodeJpg(cropped));
final detections = await _yoloDetector.detect(croppedFile);
if (!mounted) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PreviewScreen(
imagePath: croppedFile.path,
detections: detections,
bulletCount: _numberOfBullets, // Pass the bullet count
),
),
);
} catch (e) {
print('Error taking picture: $e');
}
}
// Method to show the dialog for selecting the number of bullets
void _showBulletDialog() {
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), // Increased padding for better spacing
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(bottom: 16.0),
child: Text(
'Select the number of bullets',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
Center(
child: Wrap(
alignment: WrapAlignment.center,
spacing: 12,
runSpacing: 12,
children: List.generate(5, (index) {
final bulletNum = index + 1;
final isSelected = _numberOfBullets == bulletNum;
return ElevatedButton(
onPressed: () {
_selectBulletCount(bulletNum);
Navigator.of(context).pop();
},
style: ElevatedButton.styleFrom(
backgroundColor:
isSelected ? _bulletColor : Colors.grey[800],
foregroundColor: Colors.white,
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
'$bulletNum',
style: TextStyle(
fontSize: 18,
fontWeight:
isSelected ? FontWeight.bold : FontWeight.normal,
),
),
);
}),
),
),
],
),
),
);
},
);
}
// Update the bullet count and save it
void _selectBulletCount(int number) {
setState(() {
_numberOfBullets = number; // Update the selected number
});
_saveBulletCount(number); // Save the selected number
}
@override
Widget build(BuildContext context) {
if (_controller == null || _initializeControllerFuture == null) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
extendBody: true,
appBar: AppBar(
backgroundColor: Colors.grey[900],
title: const Text(
"Align the target inside the square",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
centerTitle: true,
actions: [
// Display the selected bullet count
IconButton(
icon: Text(
'$_numberOfBullets',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
onPressed: _showBulletDialog, // Show dialog on tap
),
const SizedBox(width: 16), // Space after the number
],
),
bottomNavigationBar: BottomAppBar(
color: Colors.grey[900],
shape: const CircularNotchedRectangle(),
notchMargin: 8.0,
child: SizedBox(height: 60),
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
floatingActionButton: FloatingActionButton(
backgroundColor: Colors.white,
onPressed: _takePicture,
child: const Icon(Icons.camera_alt, color: Colors.black),
),
body: FutureBuilder(
future: _initializeControllerFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
final screenSize = MediaQuery.of(context).size;
return Stack(
children: [
Positioned.fill(
child: Container(
color: Colors.black, // covers any empty space
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(
width: _controller!.value.previewSize!.height,
height: _controller!.value.previewSize!.width,
child: CameraPreview(_controller!),
),
),
),
),
Center(
child: Container(
width: screenSize.width * 0.9,
height: screenSize.width * 0.9,
decoration: BoxDecoration(
border: Border.all(
color: _bulletColor.withOpacity(0.9),
width: 3,
),
),
),
),
],
);
} else {
return const Center(child: CircularProgressIndicator());
}
},
),
);
}
}
+93
View File
@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'book_screen.dart';
import 'calendar_screen.dart';
import 'settings_screen.dart';
import 'target_screen.dart';
import '../widgets/bottom_nav.dart';
//import 'login_screen.dart';
import 'camera_screen.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
_HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
int _selectedIndex = 1; // Default to Shooting Screen
static final List<Widget> _pages = <Widget>[
BookScreen(),
TargetScreen(),
Placeholder(), // Placeholder for Scan - optional
CalendarScreen(),
SettingsScreen(),
];
static final List<String> _pageTitles = [
'Book',
'Shooting',
'Scan',
'Calendar',
'Settings'
];
void _onItemTapped(int index) {
if (index == 2) {
_openCameraScan();
return;
}
setState(() {
_selectedIndex = index;
});
}
//void _logout() {
// Navigator.of(context).pushAndRemoveUntil(
// MaterialPageRoute(builder: (context) => const LoginScreen()),
// (Route<dynamic> route) => false,
// );
//}
// When opening the camera screen, ensure the shooting screen is always selected after returning
void _openCameraScan() async {
// Navigate to CameraScreen
await Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const CameraScreen()),
);
// After returning, ensure that Shooting screen (index 1) is selected
setState(() {
_selectedIndex = 1;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
_pageTitles[_selectedIndex],
style: const TextStyle(color: Colors.white),
),
backgroundColor: Colors.grey[900],
iconTheme: const IconThemeData(color: Colors.white),
automaticallyImplyLeading: false,
actions: [
IconButton(
icon: const Icon(Icons.logout, color: Colors.red),
onPressed: null, // disables the button
),
],
),
body: _pages[_selectedIndex],
bottomNavigationBar: BottomNavBar(
selectedIndex: _selectedIndex,
onItemTapped: _onItemTapped,
),
);
}
}
+232
View File
@@ -0,0 +1,232 @@
import 'package:flutter/material.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../widgets/input_field.dart';
import 'home_screen.dart';
import 'register_screen.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final usernameController = TextEditingController();
final passwordController = TextEditingController();
bool rememberMe = false;
final GoogleSignIn _googleSignIn = GoogleSignIn(
clientId: 'YOUR_WEB_CLIENT_ID_HERE',
scopes: ['email'],
);
@override
void initState() {
super.initState();
_checkAutoLogin();
}
Future<void> _checkAutoLogin() async {
final prefs = await SharedPreferences.getInstance();
final isRemembered = prefs.getBool('rememberMe') ?? false;
final googleUser = prefs.getString('googleUser');
if (isRemembered) {
final savedUsername = prefs.getString('loginUsername') ?? prefs.getString('username');
final savedPassword = prefs.getString('password');
if (savedUsername != null && savedPassword != null) {
usernameController.text = savedUsername;
passwordController.text = savedPassword;
setState(() => rememberMe = true);
}
} else if (googleUser != null) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const HomeScreen()),
);
}
}
Future<void> _saveLoginDetails(String username, String password) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('loginUsername', username); // Ensures login uses latest username
await prefs.setString('password', password);
if (rememberMe) {
await prefs.setBool('rememberMe', true);
} else {
await prefs.remove('rememberMe');
}
}
void login() async {
String username = usernameController.text.trim();
String password = passwordController.text.trim();
final prefs = await SharedPreferences.getInstance();
String? savedUsername = prefs.getString('loginUsername') ?? prefs.getString('username'); // Get the latest username
String? savedPassword = prefs.getString('password'); // Get the saved password
if (savedUsername == username && savedPassword == password) {
await _saveLoginDetails(username, password);
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const HomeScreen()),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid username or password')),
);
}
}
Future<void> googleLogin() async {
try {
final GoogleSignInAccount? googleUser = await _googleSignIn.signIn();
if (googleUser != null) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('googleUser', googleUser.displayName ?? 'Google User');
await prefs.setString('googleEmail', googleUser.email);
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const HomeScreen()),
);
}
} catch (error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Google Sign-In Failed: $error')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/logo.png',
width: 1000,
height: 150,
),
const SizedBox(height: 40),
InputField(
hintText: 'Username',
icon: Icons.person,
controller: usernameController,
),
const SizedBox(height: 16),
InputField(
hintText: 'Password',
icon: Icons.lock,
obscureText: true,
controller: passwordController,
),
const SizedBox(height: 16),
/// Remember Me Checkbox - Centered
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Checkbox(
value: rememberMe,
onChanged: (value) {
setState(() {
rememberMe = value ?? false;
});
},
activeColor: Colors.grey[800],
),
const Text(
'Remember Me',
style: TextStyle(
color: Colors.black87,
fontSize: 16,
),
),
],
),
const SizedBox(height: 24),
/// Login Button
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[800],
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(50),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
onPressed: login,
child: const Text('Login', style: TextStyle(fontSize: 18)),
),
const SizedBox(height: 16),
/// Horizontal Line with "or"
Row(
children: const [
Expanded(
child: Divider(color: Colors.grey, thickness: 1),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text("or", style: TextStyle(color: Colors.grey, fontSize: 16)),
),
Expanded(
child: Divider(color: Colors.grey, thickness: 1),
),
],
),
const SizedBox(height: 16),
/// Google Sign-In Button (Styled same as Login)
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[800], // Match Login Button
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(50),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
onPressed: googleLogin,
icon: const Icon(Icons.g_mobiledata, size: 30), // Google Icon
label: const Text('Sign in with Google', style: TextStyle(fontSize: 18)),
),
const SizedBox(height: 20),
/// Register Text (Styled in Blue)
GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const RegisterScreen()),
);
},
child: const Text(
"Don't have an account? Register",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.grey, // Better visibility
),
),
),
],
),
),
),
);
}
}
+972
View File
@@ -0,0 +1,972 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:image/image.dart' as img;
import 'package:path_provider/path_provider.dart';
import 'yolo_detector.dart';
import 'dart:convert'; // For JSON encoding
import 'dart:math';
import 'package:shared_preferences/shared_preferences.dart';
class PreviewScreen extends StatefulWidget {
final String imagePath;
final List<DetectionResult> detections;
final int bulletCount; // Add bulletCount parameter
const PreviewScreen({super.key, required this.imagePath, required this.detections, required this.bulletCount});
@override
State<PreviewScreen> createState() => _PreviewScreenState();
}
enum PreviewMode { cameraWithDetections, mappedTarget }
class _PreviewScreenState extends State<PreviewScreen> {
bool _isLoading = true;
PreviewMode _mode = PreviewMode.mappedTarget;
List<Offset> _mappedBullets = [];
late List<DetectionResult> _realImageDetections;
img.Image? _realImageDecoded;
img.Image? _targetImageDecoded;
Future<img.Image?>? _targetImageFuture;
Offset? _targetImageCenterForScoring;
Uint8List? _cachedTargetImage;
Uint8List? _cachedRealImage;
final String targetAssetPath = 'assets/target.png';
final Offset fixedRingTargetCenter = const Offset(602.51, 600.43);
final Size fixedRingTargetSize = const Size(355.94, 359.27);
int? _highlightedBulletIndex;
final List<int> _manualBulletScores = [];
bool _hasMissingBullets = false;
Color _bulletColor = Colors.blue; // default fallback
double get _bulletOpacity => 0.8;
Color get _bulletColorWithOpacity => _bulletColor.withOpacity(_bulletOpacity);
@override
void initState() {
super.initState();
_realImageDetections = widget.detections;
_targetImageFuture = _loadAndDecodeTargetImage();
_loadBulletColor(); // 👈 Add this
_runDetectionAndMapping();
}
Future<void> _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 'sky':
return Colors.orange;
case 'violet':
return Colors.deepPurpleAccent;
default:
return Colors.blue; // fallback
}
}
Future<void> _handleMissingBullets() async {
final currentDetected = _mappedBullets.length;
final result = await _showMismatchOptionsDialog(currentDetected);
if (result == 'retry') {
Navigator.pop(context); // Go back to camera screen
} else if (result == 'manual') {
_manualScoreEntry();
}
}
Offset _generatePositionForScore(int score, Offset center) {
const double bulletRadius = 25;
final List<double> rings = [14, 70, 125, 180, 235, 290, 345, 400, 455, 510];
final rng = score.clamp(0, 10);
final double distance = rng == 0 ? rings.last + bulletRadius + 5 : rings[10 - rng];
final angle = (360 / widget.bulletCount) * (_manualBulletScores.length + 1);
final radians = angle * 3.14159 / 180;
return Offset(
center.dx + distance * cos(radians),
center.dy + distance * sin(radians),
);
}
Future<img.Image?> _loadAndDecodeTargetImage() async {
final byteData = await rootBundle.load(targetAssetPath);
final bytes = byteData.buffer.asUint8List();
final tempDir = await getTemporaryDirectory();
final targetFile = File('${tempDir.path}/target.png');
await targetFile.writeAsBytes(bytes);
final decoded = img.decodeImage(bytes);
if (decoded != null) {
_targetImageCenterForScoring = Offset(decoded.width / 2, decoded.height / 2 - 1);
}
return decoded;
}
Future<void> _runDetectionAndMapping() async {
try {
// 1. Read the real image file
final File realImageFile = File(widget.imagePath);
final realBytes = await realImageFile.readAsBytes();
// 2. Decode the real image (ensure it's valid)
_realImageDecoded = img.decodeImage(realBytes);
if (_realImageDecoded == null) {
debugPrint("❌ Failed to decode image from path: ${widget.imagePath}");
setState(() => _isLoading = false);
return;
}
// 3. Cache decoded image as PNG for rendering
_cachedRealImage = Uint8List.fromList(img.encodePng(_realImageDecoded!));
// 4. Find the detected ring center and size
final ringCamData = _getRingCenterSize(_realImageDetections);
if (ringCamData == null) {
debugPrint("⚠️ Ring (classId 2) not detected in real image.");
setState(() => _isLoading = false);
return;
}
final Offset ringCamCenter = ringCamData['center'];
final Size sizeCam = ringCamData['size'];
// 5. Extract all bullet centers (classId 0)
final List<Offset> bulletsCam = _realImageDetections
.where((r) => r.classId == 0)
.map((r) => Offset((r.x1 + r.x2) / 2, (r.y1 + r.y2) / 2))
.toList();
// 6. Map bullets to target and score each one
final List<Map<String, dynamic>> scoredBullets = bulletsCam.map((bullet) {
final mappedBullet = _mapToTargetNormalized(
bullet,
ringCamCenter,
fixedRingTargetCenter,
sizeCam,
fixedRingTargetSize,
);
final score = _scoreForBullet(
mappedBullet,
_targetImageCenterForScoring ?? fixedRingTargetCenter,
);
return {'bullet': mappedBullet, 'score': score};
}).toList();
// 7. Trim to bullet limit by removing highest scoring ones
scoredBullets.sort((a, b) => b['score'].compareTo(a['score']));
while (scoredBullets.length > widget.bulletCount) {
scoredBullets.removeAt(0);
}
// 8. Collect final mapped bullets
final List<Offset> mappedBullets = scoredBullets
.map((e) => e['bullet'] as Offset)
.toList();
// 9. Load target background image
_targetImageDecoded = await _targetImageFuture;
if (_targetImageDecoded != null) {
_cachedTargetImage = Uint8List.fromList(img.encodePng(_targetImageDecoded!));
}
// 10. Update UI
final missing = widget.bulletCount - mappedBullets.length;
setState(() {
_mappedBullets = mappedBullets;
_isLoading = false;
_hasMissingBullets = missing > 0;
});
} catch (e, stack) {
debugPrint("❌ Error during detection mapping: $e");
debugPrint(stack.toString());
setState(() => _isLoading = false);
}
}
Future<String?> _showMismatchOptionsDialog(int detectedCount) async {
return showDialog<String>(
context: context,
builder: (context) {
final missing = widget.bulletCount - detectedCount;
return AlertDialog(
backgroundColor: Colors.grey[900],
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: const Text(
'Bullet Count Mismatch',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
content: Text(
'Detected $detectedCount bullet(s), expected ${widget.bulletCount}.\n'
'You can retry detection or manually add the ${missing.abs()} missing shot(s).',
style: const TextStyle(color: Colors.white70),
),
contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 12),
actionsPadding: const EdgeInsets.only(bottom: 16, left: 16, right: 16),
actions: [
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () => Navigator.pop(context, 'retry'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.redAccent,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.restart_alt, color: Colors.white),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: () => Navigator.pop(context, 'manual'),
style: ElevatedButton.styleFrom(
backgroundColor: _bulletColor,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.edit, color: Colors.white),
),
),
],
),
],
);
},
);
}
void _manualScoreEntry() {
final missingCount = widget.bulletCount - _mappedBullets.length;
final controllers = List.generate(missingCount, (_) => TextEditingController());
showDialog(
context: context,
builder: (context) {
return Dialog(
backgroundColor: Colors.grey[900],
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Enter Missing Bullet Scores',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 300),
child: SingleChildScrollView(
child: Column(
children: List.generate(missingCount, (index) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: TextField(
controller: controllers[index],
keyboardType: TextInputType.number,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'Bullet Score ${_mappedBullets.length + index + 1}',
labelStyle: const TextStyle(color: Colors.white54),
filled: true,
fillColor: Colors.grey[800],
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: _bulletColor),
),
),
),
);
}),
),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
final scores = controllers.map((c) => int.tryParse(c.text.trim()) ?? 0).toList();
final center = _targetImageCenterForScoring ?? fixedRingTargetCenter;
setState(() {
for (final score in scores) {
final offset = _generatePositionForScore(score, center);
_mappedBullets.add(offset);
_manualBulletScores.add(score);
}
});
Navigator.pop(context);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.greenAccent[700],
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text('Add Bullets'),
),
),
],
),
),
);
},
);
}
Future<void> _saveTargetData() async {
try {
// Show a dialog to let the user select between a new folder or an existing one
final result = await _showSaveOptionsDialog();
if (result == null) return;
String? selectedFolder;
bool isTournament = false;
try {
final decoded = jsonDecode(result);
selectedFolder = decoded['folder'];
isTournament = decoded['isTournament'] == true;
} catch (_) {
selectedFolder = result; // Treat plain string as folder name (existing folder)
}
if (selectedFolder == null) return;
final directory = await getApplicationDocumentsDirectory();
final saveDirectory = Directory('${directory.path}/saved_images/$selectedFolder');
// Create folder if it doesn't exist
if (!await saveDirectory.exists()) {
await saveDirectory.create(recursive: true);
}
// Save tournament metadata if applicable
if (isTournament) {
final metadataFile = File('${saveDirectory.path}/metadata.json');
await metadataFile.writeAsString(jsonEncode({'isTournament': true}));
}
// Use timestamp for a unique file name
final now = DateTime.now();
final dateString = "${now.year}-${now.month}-${now.day}";
final timeString = "${now.hour}-${now.minute}-${now.second}";
final fileName = "target_${dateString}_$timeString.json";
final filePath = '${saveDirectory.path}/$fileName';
// Prepare target data to save
final targetData = {
'date': dateString,
'totalScore': _totalScore,
'bullets': _mappedBullets.map((b) => {'x': b.dx, 'y': b.dy}).toList(),
};
// Write file
final jsonFile = File(filePath);
await jsonFile.writeAsString(jsonEncode(targetData));
// Close preview and signal refresh
Navigator.pop(context, true);
} catch (e) {
print("Error saving target data: $e");
}
}
Future<String?> _showSaveOptionsDialog() async {
TextEditingController _controller = TextEditingController();
bool isNewFolderEnabled = false;
bool isTournament = false; // 👈 new flag
final directory = await getApplicationDocumentsDirectory();
final saveDirectory = Directory('${directory.path}/saved_images');
final List<String> existingFolders = [];
final folders = saveDirectory.listSync();
for (var folder in folders) {
if (folder is Directory) {
existingFolders.add(folder.path.split('/').last);
}
}
String? lastFolderName = existingFolders.isNotEmpty ? existingFolders.last : null;
return await showDialog<String>(
context: context,
builder: (context) {
return Dialog(
backgroundColor: Colors.grey[900],
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: StatefulBuilder(
builder: (context, setState) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Padding(
padding: EdgeInsets.only(bottom: 16.0),
child: Text(
'Save Target Data',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
const Text(
"Select where you'd like to save the data:",
style: TextStyle(color: Colors.white70),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
if (lastFolderName != null) ...[
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
Navigator.pop(context, lastFolderName);
},
style: ElevatedButton.styleFrom(
backgroundColor: _bulletColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text("Save to '$lastFolderName'"),
),
),
const SizedBox(height: 16),
],
// New folder name input
SizedBox(
width: double.infinity,
child: TextField(
controller: _controller,
style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center,
decoration: InputDecoration(
filled: true,
fillColor: Colors.grey[800],
hintText: "Enter a new folder name",
hintStyle: const TextStyle(color: Colors.white54),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: _bulletColor),
),
),
onChanged: (value) {
setState(() {
isNewFolderEnabled = value.trim().isNotEmpty;
});
},
),
),
const SizedBox(height: 12),
// Tournament toggle
if (isNewFolderEnabled) ...[
const SizedBox(height: 12),
GestureDetector(
onTap: () {
setState(() {
isTournament = !isTournament;
});
},
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: isTournament ? _bulletColor.withOpacity(0.2) : Colors.grey[850],
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isTournament ? _bulletColor : Colors.grey[700]!,
width: 1.5,
),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Center(
child: Text(
isTournament ? 'Tournament Mode 🏆' : 'Mark as Tournament 🏆',
style: TextStyle(
color: isTournament ? _bulletColor : Colors.white70,
fontWeight: FontWeight.w500,
fontSize: 16,
),
),
),
),
),
],
const SizedBox(height: 16),
// Create new folder
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: isNewFolderEnabled
? () => Navigator.pop(
context,
jsonEncode({
'folder': _controller.text.trim(),
'isTournament': isTournament,
}),
)
: null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
backgroundColor:
isNewFolderEnabled ? _bulletColor : Colors.grey[700],
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey[700],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text("Create New Folder"),
),
),
],
);
},
),
),
);
},
);
}
Map<String, dynamic>? _getRingCenterSize(List<DetectionResult> results) {
try {
final ring = results.firstWhere((r) => r.classId == 2);
final cx = (ring.x1 + ring.x2) / 2;
final cy = (ring.y1 + ring.y2) / 2;
final width = ring.x2 - ring.x1;
final height = ring.y2 - ring.y1;
return {
'center': Offset(cx, cy),
'size': Size(width, height),
};
} catch (_) {
return null;
}
}
Offset _mapToTargetNormalized(
Offset bulletCam,
Offset ringCam,
Offset ringTarget,
Size sizeCam,
Size sizeTarget) {
final normX = (bulletCam.dx - ringCam.dx) / sizeCam.width;
final normY = (bulletCam.dy - ringCam.dy) / sizeCam.height;
final mappedX = normX * sizeTarget.width + ringTarget.dx;
final mappedY = normY * sizeTarget.height + ringTarget.dy;
return Offset(mappedX, mappedY);
}
int _scoreForBullet(Offset bullet, Offset centerOverride) {
const double bulletRadius = 25;
final double distanceToEdge = (bullet - centerOverride).distance - bulletRadius;
final List<double> rings = [14, 70, 125, 180, 235, 290, 345, 400, 455, 510];
for (int i = 0; i < rings.length; i++) {
if (distanceToEdge <= rings[i]) {
return 10 - i;
}
}
return 0;
}
int get _totalScore {
final autoScore = _mappedBullets
.take(widget.bulletCount - _manualBulletScores.length)
.map((b) => _scoreForBullet(b, _targetImageCenterForScoring ?? fixedRingTargetCenter))
.fold(0, (sum, score) => sum + score);
final manualScore = _manualBulletScores.fold(0, (a, b) => a + b);
return autoScore + manualScore;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.grey[900],
iconTheme: const IconThemeData(color: Colors.white),
title: const Text(
'Detection Preview',
style: TextStyle(color: Colors.white),
),
actions: [
IconButton(
icon: Icon(
_mode == PreviewMode.mappedTarget ? Icons.camera_alt : Icons.center_focus_strong,
),
color: Colors.white,
onPressed: () {
setState(() {
_mode = _mode == PreviewMode.mappedTarget
? PreviewMode.cameraWithDetections
: PreviewMode.mappedTarget;
});
},
tooltip: _mode == PreviewMode.mappedTarget
? "Show Camera Detections"
: "Show Mapped Target",
),
IconButton(
icon: const Icon(Icons.save),
color: Colors.white,
onPressed: _saveTargetData,
tooltip: "Save Target Data",
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: Stack(
children: [
Column(
children: [
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final screenWidth = constraints.maxWidth;
final screenHeight = constraints.maxHeight;
final displayImage = _mode == PreviewMode.mappedTarget
? _cachedTargetImage
: _cachedRealImage;
final imgData = _mode == PreviewMode.mappedTarget
? _targetImageDecoded
: _realImageDecoded;
if (displayImage == null || imgData == null) {
return const Center(child: Text("Image not available"));
}
final imgWidth = imgData.width.toDouble();
final imgHeight = imgData.height.toDouble();
final aspectRatio = imgWidth / imgHeight;
final screenAspect = screenWidth / screenHeight;
double displayWidth, displayHeight, offsetX, offsetY;
if (aspectRatio > screenAspect) {
displayWidth = screenWidth;
displayHeight = screenWidth / aspectRatio;
offsetX = 0;
offsetY = (screenHeight - displayHeight) / 2;
} else {
displayHeight = screenHeight;
displayWidth = screenHeight * aspectRatio;
offsetY = 0;
offsetX = (screenWidth - displayWidth) / 2;
}
final scaleX = displayWidth / imgWidth;
final scaleY = displayHeight / imgHeight;
final imageWidget = Image.memory(displayImage, fit: BoxFit.fill);
return Stack(
children: [
Positioned(
left: offsetX,
top: offsetY,
width: displayWidth,
height: displayHeight,
child: imageWidget,
),
if (_mode == PreviewMode.cameraWithDetections)
..._realImageDetections.map((d) {
final left = d.x1 * scaleX + offsetX;
final top = d.y1 * scaleY + offsetY;
final width = (d.x2 - d.x1) * scaleX;
final height = (d.y2 - d.y1) * scaleY;
return Positioned(
left: left,
top: top,
width: width,
height: height,
child: Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.green, width: 2),
),
child: Align(
alignment: Alignment.topLeft,
child: Container(
color: Colors.green.withOpacity(0.5),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Text(
"ID:${d.classId} ${(d.confidence * 100).toStringAsFixed(1)}%",
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
),
),
);
}).toList(),
if (_mode == PreviewMode.mappedTarget)
..._mappedBullets.asMap().entries.take(widget.bulletCount).map((entry) {
final index = entry.key;
final b = entry.value;
final dx = b.dx * scaleX + offsetX;
final dy = b.dy * scaleY + offsetY;
final double bulletSize = ((scaleX + scaleY) / 2) * 55;
final isHighlighted = index == _highlightedBulletIndex;
final isManual = index >= (_mappedBullets.length - _manualBulletScores.length);
return Positioned(
left: dx - bulletSize / 2,
top: dy - bulletSize / 2,
child: Container(
width: bulletSize,
height: bulletSize,
decoration: BoxDecoration(
color: isManual
? Colors.red.withOpacity(0.9)
: _bulletColorWithOpacity,
shape: BoxShape.circle,
border: Border.all(
color: isHighlighted ? Colors.red : Colors.black,
width: isHighlighted ? 3 : 1,
),
),
),
);
}).toList(),
],
);
},
),
),
const SizedBox(height: 12),
if (_mappedBullets.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: List.generate(
_mappedBullets.length,
(index) {
final bullet = _mappedBullets[index];
final isManual = index >= (_mappedBullets.length - _manualBulletScores.length);
final score = isManual
? _manualBulletScores[index - (_mappedBullets.length - _manualBulletScores.length)]
: _scoreForBullet(
bullet,
_targetImageCenterForScoring ?? fixedRingTargetCenter,
);
final isSelected = _highlightedBulletIndex == index;
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: AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: 100,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: const [
BoxShadow(
color: Colors.black26,
offset: Offset(0, 2),
blurRadius: 6),
],
),
child: 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: isSelected
? Colors.red[400]
: (isManual
? Colors.red[600]
: _bulletColor),
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,
),
),
),
),
],
),
),
),
),
);
},
),
),
),
const SizedBox(height: 12),
],
),
if (_hasMissingBullets)
Positioned(
top: 12,
right: 12,
child: ElevatedButton.icon(
onPressed: _handleMissingBullets,
icon: const Icon(Icons.warning_amber_rounded, color: Colors.white, size: 20),
label: const Text("Fix Bullets", style: TextStyle(color: Colors.white)),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red[600],
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
elevation: 4,
),
),
),
],
),
bottomNavigationBar: BottomAppBar(
color: Colors.grey[900],
child: SizedBox(
height: 56,
child: Center(
child: Text(
"🎯 Total Score: $_totalScore",
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
),
);
}
}
+180
View File
@@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../widgets/input_field.dart';
import 'login_screen.dart';
class RegisterScreen extends StatefulWidget {
const RegisterScreen({super.key});
@override
State<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends State<RegisterScreen> {
final usernameController = TextEditingController();
final emailController = TextEditingController();
final passwordController = TextEditingController();
final confirmPasswordController = TextEditingController();
Future<void> register() async {
String username = usernameController.text.trim();
String email = emailController.text.trim();
String password = passwordController.text.trim();
String confirmPassword = confirmPasswordController.text.trim();
if (username.isEmpty || email.isEmpty || password.isEmpty || confirmPassword.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('All fields are required')),
);
return;
}
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(email)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid email format')),
);
return;
}
if (password != confirmPassword) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Passwords do not match')),
);
return;
}
final prefs = await SharedPreferences.getInstance();
// Check if the username or email is already taken
String? existingUser = prefs.getString(username);
String? existingEmail = prefs.getString(email);
if (existingUser != null || existingEmail != null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Username or Email already exists. Choose another.')),
);
return;
}
// Save user credentials in SharedPreferences
await prefs.setString(username, password);
await prefs.setString(email, username); // Store email linked to the username
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Registration Successful! Please Login')),
);
// Redirect to Login Screen
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const LoginScreen()),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
/// Logo
Image.asset(
'assets/logo.png',
width: 1000,
height: 150,
),
const SizedBox(height: 40),
/// Username Field
InputField(
hintText: 'Username',
icon: Icons.person,
controller: usernameController,
),
const SizedBox(height: 16),
/// Email Field
InputField(
hintText: 'Email',
icon: Icons.email,
controller: emailController,
),
const SizedBox(height: 16),
/// Password Field
InputField(
hintText: 'Password',
icon: Icons.lock,
obscureText: true,
controller: passwordController,
),
const SizedBox(height: 16),
/// Confirm Password Field
InputField(
hintText: 'Confirm Password',
icon: Icons.lock,
obscureText: true,
controller: confirmPasswordController,
),
const SizedBox(height: 24),
/// Register Button
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[800], // Match Login Button
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(50),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
onPressed: register,
child: const Text('Register', style: TextStyle(fontSize: 18)),
),
const SizedBox(height: 16),
/// Horizontal Line with "or" (optional, in case Google Sign-Up is added)
Row(
children: const [
Expanded(
child: Divider(color: Colors.grey, thickness: 1),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text("or", style: TextStyle(color: Colors.grey, fontSize: 16)),
),
Expanded(
child: Divider(color: Colors.grey, thickness: 1),
),
],
),
const SizedBox(height: 16),
/// Login Navigation (Styled in Blue)
GestureDetector(
onTap: () {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const LoginScreen()),
);
},
child: const Text(
"Already have an account? Login",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.grey, // Styled for better visibility
),
),
),
],
),
),
),
);
}
}
+445
View File
@@ -0,0 +1,445 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
List<String> _cameraIPs = List.generate(6, (index) => 'Not Set');
final List<String> _defaultIPs = [
'192.168.0.134:9081',
'192.168.0.134:9082',
'192.168.0.134:9083',
'192.168.0.134:9084',
'192.168.0.134:9085',
'192.168.0.134:9086',
];
String _selectedBulletColor = 'Blue';
final List<String> _bulletColors = [
'Blue',
'Green',
'Yellow',
'Purple',
'Cyan',
'Teal',
'Indigo',
'Pink',
'Lime',
'Mint',
'Sky',
'Violet',
];
@override
void initState() {
super.initState();
_loadCameraIPs();
_loadBulletColor();
_loadBulletColor();
}
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 'sky':
return Colors.orange;
case 'violet':
return Colors.deepPurpleAccent;
default:
return Colors.blue; // fallback
}
}
Future<void> _loadBulletColor() async {
final prefs = await SharedPreferences.getInstance();
String savedColor = prefs.getString('bulletColor') ?? 'Blue';
// Fallback in case old color is no longer valid
if (!_bulletColors.contains(savedColor)) {
savedColor = 'Blue';
await prefs.setString('bulletColor', savedColor);
}
setState(() {
_selectedBulletColor = savedColor;
});
}
Future<void> _saveBulletColor(String color) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('bulletColor', color);
setState(() => _selectedBulletColor = color);
}
Future<void> _loadCameraIPs() async {
final prefs = await SharedPreferences.getInstance();
List<String> tempIPs = [];
for (int i = 0; i < 6; i++) {
String key = 'cameraIP_${i + 1}';
String? savedIP = prefs.getString(key);
// If not saved, use default and write it to prefs
if (savedIP == null) {
await prefs.setString(key, _defaultIPs[i]);
tempIPs.add(_defaultIPs[i]);
} else {
tempIPs.add(savedIP);
}
}
setState(() {
_cameraIPs = tempIPs;
});
}
Future<void> _saveCameraIP(int index, String ip) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('cameraIP_${index + 1}', ip);
_loadCameraIPs();
}
Future<void> _clearCameraIP(int index) async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('cameraIP_${index + 1}');
_loadCameraIPs();
}
void _editCameraIP(int index) {
TextEditingController controller = TextEditingController(
text: _cameraIPs[index],
);
showDialog(
context: context,
builder: (context) {
return Dialog(
backgroundColor: Colors.grey[900],
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: StatefulBuilder(
builder: (context, setState) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
"Camera ${index + 1}",
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: TextField(
controller: controller,
style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center,
decoration: InputDecoration(
filled: true,
fillColor: Colors.grey[800],
hintText: "Enter Camera IP",
hintStyle: const TextStyle(color: Colors.white54),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: _getColorFromName(_selectedBulletColor)),
),
),
),
),
const SizedBox(height: 24),
// Save Button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () async {
String newIP = controller.text.trim();
if (newIP.isNotEmpty) {
await _saveCameraIP(index, newIP);
Navigator.pop(context);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: _getColorFromName(_selectedBulletColor),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text("Save"),
),
),
const SizedBox(height: 12),
// Reset Button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () async {
await _clearCameraIP(index);
Navigator.pop(context);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[700],
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text("Reset to Default"),
),
),
const SizedBox(height: 12),
// Cancel Button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Navigator.pop(context),
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"),
),
),
],
);
},
),
),
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Column(
children: [
const SizedBox(height: 8),
_buildBulletColorSelector(),
const SizedBox(height: 16),
_buildCameraList(),
],
),
),
);
}
Widget _buildBulletColorSelector() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"Select Bullet Color",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 8),
GestureDetector(
onTap: _showBulletColorPopup,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Container(
width: 16,
height: 16,
margin: const EdgeInsets.only(right: 12),
decoration: BoxDecoration(
color: _getColorFromName(_selectedBulletColor),
shape: BoxShape.circle,
border: Border.all(color: Colors.black26),
),
),
Expanded(
child: Text(
_selectedBulletColor,
style: const TextStyle(color: Colors.black87, fontSize: 14),
),
),
const Icon(Icons.chevron_right, color: Colors.black54),
],
),
),
),
],
);
}
void _showBulletColorPopup() {
showDialog(
context: context,
builder: (context) {
return Dialog(
backgroundColor: Colors.grey[900],
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"Select Bullet Color",
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 20),
Wrap(
spacing: 12,
runSpacing: 12,
children: _bulletColors.map((colorName) {
final color = _getColorFromName(colorName);
final isSelected = colorName == _selectedBulletColor;
return GestureDetector(
onTap: () async {
await _saveBulletColor(colorName);
Navigator.of(context).pop();
},
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: isSelected ? Colors.white : Colors.white24,
width: isSelected ? 3 : 1,
),
),
),
);
}).toList(),
),
const SizedBox(height: 20),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text("Cancel", style: TextStyle(color: Colors.white70)),
),
],
),
),
);
},
);
}
Widget _buildCameraList() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"Camera Settings",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 8),
...List.generate(6, (index) {
return GestureDetector(
onTap: () => _editCameraIP(index),
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Camera ${index + 1}",
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
Text(
_cameraIPs[index],
style: const TextStyle(color: Colors.black54),
),
],
),
const Icon(Icons.chevron_right, color: Colors.black45),
],
),
),
);
}),
],
);
}
}
+579
View File
@@ -0,0 +1,579 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:screenshot/screenshot.dart';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_mjpeg/flutter_mjpeg.dart';
import 'package:path_provider/path_provider.dart';
import 'package:image/image.dart' as img;
import 'yolo_detector.dart';
import 'preview_screen.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
enum ImageFilterType { none, greyscale, invert}
class CameraStreamScreen extends StatefulWidget {
final int cameraIndex;
const CameraStreamScreen({super.key, required this.cameraIndex});
@override
State<CameraStreamScreen> createState() => _CameraStreamScreenState();
}
class _CameraStreamScreenState extends State<CameraStreamScreen> with SingleTickerProviderStateMixin {
String? _streamUrl;
bool _isError = false;
bool _isCapturing = false;
final ScreenshotController _screenshotController = ScreenshotController();
final TransformationController _transformationController = TransformationController();
ImageFilterType _selectedFilter = ImageFilterType.none;
late YOLODetector _yoloDetector;
int _numberOfBullets = 5;
late AnimationController _zoomAnimController;
Animation<Matrix4>? _zoomAnimation;
Matrix4? _originalMatrix;
Color _bulletColor = Colors.blue; // default/fallback bullet color
@override
void initState() {
super.initState();
_loadStreamUrl();
_loadBulletCount();
_loadSelectedFilter();
_loadBulletColor(); // 👈 add this to actually load the saved bullet color
YOLODetector.create().then((detector) => _yoloDetector = detector);
_setSystemBarStyle(); // 👈 set colors
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); // 👈 show bars
_keepScreenAwake();
_zoomAnimController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
}
Future<void> _loadBulletColor() async {
final prefs = await SharedPreferences.getInstance();
final selected = prefs.getString('bulletColor') ?? 'Blue';
setState(() {
_bulletColor = _getColorFromName(selected);
});
}
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 'sky':
return Colors.orange;
case 'violet':
return Colors.deepPurpleAccent;
default:
return Colors.blue; // fallback
}
}
void _setSystemBarStyle() {
SystemChrome.setSystemUIOverlayStyle(
SystemUiOverlayStyle(
statusBarColor: Colors.grey[900], // ✅ match your AppBar
statusBarIconBrightness: Brightness.light, // white icons
systemNavigationBarColor: Colors.grey[900],
systemNavigationBarIconBrightness: Brightness.light,
),
);
}
Future<void> _loadSelectedFilter() async {
final prefs = await SharedPreferences.getInstance();
final index = prefs.getInt('selectedFilter');
if (index != null && index >= 0 && index < ImageFilterType.values.length) {
setState(() {
_selectedFilter = ImageFilterType.values[index];
});
}
}
Future<void> _keepScreenAwake() async {
try {
await WakelockPlus.enable();
} catch (e) {
debugPrint("⚠️ Could not keep screen awake: $e");
}
}
Future<void> _loadStreamUrl() async {
final prefs = await SharedPreferences.getInstance();
String? url = prefs.getString('cameraIP_${widget.cameraIndex}');
setState(() {
_streamUrl = (url != null && url.trim().isNotEmpty)
? (url.startsWith(RegExp(r"https?://")) ? url : "http://$url")
: null;
_isError = _streamUrl == null;
});
}
Future<void> _loadBulletCount() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_numberOfBullets = prefs.getInt('bulletCount') ?? 5;
});
}
Future<void> _captureAndPreviewImage() async {
if (_streamUrl == null || _isCapturing) return;
setState(() => _isCapturing = true);
_originalMatrix = _transformationController.value;
try {
final targetMatrix = Matrix4.identity()..scale(1.0);
final isZoomedIn = !_matrixEquals(_originalMatrix!, targetMatrix);
if (isZoomedIn) {
_zoomAnimController.reset();
_zoomAnimation = Matrix4Tween(
begin: _originalMatrix,
end: targetMatrix,
).animate(CurvedAnimation(
parent: _zoomAnimController,
curve: Curves.easeOut,
));
void updateZoom() {
_transformationController.value = _zoomAnimation!.value;
}
_zoomAnimController.addListener(updateZoom);
await _zoomAnimController.forward();
_zoomAnimController.removeListener(updateZoom);
}
await Future.delayed(const Duration(milliseconds: 100));
final imageBytes = await _screenshotController.capture();
if (imageBytes == null || imageBytes.isEmpty) return;
final decoded = img.decodeImage(imageBytes);
if (decoded == null) return;
final width = decoded.width;
final height = decoded.height;
final size = width < height ? width : height;
final offsetX = (width - size) ~/ 2;
final offsetY = (height - size) ~/ 2;
final cropped = img.copyCrop(decoded, x: offsetX, y: offsetY, width: size, height: size);
// Debugging: log the selected filter
debugPrint("📸 Selected filter: $_selectedFilter");
// Create the output directory and path
final dir = await getApplicationDocumentsDirectory();
final path = '${dir.path}/ip_capture_temp.jpg';
img.Image processed = cropped;
// Apply the selected filter
switch (_selectedFilter) {
case ImageFilterType.greyscale:
processed = img.grayscale(cropped);
break;
case ImageFilterType.invert:
processed = invertImage(cropped); // ✅ use your custom invert function
break;
case ImageFilterType.none:
break;
}
// Save the processed image to a file
final file = File(path)..writeAsBytesSync(img.encodeJpg(processed));
// Detect objects in the image using YOLO
final detections = await _yoloDetector.detect(file);
if (!mounted) return;
// Navigate to the preview screen
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PreviewScreen(
imagePath: file.path,
detections: detections,
bulletCount: _numberOfBullets,
),
),
);
} catch (e) {
debugPrint("❌ Capture error: $e");
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Capture failed: $e")),
);
}
} finally {
_zoomAnimController.reset();
_zoomAnimation = Matrix4Tween(
begin: _transformationController.value,
end: _originalMatrix!,
).animate(CurvedAnimation(
parent: _zoomAnimController,
curve: Curves.easeIn,
));
void restoreZoom() {
_transformationController.value = _zoomAnimation!.value;
}
_zoomAnimController.addListener(restoreZoom);
await _zoomAnimController.forward();
_zoomAnimController.removeListener(restoreZoom);
setState(() => _isCapturing = false);
}
}
img.Image invertImage(img.Image source) {
final result = img.Image.from(source);
for (int y = 0; y < result.height; y++) {
for (int x = 0; x < result.width; x++) {
final pixel = result.getPixel(x, y);
result.setPixelRgb(x, y, 255 - pixel.r, 255 - pixel.g, 255 - pixel.b);
}
}
return result;
}
bool _matrixEquals(Matrix4 a, Matrix4 b) {
for (int i = 0; i < 16; i++) {
if ((a.storage[i] - b.storage[i]).abs() > 0.001) return false;
}
return true;
}
void _showBulletDialog() {
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), // Increased padding for better spacing
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(bottom: 16.0),
child: Text(
'Select the number of bullets',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
Center(
child: Wrap(
alignment: WrapAlignment.center,
spacing: 12,
runSpacing: 12,
children: List.generate(5, (index) {
final bulletNum = index + 1;
final isSelected = _numberOfBullets == bulletNum;
return ElevatedButton(
onPressed: () {
_selectBulletCount(bulletNum);
Navigator.of(context).pop();
},
style: ElevatedButton.styleFrom(
backgroundColor:
isSelected ? _bulletColor : Colors.grey[800],
foregroundColor: Colors.white,
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
'$bulletNum',
style: TextStyle(
fontSize: 18,
fontWeight:
isSelected ? FontWeight.bold : FontWeight.normal,
),
),
);
}),
),
),
],
),
),
);
},
);
}
void _showFilterDialog() {
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,
children: [
const Text(
'Choose a Filter',
style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 12,
alignment: WrapAlignment.center,
children: ImageFilterType.values.map((f) {
final isSelected = _selectedFilter == f;
final label = f.toString().split('.').last;
return ElevatedButton(
onPressed: () async {
setState(() {
_selectedFilter = f;
});
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('selectedFilter', f.index); // Save filter index
Navigator.of(context).pop();
},
style: ElevatedButton.styleFrom(
backgroundColor: isSelected ? _bulletColor : Colors.grey[800],
foregroundColor: Colors.white,
),
child: Text(label[0].toUpperCase() + label.substring(1)),
);
}).toList(),
),
],
),
),
);
},
);
}
void _selectBulletCount(int number) async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_numberOfBullets = number;
});
await prefs.setInt('bulletCount', number);
}
@override
void dispose() {
_zoomAnimController.dispose();
_yoloDetector.close();
_restoreScreenSettings();
_setSystemBarStyle(); // restore grey[900] on back
super.dispose();
}
Future<void> _restoreScreenSettings() async {
try {
await WakelockPlus.disable();
} catch (e) {
debugPrint("⚠️ Could not restore screen settings: $e");
}
}
ColorFilter _buildColorFilter() {
switch (_selectedFilter) {
case ImageFilterType.greyscale:
return const ColorFilter.matrix([
0.2126, 0.7152, 0.0722, 0, 0,
0.2126, 0.7152, 0.0722, 0, 0,
0.2126, 0.7152, 0.0722, 0, 0,
0, 0, 0, 1, 0,
]);
case ImageFilterType.invert:
return const ColorFilter.matrix([
-1, 0, 0, 0, 255,
0, -1, 0, 0, 255,
0, 0, -1, 0, 255,
0, 0, 0, 1, 0,
]);
default:
return const ColorFilter.mode(Colors.transparent, BlendMode.dst);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Target ${widget.cameraIndex}"),
backgroundColor: Colors.grey[900],
foregroundColor: Colors.white,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
icon: Text(
'$_numberOfBullets',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
onPressed: _showBulletDialog,
),
IconButton(
icon: const Icon(Icons.filter_alt, color: Colors.white),
onPressed: _showFilterDialog,
),
const SizedBox(width: 16),
],
),
backgroundColor: Colors.black,
body: Stack(
children: [
Screenshot(
controller: _screenshotController,
child: LayoutBuilder(
builder: (context, constraints) {
return Center(
child: InteractiveViewer(
clipBehavior: Clip.hardEdge,
constrained: false,
boundaryMargin: EdgeInsets.zero,
minScale: 1.0,
maxScale: 5.0,
transformationController: _transformationController,
child: SizedBox(
width: constraints.maxWidth,
height: constraints.maxHeight,
child: (_isError || _streamUrl == null)
? _buildErrorContent()
: RepaintBoundary(
child: ColorFiltered(
colorFilter: _buildColorFilter(),
child: Mjpeg(
stream: _streamUrl!,
isLive: true,
fit: BoxFit.contain,
error: (context, error, stackTrace) => _buildErrorContent(),
),
),
),
),
),
);
},
),
),
if (_isCapturing)
Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.4),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: Colors.white),
SizedBox(height: 16),
Text(
"Processing image...",
style: TextStyle(color: Colors.white, fontSize: 16),
),
],
),
),
),
],
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
floatingActionButton: _isError
? null
: FloatingActionButton(
backgroundColor: Colors.white,
onPressed: _captureAndPreviewImage,
child: const Icon(Icons.camera_alt, color: Colors.black),
),
bottomNavigationBar: BottomAppBar(
color: Colors.grey[900],
shape: const CircularNotchedRectangle(),
notchMargin: 8.0,
child: SizedBox(
height: 60,
child: Stack(
alignment: Alignment.center,
children: [
Align(
alignment: Alignment.center,
),
],
),
),
),
);
}
Widget _buildErrorContent() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error, color: Colors.red, size: 100),
const SizedBox(height: 20),
const Text(
"Unable to load camera stream",
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
ElevatedButton(
onPressed: _loadStreamUrl,
child: const Text("Retry"),
),
],
);
}
}
+118
View File
@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'stream_screen.dart'; // Import the camera stream screen
class TargetScreen extends StatefulWidget {
const TargetScreen({super.key});
@override
State<TargetScreen> createState() => _TargetScreenState();
}
class _TargetScreenState extends State<TargetScreen> {
List<bool> _cameraButtonsEnabled = List.generate(6, (_) => false); // Initially, all buttons are disabled
@override
void initState() {
super.initState();
_loadCameraData();
}
/// Load the camera IPs from SharedPreferences and update button status
Future<void> _loadCameraData() async {
final prefs = await SharedPreferences.getInstance();
final List<String> defaultIPs = [
'192.168.0.134:9081',
'192.168.0.134:9082',
'192.168.0.134:9083',
'192.168.0.134:9084',
'192.168.0.134:9085',
'192.168.0.134:9086',
];
List<bool> cameraDataStatus = List.generate(6, (index) {
String key = 'cameraIP_${index + 1}';
if (!prefs.containsKey(key)) {
prefs.setString(key, defaultIPs[index]);
return true; // Default set
}
String? ip = prefs.getString(key);
return ip != null && ip.isNotEmpty && ip != 'Not Set';
});
if (!mounted) return;
setState(() {
_cameraButtonsEnabled = cameraDataStatus;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: Column(
children: [
Expanded(
child: GridView.builder(
itemCount: 6,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 1, // square-ish buttons
),
itemBuilder: (context, index) {
final isEnabled = _cameraButtonsEnabled[index];
return InkWell(
onTap: isEnabled
? () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CameraStreamScreen(cameraIndex: index + 1),
),
);
}
: null,
borderRadius: BorderRadius.circular(16),
child: Container(
decoration: BoxDecoration(
color: isEnabled ? Colors.grey[900] : Colors.grey[300],
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.videocam_rounded,
color: isEnabled ? Colors.white : Colors.grey[500],
size: 36,
),
const SizedBox(height: 12),
Text(
"Target ${index + 1}",
textAlign: TextAlign.center,
style: TextStyle(
color: isEnabled ? Colors.white : Colors.grey[600],
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
},
),
),
],
),
),
);
}
}
+515
View File
@@ -0,0 +1,515 @@
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';
class TargetViewScreen extends StatefulWidget {
final List<File> jsonFiles;
final Function refreshFolders;
final bool isCombinedView;
const TargetViewScreen({
super.key,
required this.jsonFiles,
required this.refreshFolders,
this.isCombinedView = false,
});
@override
State<TargetViewScreen> createState() => _TargetViewScreenState();
}
class _TargetViewScreenState extends State<TargetViewScreen> {
int _currentIndex = 0;
List<Offset> _bullets = [];
int _totalScore = 0;
Offset? _targetCenter;
Uint8List? _targetImageBytes;
img.Image? _targetImageDecoded;
String _date = '';
int? _highlightedBulletIndex;
int? _pressedScoreGroup;
late String _targetFilePath;
final String targetAssetPath = 'assets/target.png';
final double bulletSize = 19;
Color _bulletColor = Colors.blue; // default/fallback bullet color
@override
void initState() {
super.initState();
_loadBulletColor(); // 👈 this will load bullet color
_loadJsonAndImage();
}
Future<void> _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 'sky':
return Colors.orange;
case 'violet':
return Colors.deepPurpleAccent;
default:
return Colors.blue; // fallback
}
}
Future<void> _loadJsonAndImage() async {
try {
final byteData = await rootBundle.load(targetAssetPath);
final bytes = byteData.buffer.asUint8List();
_targetImageDecoded = img.decodeImage(bytes);
if (_targetImageDecoded == null) {
print("Failed to decode image.");
return;
}
_targetImageBytes = Uint8List.fromList(img.encodePng(_targetImageDecoded!));
_targetCenter = Offset(
_targetImageDecoded!.width / 2,
_targetImageDecoded!.height / 2 - 1,
);
final currentFile = widget.jsonFiles[_currentIndex];
final jsonString = await currentFile.readAsString();
final data = jsonDecode(jsonString);
_date = data['date'] ?? 'Unknown Date';
final bulletsJson = data['bullets'] as List;
_bullets = bulletsJson.map((b) => Offset(b['x'], b['y'])).toList();
_targetFilePath = currentFile.path;
_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<void> _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: Colors.white,
appBar: AppBar(
title: 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: [
const Text(
'Delete Target',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
const Text(
"Are you sure you want to delete this target?",
style: 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: const Text("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())
: 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;
return Stack(
children: [
Positioned(
left: offsetX,
top: offsetY,
width: displayWidth,
height: displayHeight,
child: Image.memory(_targetImageBytes!, fit: BoxFit.fill),
),
..._bullets.asMap().entries.map((entry) {
final i = entry.key;
final bullet = entry.value;
final dx = bullet.dx * scaleX + offsetX;
final dy = bullet.dy * scaleY + offsetY;
final score = _scoreForBullet(bullet, _targetCenter!);
final isHighlighted = widget.isCombinedView
? (_pressedScoreGroup == score)
: (i == _highlightedBulletIndex);
return Positioned(
left: dx - bulletSize / 2,
top: dy - bulletSize / 2,
child: Container(
width: bulletSize,
height: bulletSize,
decoration: BoxDecoration(
color: _bulletColor.withOpacity(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: Colors.white,
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],
child: SizedBox(
height: 56,
child: Center(
child: Text(
"🎯 Total Score: $_totalScore",
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
),
);
}
List<Widget> _buildGroupedScoreButtons() {
final Map<int, int> scoreGroups = {};
for (final bullet in _bullets) {
final score = _scoreForBullet(bullet, _targetCenter!);
scoreGroups[score] = (scoreGroups[score] ?? 0) + 1;
}
return scoreGroups.entries.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: [
BoxShadow(
color: Colors.black26,
offset: const 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.withOpacity(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<Widget> _buildIndividualScoreButtons() {
return List.generate(_bullets.length, (index) {
final bullet = _bullets[index];
final score = _scoreForBullet(bullet, _targetCenter!);
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: [
BoxShadow(
color: Colors.black26,
offset: const 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.withOpacity(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,
),
),
),
),
],
),
),
),
),
);
});
}
}
+162
View File
@@ -0,0 +1,162 @@
// ======= yolo_detector.dart (updated with imageToInputTensor helper) =======
import 'dart:io';
import 'dart:typed_data';
import 'package:tflite_flutter/tflite_flutter.dart';
import 'package:image/image.dart' as img;
class DetectionResult {
final double x1, y1, x2, y2;
final double confidence;
final int classId;
DetectionResult(this.x1, this.y1, this.x2, this.y2, this.confidence, this.classId);
}
class CroppedImageResult {
final img.Image image;
final int offsetX;
final int offsetY;
final int cropWidth;
final int cropHeight;
CroppedImageResult(this.image, this.offsetX, this.offsetY, this.cropWidth, this.cropHeight);
}
class YOLODetector {
late Interpreter _interpreter;
late int inputWidth, inputHeight;
final double confidenceThreshold = 0.4;
YOLODetector._create();
static Future<YOLODetector> create() async {
final detector = YOLODetector._create();
await detector._loadModel();
return detector;
}
Future<void> _loadModel() async {
final options = InterpreterOptions()..threads = 2;
_interpreter = await Interpreter.fromAsset('assets/best_float32_nms.tflite', options: options);
final inputTensor = _interpreter.getInputTensor(0);
final inputShape = inputTensor.shape;
inputHeight = inputShape[1];
inputWidth = inputShape[2];
print("📐 Model input shape: $inputShape");
print("📦 Model input type: ${inputTensor.type}");
}
Future<List<DetectionResult>> detect(File imageFile) async {
final bytes = await imageFile.readAsBytes();
final decodedImage = img.decodeImage(bytes);
if (decodedImage == null) throw Exception("Could not decode image.");
return detectFromImage(decodedImage);
}
Future<List<DetectionResult>> detectFromImage(img.Image originalImage) async {
final croppedResult = cropToModelAspectRatio(originalImage, inputWidth, inputHeight);
final fixedImage = img.bakeOrientation(croppedResult.image);
final inputTensor = imageToInputTensor(fixedImage, inputWidth, inputHeight);
final inputBuffer = reshapeFloat(inputTensor.toList(), [1, inputHeight, inputWidth, 3]);
final outputShape = _interpreter.getOutputTensor(0).shape;
final numDetections = outputShape[1];
final outputBuffer = List.generate(1, (_) =>
List.generate(numDetections, (_) => List.filled(6, 0.0)));
print("🚀 Running inference...");
_interpreter.run(inputBuffer, outputBuffer);
final rawDetections = outputBuffer[0];
final results = <DetectionResult>[];
for (var detection in rawDetections) {
final x1 = detection[0];
final y1 = detection[1];
final x2 = detection[2];
final y2 = detection[3];
final score = detection[4];
final classId = detection[5].toInt();
if (score < confidenceThreshold) continue;
results.add(DetectionResult(
x1 * croppedResult.cropWidth + croppedResult.offsetX,
y1 * croppedResult.cropHeight + croppedResult.offsetY,
x2 * croppedResult.cropWidth + croppedResult.offsetX,
y2 * croppedResult.cropHeight + croppedResult.offsetY,
score,
classId,
));
}
print("🎯 Detections found: ${results.length}");
return results;
}
void close() {
_interpreter.close();
}
List<List<List<List<double>>>> reshapeFloat(List<double> flat, List<int> dims) {
final d0 = dims[0], d1 = dims[1], d2 = dims[2], d3 = dims[3];
final reshaped = List.generate(d0, (_) =>
List.generate(d1, (_) =>
List.generate(d2, (_) => List.filled(d3, 0.0))));
int index = 0;
for (int i = 0; i < d0; i++) {
for (int j = 0; j < d1; j++) {
for (int k = 0; k < d2; k++) {
for (int l = 0; l < d3; l++) {
reshaped[i][j][k][l] = flat[index++];
}
}
}
}
return reshaped;
}
CroppedImageResult cropToModelAspectRatio(img.Image image, int targetWidth, int targetHeight) {
final targetAspectRatio = targetWidth / targetHeight;
final originalAspectRatio = image.width / image.height;
int cropWidth = image.width;
int cropHeight = image.height;
if (originalAspectRatio > targetAspectRatio) {
cropWidth = (image.height * targetAspectRatio).toInt();
} else {
cropHeight = (image.width / targetAspectRatio).toInt();
}
final offsetX = ((image.width - cropWidth) / 2).toInt();
final offsetY = ((image.height - cropHeight) / 2).toInt();
final croppedImage = img.copyCrop(image, x: offsetX, y: offsetY, width: cropWidth, height: cropHeight);
return CroppedImageResult(croppedImage, offsetX, offsetY, cropWidth, cropHeight);
}
Float32List imageToInputTensor(img.Image inputImage, int width, int height) {
final resized = img.copyResize(inputImage, width: width, height: height);
final Float32List input = Float32List(width * height * 3);
int index = 0;
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
final pixel = resized.getPixel(x, y);
final r = pixel.r / 255.0;
final g = pixel.g / 255.0;
final b = pixel.b / 255.0;
input[index++] = r;
input[index++] = g;
input[index++] = b;
}
}
return input;
}
}