emojis removed, updated headers, added spectate mode
This commit is contained in:
+138
-62
@@ -376,13 +376,24 @@ Future<void> _renameFolder(String oldFolderName) async {
|
|||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Row(
|
||||||
isTournament ? _t('Tournament Mode 🏆') : _t('Mark as Tournament 🏆'),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
style: TextStyle(
|
children: [
|
||||||
color: isTournament ? _bulletColor : Colors.white70,
|
Icon(
|
||||||
fontWeight: FontWeight.w500,
|
Icons.emoji_events,
|
||||||
fontSize: 16,
|
size: 16,
|
||||||
),
|
color: isTournament ? _bulletColor : Colors.white70,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
isTournament ? _t('Tournament Mode') : _t('Mark as Tournament'),
|
||||||
|
style: TextStyle(
|
||||||
|
color: isTournament ? _bulletColor : Colors.white70,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -594,14 +605,14 @@ Widget _buildFolderRow(String folderName) {
|
|||||||
: 'Unknown Date';
|
: 'Unknown Date';
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 16),
|
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey[300],
|
color: Colors.grey[300],
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Header section: Name and Date
|
// Header section with grey background like toolbars
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -613,40 +624,56 @@ Widget _buildFolderRow(String folderName) {
|
|||||||
topRight: Radius.circular(12),
|
topRight: Radius.circular(12),
|
||||||
),
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[900],
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(12),
|
||||||
|
topRight: Radius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
// Small colored indicator
|
// White indicator line
|
||||||
Container(
|
Container(
|
||||||
width: 4,
|
width: 4,
|
||||||
height: 20,
|
height: 20,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _bulletColor,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Row(
|
||||||
_folderTypes[folderName] == true
|
children: [
|
||||||
? '🏆 $folderName'
|
if (_folderTypes[folderName] == true) ...[
|
||||||
: folderName,
|
const Icon(Icons.emoji_events, size: 18, color: Colors.white),
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: _bulletColor),
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
folderName,
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
const Icon(Icons.calendar_today, size: 14, color: Colors.white),
|
||||||
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
"📅 $formattedDate",
|
formattedDate,
|
||||||
style: const TextStyle(fontSize: 13, color: Colors.black87),
|
style: const TextStyle(fontSize: 13, color: Colors.white),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 12),
|
||||||
Icon(
|
Icon(
|
||||||
_expandedFolder == folderName ? Icons.expand_less : Icons.expand_more,
|
_expandedFolder == folderName ? Icons.expand_less : Icons.expand_more,
|
||||||
color: Colors.black87,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -698,7 +725,7 @@ Widget _buildFolderRow(String folderName) {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.emoji_events, size: 16, color: Colors.amber),
|
const Icon(Icons.emoji_events, size: 16, color: Colors.black87),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
"$totalScore",
|
"$totalScore",
|
||||||
@@ -816,7 +843,7 @@ Widget _buildTargetsList(String folderName) {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Icon(
|
const Icon(
|
||||||
Icons.emoji_events,
|
Icons.emoji_events,
|
||||||
color: Colors.amber,
|
color: Colors.black87,
|
||||||
size: 14,
|
size: 14,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
@@ -893,13 +920,28 @@ Widget build(BuildContext context) {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
_folderFilter == 'all'
|
mainAxisSize: MainAxisSize.min,
|
||||||
? _t('Showing: 📂 All folders')
|
children: [
|
||||||
: _folderFilter == 'tournament'
|
Icon(
|
||||||
? _t('Showing: 🏆 Tournament folders')
|
_folderFilter == 'all'
|
||||||
: _t('Showing: 🎯 Training folders'),
|
? Icons.folder
|
||||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Colors.black),
|
: _folderFilter == 'tournament'
|
||||||
|
? Icons.emoji_events
|
||||||
|
: Icons.track_changes,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
_folderFilter == 'all'
|
||||||
|
? _t('Showing: All folders')
|
||||||
|
: _folderFilter == 'tournament'
|
||||||
|
? _t('Showing: Tournament folders')
|
||||||
|
: _t('Showing: Training folders'),
|
||||||
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Colors.black),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
Icon(Icons.filter_list, color: Colors.black87, size: 18),
|
Icon(Icons.filter_list, color: Colors.black87, size: 18),
|
||||||
],
|
],
|
||||||
@@ -917,6 +959,7 @@ Widget build(BuildContext context) {
|
|||||||
thickness: 6, // optional: adjust width
|
thickness: 6, // optional: adjust width
|
||||||
radius: const Radius.circular(8),
|
radius: const Radius.circular(8),
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
itemCount: _filteredFolderNames().length,
|
itemCount: _filteredFolderNames().length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
String folderName = _filteredFolderNames()[index];
|
String folderName = _filteredFolderNames()[index];
|
||||||
@@ -955,11 +998,11 @@ void _showFolderFilterPopup(BuildContext context) {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Folder type filters
|
// Folder type filters
|
||||||
_buildStyledFilterButton('all', _t('📂 All folders'), context),
|
_buildStyledFilterButton('all', Icons.folder, _t('All folders'), context),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildStyledFilterButton('tournament', _t('🏆 Tournament'), context),
|
_buildStyledFilterButton('tournament', Icons.emoji_events, _t('Tournament'), context),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildStyledFilterButton('training', _t('🎯 Training'), context),
|
_buildStyledFilterButton('training', Icons.track_changes, _t('Training'), context),
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
@@ -986,16 +1029,27 @@ void _showFolderFilterPopup(BuildContext context) {
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Row(
|
||||||
_t('📅 Sort by Date'),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
textAlign: TextAlign.center,
|
children: [
|
||||||
style: TextStyle(
|
Icon(
|
||||||
color: _sortMode == 'date' ? Colors.white : Colors.white70,
|
Icons.calendar_today,
|
||||||
fontWeight: FontWeight.w500,
|
size: 14,
|
||||||
fontSize: 14,
|
color: _sortMode == 'date' ? Colors.white : Colors.white70,
|
||||||
),
|
),
|
||||||
maxLines: 2,
|
const SizedBox(width: 4),
|
||||||
overflow: TextOverflow.ellipsis,
|
Text(
|
||||||
|
_t('Sort by Date'),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: _sortMode == 'date' ? Colors.white : Colors.white70,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1015,16 +1069,27 @@ void _showFolderFilterPopup(BuildContext context) {
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Row(
|
||||||
_t('🏆 Sort by Score'),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
textAlign: TextAlign.center,
|
children: [
|
||||||
style: TextStyle(
|
Icon(
|
||||||
color: _sortMode == 'score' ? Colors.white : Colors.white70,
|
Icons.emoji_events,
|
||||||
fontWeight: FontWeight.w500,
|
size: 14,
|
||||||
fontSize: 14,
|
color: _sortMode == 'score' ? Colors.white : Colors.white70,
|
||||||
),
|
),
|
||||||
maxLines: 2,
|
const SizedBox(width: 4),
|
||||||
overflow: TextOverflow.ellipsis,
|
Text(
|
||||||
|
_t('Sort by Score'),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: _sortMode == 'score' ? Colors.white : Colors.white70,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1043,7 +1108,7 @@ void _showFolderFilterPopup(BuildContext context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Widget _buildStyledFilterButton(String value, String label, BuildContext context) {
|
Widget _buildStyledFilterButton(String value, IconData icon, String label, BuildContext context) {
|
||||||
final isSelected = _folderFilter == value;
|
final isSelected = _folderFilter == value;
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
@@ -1062,13 +1127,24 @@ Widget _buildStyledFilterButton(String value, String label, BuildContext context
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Row(
|
||||||
label,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
style: TextStyle(
|
children: [
|
||||||
color: isSelected ? Colors.white : Colors.white70,
|
Icon(
|
||||||
fontSize: 16,
|
icon,
|
||||||
fontWeight: FontWeight.w500,
|
size: 18,
|
||||||
),
|
color: isSelected ? Colors.white : Colors.white70,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSelected ? Colors.white : Colors.white70,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -473,15 +473,15 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
|||||||
onTap: () => _makePhoneCall(contact.phone),
|
onTap: () => _makePhoneCall(contact.phone),
|
||||||
onLongPress: () => _showContactPopup(context, contact),
|
onLongPress: () => _showContactPopup(context, contact),
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey[300],
|
color: Colors.grey[300],
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(12),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.grey.withValues(alpha: 0.08),
|
color: Colors.grey.withValues(alpha: 0.3),
|
||||||
blurRadius: 2,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 1),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -490,37 +490,37 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
|||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Colored header bar (like event cards)
|
// Grey header bar with white text (matching book screen style)
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: accentColor.withValues(alpha: 0.2),
|
color: Colors.grey[900],
|
||||||
borderRadius: const BorderRadius.only(
|
borderRadius: const BorderRadius.only(
|
||||||
topLeft: Radius.circular(10),
|
topLeft: Radius.circular(12),
|
||||||
topRight: Radius.circular(10),
|
topRight: Radius.circular(12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Small colored indicator
|
// White indicator line
|
||||||
Container(
|
Container(
|
||||||
width: 3,
|
width: 4,
|
||||||
height: 12,
|
height: 20,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: accentColor,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 7),
|
const SizedBox(width: 12),
|
||||||
// Contact name
|
// Contact name in white
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
_capitalize(contact.fullName),
|
_capitalize(contact.fullName),
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: accentColor,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1154,9 +1154,7 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isToday
|
color: cardAccentColor,
|
||||||
? cardAccentColor.withValues(alpha: 0.2)
|
|
||||||
: cardAccentColor.withValues(alpha: 0.1),
|
|
||||||
borderRadius: const BorderRadius.only(
|
borderRadius: const BorderRadius.only(
|
||||||
topLeft: Radius.circular(12),
|
topLeft: Radius.circular(12),
|
||||||
topRight: Radius.circular(12),
|
topRight: Radius.circular(12),
|
||||||
@@ -1164,22 +1162,22 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Small colored indicator
|
// Small white indicator line
|
||||||
Container(
|
Container(
|
||||||
width: 4,
|
width: 4,
|
||||||
height: 16,
|
height: 16,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: cardAccentColor,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
dateText,
|
dateText,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: cardAccentColor,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../utils/translations.dart';
|
||||||
|
|
||||||
|
class CameraSelectionDialog extends StatefulWidget {
|
||||||
|
final List<Map<String, dynamic>> availableCameras;
|
||||||
|
final String selectedLanguage;
|
||||||
|
|
||||||
|
const CameraSelectionDialog({
|
||||||
|
super.key,
|
||||||
|
required this.availableCameras,
|
||||||
|
required this.selectedLanguage,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CameraSelectionDialog> createState() => _CameraSelectionDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CameraSelectionDialogState extends State<CameraSelectionDialog> {
|
||||||
|
late List<bool> _selectedCameras;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_selectedCameras = List.generate(widget.availableCameras.length, (_) => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _t(String key) {
|
||||||
|
return AppTranslations.translate(key, widget.selectedLanguage);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
backgroundColor: Colors.grey[900],
|
||||||
|
title: Text(
|
||||||
|
_t('Select Cameras to Spectate'),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
content: SizedBox(
|
||||||
|
width: double.maxFinite,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_t('Choose 1-6 cameras'),
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[400],
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Flexible(
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: widget.availableCameras.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final camera = widget.availableCameras[index];
|
||||||
|
final cameraIndex = camera['index'] as int;
|
||||||
|
final cameraIp = camera['ip'] as String;
|
||||||
|
|
||||||
|
return CheckboxListTile(
|
||||||
|
title: Text(
|
||||||
|
"${_t('Camera')} $cameraIndex",
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
cameraIp,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[400],
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
value: _selectedCameras[index],
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedCameras[index] = value ?? false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
activeColor: Colors.grey[800],
|
||||||
|
checkColor: Colors.white,
|
||||||
|
tileColor: Colors.grey[850],
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(
|
||||||
|
_t('Cancel'),
|
||||||
|
style: const TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _selectedCameras.contains(true)
|
||||||
|
? () {
|
||||||
|
final selectedCameras = <Map<String, dynamic>>[];
|
||||||
|
for (int i = 0; i < _selectedCameras.length; i++) {
|
||||||
|
if (_selectedCameras[i]) {
|
||||||
|
selectedCameras.add(widget.availableCameras[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Navigator.pop(context, selectedCameras);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
child: Text(
|
||||||
|
_t('View Cameras'),
|
||||||
|
style: TextStyle(
|
||||||
|
color: _selectedCameras.contains(true)
|
||||||
|
? Colors.white
|
||||||
|
: Colors.grey[600],
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_mjpeg/flutter_mjpeg.dart';
|
||||||
|
import '../utils/translations.dart';
|
||||||
|
|
||||||
|
class MultiCameraScreen extends StatefulWidget {
|
||||||
|
final List<Map<String, dynamic>> cameras;
|
||||||
|
final String selectedLanguage;
|
||||||
|
|
||||||
|
const MultiCameraScreen({
|
||||||
|
super.key,
|
||||||
|
required this.cameras,
|
||||||
|
required this.selectedLanguage,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MultiCameraScreen> createState() => _MultiCameraScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MultiCameraScreenState extends State<MultiCameraScreen> {
|
||||||
|
int? _fullscreenIndex; // null = grid view, index = fullscreen camera
|
||||||
|
|
||||||
|
String _t(String key) {
|
||||||
|
return AppTranslations.translate(key, widget.selectedLanguage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate grid columns based on camera count
|
||||||
|
int get _crossAxisCount {
|
||||||
|
final count = widget.cameras.length;
|
||||||
|
if (count == 1) return 1;
|
||||||
|
if (count <= 4) return 2;
|
||||||
|
return 3; // 5-6 cameras
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(
|
||||||
|
_fullscreenIndex == null
|
||||||
|
? _t('Multi-Camera View')
|
||||||
|
: "${_t('Camera')} ${widget.cameras[_fullscreenIndex!]['index']}",
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.grey[900],
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||||
|
onPressed: () {
|
||||||
|
if (_fullscreenIndex != null) {
|
||||||
|
setState(() {
|
||||||
|
_fullscreenIndex = null; // Exit fullscreen first
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Navigator.pop(context); // Exit screen
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: _fullscreenIndex == null
|
||||||
|
? _buildGridView()
|
||||||
|
: _buildFullscreenView(_fullscreenIndex!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildGridView() {
|
||||||
|
return GridView.builder(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
itemCount: widget.cameras.length,
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: _crossAxisCount,
|
||||||
|
mainAxisSpacing: 8,
|
||||||
|
crossAxisSpacing: 8,
|
||||||
|
childAspectRatio: 4 / 3, // 4:3 aspect ratio for camera feeds
|
||||||
|
),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return _buildCameraCell(index);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCameraCell(int index) {
|
||||||
|
final camera = widget.cameras[index];
|
||||||
|
final cameraIndex = camera['index'] as int;
|
||||||
|
final cameraIp = camera['ip'] as String;
|
||||||
|
|
||||||
|
final streamUrl = cameraIp.startsWith(RegExp(r"https?://"))
|
||||||
|
? cameraIp
|
||||||
|
: "http://$cameraIp";
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_fullscreenIndex = index; // Enter fullscreen
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.grey[700]!, width: 1),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
// MJPEG Stream
|
||||||
|
Mjpeg(
|
||||||
|
stream: streamUrl,
|
||||||
|
isLive: true,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
error: (context, error, stackTrace) {
|
||||||
|
return _buildErrorWidget(cameraIndex);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// Camera label overlay
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
left: 8,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.6),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
"${_t('Camera')} $cameraIndex",
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFullscreenView(int index) {
|
||||||
|
final camera = widget.cameras[index];
|
||||||
|
final cameraIndex = camera['index'] as int;
|
||||||
|
final cameraIp = camera['ip'] as String;
|
||||||
|
|
||||||
|
final streamUrl = cameraIp.startsWith(RegExp(r"https?://"))
|
||||||
|
? cameraIp
|
||||||
|
: "http://$cameraIp";
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_fullscreenIndex = null; // Exit fullscreen, return to grid
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Mjpeg(
|
||||||
|
stream: streamUrl,
|
||||||
|
isLive: true,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
error: (context, error, stackTrace) {
|
||||||
|
return _buildErrorWidget(cameraIndex);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Back indicator overlay
|
||||||
|
Positioned(
|
||||||
|
bottom: 16,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.6),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.touch_app, color: Colors.white, size: 16),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
_t('Tap to return to grid'),
|
||||||
|
style:
|
||||||
|
const TextStyle(color: Colors.white, fontSize: 14),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Camera label
|
||||||
|
Positioned(
|
||||||
|
top: 16,
|
||||||
|
left: 16,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.6),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
"${_t('Camera')} $cameraIndex",
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildErrorWidget(int cameraIndex) {
|
||||||
|
return Container(
|
||||||
|
color: Colors.black,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.videocam_off, color: Colors.red, size: 32),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
"${_t('Camera')} $cameraIndex\n${_t('Stream unavailable')}",
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -576,9 +576,17 @@ void _moveBullet(Offset offsetDelta) {
|
|||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
'🎯 $nextTraining',
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.track_changes, size: 18, color: Colors.white),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
nextTraining,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
@@ -611,9 +619,17 @@ void _moveBullet(Offset offsetDelta) {
|
|||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
'🏆 $nextTournament',
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.emoji_events, size: 18, color: Colors.white),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
nextTournament,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
@@ -656,9 +672,21 @@ void _moveBullet(Offset offsetDelta) {
|
|||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
'${lastIsTournament ? "🏆" : "🎯"} $lastFolder',
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
lastIsTournament ? Icons.emoji_events : Icons.track_changes,
|
||||||
|
size: 18,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
lastFolder,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
@@ -982,48 +1010,34 @@ Widget build(BuildContext context) {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Container(
|
||||||
children: [
|
decoration: BoxDecoration(
|
||||||
Container(
|
color: _getBulletButtonColor(index),
|
||||||
height: 30,
|
borderRadius: BorderRadius.circular(16),
|
||||||
decoration: BoxDecoration(
|
),
|
||||||
color: Colors.grey[300],
|
alignment: Alignment.center,
|
||||||
borderRadius: const BorderRadius.only(
|
child: Column(
|
||||||
topLeft: Radius.circular(16),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
topRight: Radius.circular(16),
|
children: [
|
||||||
),
|
Text(
|
||||||
),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Text(
|
|
||||||
'${index + 1}',
|
'${index + 1}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 14,
|
fontSize: 12,
|
||||||
color: Colors.black87,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 4),
|
||||||
Expanded(
|
Text(
|
||||||
child: Container(
|
'$score',
|
||||||
decoration: BoxDecoration(
|
style: const TextStyle(
|
||||||
color: _getBulletButtonColor(index),
|
fontSize: 28,
|
||||||
borderRadius: const BorderRadius.only(
|
fontWeight: FontWeight.bold,
|
||||||
bottomLeft: Radius.circular(16),
|
color: Colors.white,
|
||||||
bottomRight: Radius.circular(16),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Text(
|
|
||||||
'$score',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1095,41 +1109,36 @@ Widget build(BuildContext context) {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Left side - Label (darker grey)
|
// Left side - Label with icon
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(right: 16),
|
padding: const EdgeInsets.only(right: 16),
|
||||||
child: FittedBox(
|
child: FittedBox(
|
||||||
fit: BoxFit.scaleDown,
|
fit: BoxFit.scaleDown,
|
||||||
child: Text(
|
child: Row(
|
||||||
"🎯 Total Score:",
|
children: [
|
||||||
style: TextStyle(
|
const Icon(Icons.track_changes, size: 24, color: Colors.white),
|
||||||
fontSize: 28,
|
const SizedBox(width: 8),
|
||||||
fontWeight: FontWeight.bold,
|
const Text(
|
||||||
color: Colors.white,
|
"Total Score:",
|
||||||
),
|
style: TextStyle(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Right side - Score badge (grey with rounded corners)
|
// Right side - Score number (no box)
|
||||||
Container(
|
FittedBox(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
fit: BoxFit.scaleDown,
|
||||||
decoration: BoxDecoration(
|
child: Text(
|
||||||
color: Colors.grey[800],
|
"$_totalScore",
|
||||||
borderRadius: BorderRadius.circular(16),
|
style: const TextStyle(
|
||||||
border: Border.all(
|
fontSize: 28,
|
||||||
color: Colors.grey[700]!,
|
fontWeight: FontWeight.bold,
|
||||||
width: 2,
|
color: Colors.white,
|
||||||
),
|
|
||||||
),
|
|
||||||
child: FittedBox(
|
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
child: Text(
|
|
||||||
"$_totalScore",
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
+259
-114
@@ -331,36 +331,169 @@ Future<void> _saveLanguage(String language) async {
|
|||||||
orElse: () => _languages[0],
|
orElse: () => _languages[0],
|
||||||
);
|
);
|
||||||
|
|
||||||
return Column(
|
return Container(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 0),
|
||||||
children: [
|
decoration: BoxDecoration(
|
||||||
Text(
|
color: Colors.grey[300],
|
||||||
_t("Language"),
|
borderRadius: BorderRadius.circular(12),
|
||||||
style: const TextStyle(
|
boxShadow: [
|
||||||
fontSize: 16,
|
BoxShadow(
|
||||||
fontWeight: FontWeight.bold,
|
color: Colors.grey.withValues(alpha: 0.3),
|
||||||
color: Colors.black87,
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
GestureDetector(
|
child: Column(
|
||||||
onTap: _showLanguagePopup,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Container(
|
children: [
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
// Header
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey[300],
|
color: Colors.grey[900],
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: const BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(12),
|
||||||
|
topRight: Radius.circular(12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Container(
|
||||||
selectedLang['flag'] ?? '🌐',
|
width: 4,
|
||||||
style: const TextStyle(fontSize: 20),
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
_t("Language"),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Content
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _showLanguagePopup,
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(12),
|
||||||
|
bottomRight: Radius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
selectedLang['flag'] ?? '🌐',
|
||||||
|
style: const TextStyle(fontSize: 20),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_t(selectedLang['name'] ?? 'English'),
|
||||||
|
style: const TextStyle(color: Colors.black87, fontSize: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.chevron_right, color: Colors.black54),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBulletColorSelector() {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.grey.withValues(alpha: 0.3),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[900],
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(12),
|
||||||
|
topRight: Radius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 4,
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
_t("Select Bullet Color"),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Content
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _showBulletColorPopup,
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(12),
|
||||||
|
bottomRight: Radius.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(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
_t(selectedLang['name'] ?? 'English'),
|
_t(_selectedBulletColor),
|
||||||
style: const TextStyle(color: Colors.black87, fontSize: 14),
|
style: const TextStyle(color: Colors.black87, fontSize: 14),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -370,54 +503,7 @@ Future<void> _saveLanguage(String language) async {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
),
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildBulletColorSelector() {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
_t("Select Bullet Color"),
|
|
||||||
style: const 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(
|
|
||||||
_t(_selectedBulletColor),
|
|
||||||
style: const TextStyle(color: Colors.black87, fontSize: 14),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Icon(Icons.chevron_right, color: Colors.black54),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,54 +647,113 @@ void _showBulletColorPopup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCameraList() {
|
Widget _buildCameraList() {
|
||||||
return Column(
|
return Container(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 0),
|
||||||
children: [
|
decoration: BoxDecoration(
|
||||||
Text(
|
color: Colors.grey[300],
|
||||||
_t("Camera Settings"),
|
borderRadius: BorderRadius.circular(12),
|
||||||
style: const TextStyle(
|
boxShadow: [
|
||||||
fontSize: 16,
|
BoxShadow(
|
||||||
fontWeight: FontWeight.bold,
|
color: Colors.grey.withValues(alpha: 0.3),
|
||||||
color: Colors.black87,
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
...List.generate(6, (index) {
|
child: Column(
|
||||||
return GestureDetector(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
onTap: () => _editCameraIP(index),
|
children: [
|
||||||
child: Container(
|
// Header
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
width: double.infinity,
|
||||||
decoration: BoxDecoration(
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
color: Colors.grey[300],
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(12),
|
color: Colors.grey[900],
|
||||||
),
|
borderRadius: const BorderRadius.only(
|
||||||
child: Row(
|
topLeft: Radius.circular(12),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
topRight: Radius.circular(12),
|
||||||
children: [
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
"${_t('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),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
child: Row(
|
||||||
}),
|
children: [
|
||||||
],
|
Container(
|
||||||
|
width: 4,
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
_t("Camera Settings"),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Content - all cameras
|
||||||
|
...List.generate(6, (index) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _editCameraIP(index),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
border: index < 5
|
||||||
|
? Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: Colors.grey[400]!,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"${_t('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.black54),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (index == 5)
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(12),
|
||||||
|
bottomRight: Radius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_mjpeg/flutter_mjpeg.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../utils/translations.dart';
|
||||||
|
|
||||||
|
class SpectatorScreen extends StatefulWidget {
|
||||||
|
final String selectedLanguage;
|
||||||
|
|
||||||
|
const SpectatorScreen({
|
||||||
|
super.key,
|
||||||
|
required this.selectedLanguage,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SpectatorScreen> createState() => _SpectatorScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SpectatorScreenState extends State<SpectatorScreen> {
|
||||||
|
List<bool> _selectedCameras = List.generate(6, (_) => true); // All selected by default
|
||||||
|
List<bool> _enabledCameras = List.generate(6, (_) => false); // Which cameras have IPs configured
|
||||||
|
List<Map<String, dynamic>> _cameras = [];
|
||||||
|
Color _bulletColor = Colors.blue; // default/fallback bullet color
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadCameras();
|
||||||
|
_loadBulletColor();
|
||||||
|
_setSystemUIMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setSystemUIMode() {
|
||||||
|
// No special system UI mode needed for portrait
|
||||||
|
}
|
||||||
|
|
||||||
|
String _t(String key) {
|
||||||
|
return AppTranslations.translate(key, widget.selectedLanguage);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadCameras() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
List<Map<String, dynamic>> cameras = [];
|
||||||
|
List<bool> enabledCameras = List.generate(6, (_) => false);
|
||||||
|
|
||||||
|
for (int i = 0; i < 6; i++) {
|
||||||
|
String? ip = prefs.getString('cameraIP_${i + 1}');
|
||||||
|
if (ip != null && ip.isNotEmpty && ip != 'Not Set') {
|
||||||
|
cameras.add({
|
||||||
|
'index': i + 1,
|
||||||
|
'ip': ip,
|
||||||
|
});
|
||||||
|
enabledCameras[i] = true; // Mark camera as enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_cameras = cameras;
|
||||||
|
_enabledCameras = enabledCameras;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 'orange':
|
||||||
|
return Colors.orange;
|
||||||
|
case 'red':
|
||||||
|
return Colors.red;
|
||||||
|
default:
|
||||||
|
return Colors.blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showCameraSelection() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: Colors.grey[900],
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setModalState) => SingleChildScrollView(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_t('Select Cameras'),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Single row of 6 camera buttons
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: List.generate(6, (index) {
|
||||||
|
final cameraNum = index + 1;
|
||||||
|
final isSelected = _selectedCameras[index];
|
||||||
|
final isEnabled = _enabledCameras[index];
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: isEnabled
|
||||||
|
? () {
|
||||||
|
setModalState(() {
|
||||||
|
_selectedCameras[index] = !_selectedCameras[index];
|
||||||
|
});
|
||||||
|
// Also update the parent state to refresh camera grid
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
child: Container(
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: !isEnabled
|
||||||
|
? Colors.grey[700]
|
||||||
|
: (isSelected ? _bulletColor : Colors.grey[800]),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: !isEnabled
|
||||||
|
? Colors.grey[600]!
|
||||||
|
: (isSelected ? _bulletColor : Colors.grey[700]!),
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'$cameraNum',
|
||||||
|
style: TextStyle(
|
||||||
|
color: isEnabled ? Colors.white : Colors.grey[500],
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 40,
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.grey[800],
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(
|
||||||
|
_t('Done'),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final visibleCameras = <Map<String, dynamic>>[];
|
||||||
|
for (int i = 0; i < _cameras.length; i++) {
|
||||||
|
if (_selectedCameras[_cameras[i]['index'] - 1]) {
|
||||||
|
visibleCameras.add(_cameras[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(
|
||||||
|
_t('Spectate'),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.grey[900],
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.settings, color: Colors.white),
|
||||||
|
onPressed: _showCameraSelection,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: visibleCameras.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
_t('No cameras selected'),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
itemCount: visibleCameras.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final camera = visibleCameras[index];
|
||||||
|
final cameraIndex = camera['index'] as int;
|
||||||
|
final cameraIp = camera['ip'] as String;
|
||||||
|
|
||||||
|
final streamUrl = cameraIp.startsWith(RegExp(r"https?://"))
|
||||||
|
? cameraIp
|
||||||
|
: "http://$cameraIp";
|
||||||
|
|
||||||
|
// 16:9 aspect ratio = width / height
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.grey[800]!, width: 1),
|
||||||
|
),
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Mjpeg(
|
||||||
|
stream: streamUrl,
|
||||||
|
isLive: true,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
error: (context, error, stackTrace) {
|
||||||
|
return Container(
|
||||||
|
color: Colors.grey[700],
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.videocam_off,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
"${_t('Camera')} $cameraIndex",
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// Camera number circle - top right (with bullet color)
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _bulletColor,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'$cameraIndex',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+109
-15
@@ -748,11 +748,22 @@ List<double> _multiplyMatrices(List<double> a, List<double> b) {
|
|||||||
child: ColorFiltered(
|
child: ColorFiltered(
|
||||||
colorFilter: _buildColorFilter(),
|
colorFilter: _buildColorFilter(),
|
||||||
child: Mjpeg(
|
child: Mjpeg(
|
||||||
|
key: ValueKey(_streamUrl),
|
||||||
stream: _streamUrl!,
|
stream: _streamUrl!,
|
||||||
isLive: true,
|
isLive: true,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
error: (context, error, stackTrace) {
|
error: (context, error, stackTrace) {
|
||||||
return _buildErrorContent();
|
// Mark as error so we can display error placeholder outside filters
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isError = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return SizedBox.expand(
|
||||||
|
child: Container(color: Colors.black),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -845,21 +856,104 @@ List<double> _multiplyMatrices(List<double> a, List<double> b) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildErrorContent() {
|
Widget _buildErrorContent() {
|
||||||
return Column(
|
return Center(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: SingleChildScrollView(
|
||||||
children: [
|
child: Padding(
|
||||||
const Icon(Icons.error, color: Colors.red, size: 100),
|
padding: const EdgeInsets.all(24),
|
||||||
const SizedBox(height: 20),
|
child: Container(
|
||||||
Text(
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||||
_t("Unable to load camera stream"),
|
decoration: BoxDecoration(
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
|
color: Colors.grey[900],
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error, color: _bulletColor, size: 100),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
_t("Unable to load camera stream"),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[800],
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_t("Please ensure:"),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.wifi, color: Colors.white, size: 18),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_t("WiFi is enabled"),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.signal_cellular_off, color: Colors.white, size: 18),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_t("Mobile data is disabled"),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 48,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _loadStreamUrl,
|
||||||
|
icon: const Icon(Icons.refresh, size: 20),
|
||||||
|
label: Text(_t("Reload")),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: _bulletColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
),
|
||||||
ElevatedButton(
|
|
||||||
onPressed: _loadStreamUrl,
|
|
||||||
child: Text(_t("Retry")),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'stream_screen.dart'; // Import the camera stream screen
|
import 'stream_screen.dart'; // Import the camera stream screen
|
||||||
|
import 'spectator_screen.dart';
|
||||||
import '../utils/translations.dart';
|
import '../utils/translations.dart';
|
||||||
|
|
||||||
class TargetScreen extends StatefulWidget {
|
class TargetScreen extends StatefulWidget {
|
||||||
@@ -63,7 +64,6 @@ class _TargetScreenState extends State<TargetScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -72,59 +72,106 @@ class _TargetScreenState extends State<TargetScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: GridView.builder(
|
child: Column(
|
||||||
itemCount: 6,
|
children: [
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
// Camera grid (6 cameras)
|
||||||
crossAxisCount: 2,
|
Expanded(
|
||||||
mainAxisSpacing: 16,
|
child: GridView.builder(
|
||||||
crossAxisSpacing: 16,
|
itemCount: 6,
|
||||||
childAspectRatio: 1, // square-ish buttons
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
),
|
crossAxisCount: 2,
|
||||||
itemBuilder: (context, index) {
|
mainAxisSpacing: 16,
|
||||||
final isEnabled = _cameraButtonsEnabled[index];
|
crossAxisSpacing: 16,
|
||||||
return InkWell(
|
childAspectRatio: 1,
|
||||||
onTap: isEnabled
|
),
|
||||||
? () {
|
itemBuilder: (context, index) {
|
||||||
Navigator.push(
|
final isEnabled = _cameraButtonsEnabled[index];
|
||||||
context,
|
return GestureDetector(
|
||||||
MaterialPageRoute(
|
onTap: isEnabled
|
||||||
builder: (context) =>
|
? () {
|
||||||
CameraStreamScreen(cameraIndex: index + 1),
|
Navigator.push(
|
||||||
),
|
context,
|
||||||
);
|
MaterialPageRoute(
|
||||||
}
|
builder: (context) =>
|
||||||
: null,
|
CameraStreamScreen(cameraIndex: index + 1),
|
||||||
borderRadius: BorderRadius.circular(16),
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
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(
|
||||||
|
"${_t('Camera')} ${index + 1}",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isEnabled ? Colors.white : Colors.grey[600],
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Spectate button (spans full width, half height)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => SpectatorScreen(
|
||||||
|
selectedLanguage: _selectedLanguage,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
|
height: 60,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isEnabled ? Colors.grey[900] : Colors.grey[300],
|
color: Colors.grey[900],
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.videocam_rounded,
|
Icons.visibility_rounded,
|
||||||
color: isEnabled ? Colors.white : Colors.grey[500],
|
color: Colors.white,
|
||||||
size: 36,
|
size: 28,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Text(
|
||||||
"${_t('Camera')} ${index + 1}",
|
_t('Spectate'),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
color:
|
color: Colors.white,
|
||||||
isEnabled ? Colors.white : Colors.grey[600],
|
fontSize: 18,
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -196,7 +196,14 @@ class _TargetViewScreenState extends State<TargetViewScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text('📅 $_date', style: const TextStyle(color: Colors.white)),
|
title: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.calendar_today, size: 20, color: Colors.white),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(_date, style: const TextStyle(color: Colors.white)),
|
||||||
|
],
|
||||||
|
),
|
||||||
backgroundColor: Colors.grey[900],
|
backgroundColor: Colors.grey[900],
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
iconTheme: const IconThemeData(color: Colors.white),
|
iconTheme: const IconThemeData(color: Colors.white),
|
||||||
@@ -377,27 +384,22 @@ class _TargetViewScreenState extends State<TargetViewScreen> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Combined badge with text and score
|
// Combined text with icon and score (no box)
|
||||||
Container(
|
FittedBox(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
fit: BoxFit.scaleDown,
|
||||||
decoration: BoxDecoration(
|
child: Row(
|
||||||
color: Colors.grey[800],
|
children: [
|
||||||
borderRadius: BorderRadius.circular(16),
|
const Icon(Icons.track_changes, size: 28, color: Colors.white),
|
||||||
border: Border.all(
|
const SizedBox(width: 8),
|
||||||
color: Colors.grey[700]!,
|
Text(
|
||||||
width: 2,
|
"${_t('Total score')} $_totalScore",
|
||||||
),
|
style: const TextStyle(
|
||||||
),
|
fontSize: 28,
|
||||||
child: FittedBox(
|
fontWeight: FontWeight.bold,
|
||||||
fit: BoxFit.scaleDown,
|
color: Colors.white,
|
||||||
child: Text(
|
),
|
||||||
"🎯 ${_t('Total score')} $_totalScore",
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -584,6 +584,66 @@ class AppTranslations {
|
|||||||
'English': 'copied to clipboard',
|
'English': 'copied to clipboard',
|
||||||
'Slovenian': 'kopirano v odložišče',
|
'Slovenian': 'kopirano v odložišče',
|
||||||
},
|
},
|
||||||
|
'Select Cameras to Spectate': {
|
||||||
|
'English': 'Select Cameras to Spectate',
|
||||||
|
'Slovenian': 'Izberi kamere za ogled',
|
||||||
|
},
|
||||||
|
'Choose 1-6 cameras': {
|
||||||
|
'English': 'Choose 1-6 cameras',
|
||||||
|
'Slovenian': 'Izberi 1-6 kamer',
|
||||||
|
},
|
||||||
|
'No cameras configured': {
|
||||||
|
'English': 'No cameras configured',
|
||||||
|
'Slovenian': 'Ni nastavljenih kamer',
|
||||||
|
},
|
||||||
|
'View Cameras': {
|
||||||
|
'English': 'View Cameras',
|
||||||
|
'Slovenian': 'Prikaži kamere',
|
||||||
|
},
|
||||||
|
'Multi-Camera View': {
|
||||||
|
'English': 'Multi-Camera View',
|
||||||
|
'Slovenian': 'Več-kamerski pogled',
|
||||||
|
},
|
||||||
|
'Tap to return to grid': {
|
||||||
|
'English': 'Tap to return to grid',
|
||||||
|
'Slovenian': 'Tapni za vrnitev na mrežo',
|
||||||
|
},
|
||||||
|
'Stream unavailable': {
|
||||||
|
'English': 'Stream unavailable',
|
||||||
|
'Slovenian': 'Pretok ni na voljo',
|
||||||
|
},
|
||||||
|
'Select at least one camera': {
|
||||||
|
'English': 'Select at least one camera',
|
||||||
|
'Slovenian': 'Izberi vsaj eno kamero',
|
||||||
|
},
|
||||||
|
'Spectate': {
|
||||||
|
'English': 'Spectate',
|
||||||
|
'Slovenian': 'Opazuj',
|
||||||
|
},
|
||||||
|
'Select Cameras': {
|
||||||
|
'English': 'Select Cameras',
|
||||||
|
'Slovenian': 'Izberi kamere',
|
||||||
|
},
|
||||||
|
'Spectator Mode': {
|
||||||
|
'English': 'Spectator Mode',
|
||||||
|
'Slovenian': 'Način opazovanja',
|
||||||
|
},
|
||||||
|
'Please ensure:': {
|
||||||
|
'English': 'Please ensure:',
|
||||||
|
'Slovenian': 'Prepričaj se:',
|
||||||
|
},
|
||||||
|
'WiFi is enabled': {
|
||||||
|
'English': 'WiFi is enabled',
|
||||||
|
'Slovenian': 'WiFi je vključen',
|
||||||
|
},
|
||||||
|
'Mobile data is disabled': {
|
||||||
|
'English': 'Mobile data is disabled',
|
||||||
|
'Slovenian': 'Mobilni podatki so onemogočeni',
|
||||||
|
},
|
||||||
|
'Reload': {
|
||||||
|
'English': 'Reload',
|
||||||
|
'Slovenian': 'Ponovno naloži',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
static String translate(String key, String language) {
|
static String translate(String key, String language) {
|
||||||
|
|||||||
Reference in New Issue
Block a user