first commit
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart'; // Import for SystemChrome
|
||||
import 'screens/home_screen.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Set system UI overlay style globally for the app
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.grey[900], // Set status bar color (top bar)
|
||||
systemNavigationBarColor: Colors.grey[900], // Set navigation bar color
|
||||
systemNavigationBarIconBrightness: Brightness.light, // Set navigation icon color to light
|
||||
),
|
||||
);
|
||||
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'App with Calendar',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.white),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const HomeScreen(), // Change LoginScreen to HomeScreen
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class SharedPrefsService {
|
||||
static Future<void> saveCameraIPs(List<String> cameraIPs) async {
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
for (int i = 0; i < 6; i++) {
|
||||
prefs.setString('camera_$i', cameraIPs[i]);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<String>> loadCameraIPs() async {
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
return List.generate(6, (index) => prefs.getString('camera_$index') ?? '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class BottomNavBar extends StatelessWidget {
|
||||
final int selectedIndex;
|
||||
final Function(int) onItemTapped;
|
||||
|
||||
const BottomNavBar({
|
||||
super.key,
|
||||
required this.selectedIndex,
|
||||
required this.onItemTapped,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: SizedBox(
|
||||
height: 80,
|
||||
child: BottomNavigationBar(
|
||||
backgroundColor: Colors.grey[900],
|
||||
currentIndex: selectedIndex,
|
||||
onTap: onItemTapped,
|
||||
showSelectedLabels: true,
|
||||
showUnselectedLabels: true,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
selectedFontSize: 12,
|
||||
unselectedFontSize: 12,
|
||||
selectedItemColor: Colors.white,
|
||||
unselectedItemColor: Colors.white70,
|
||||
items: const [
|
||||
BottomNavigationBarItem(icon: Icon(Icons.book), label: "Book"),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.my_location), label: "Shooting"),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.camera_alt), label: "Camera"),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.calendar_today), label: "Calendar"),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.settings), label: "Settings"),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class InputField extends StatelessWidget {
|
||||
final String hintText;
|
||||
final IconData icon;
|
||||
final bool obscureText;
|
||||
final TextEditingController controller;
|
||||
|
||||
const InputField({
|
||||
super.key,
|
||||
required this.hintText,
|
||||
required this.icon,
|
||||
this.obscureText = false,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
obscureText: obscureText,
|
||||
style: const TextStyle(color: Colors.grey), // Text inside input field is gray
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: Icon(icon, color: Colors.grey), // Icons inside input field are gray
|
||||
hintText: hintText,
|
||||
hintStyle: const TextStyle(color: Colors.grey), // Placeholder text is gray
|
||||
filled: true,
|
||||
fillColor: Colors.grey[300], // Light gray background for inputs
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none, // No border
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user