Auto-sync: 20260323_010000

This commit is contained in:
Paolo 2026-03-23 01:00:01 +01:00
parent c3390609c3
commit 6372935e1c
8 changed files with 717 additions and 277 deletions

View file

@ -1,3 +1,3 @@
index.html,1773586765860,5737ce966fa8786becaf7f36a32992cf44102fb3a217c226c30576c993b33e63
404.html,1773344753356,05cbc6f94d7a69ce2e29646eab13be2c884e61ba93e3094df5028866876d18b3
report.html,1773588057140,876c6baaa912c9abfb81ee70e9868d84476b1c204ebca4c99f458f300661a36b
report.html,1774223974711,2848745a7b4437e80aabba9bd776c1c7f90b1be21e67ddaf062c22a21ac99554

View file

@ -9,7 +9,9 @@ class _ClosureResult {
final bool closesSomething;
final int netValue;
final bool causesSwap;
_ClosureResult(this.closesSomething, this.netValue, this.causesSwap);
final bool isIceTrap; // NUOVO: Identifica le mosse suicide sul ghiaccio
_ClosureResult(this.closesSomething, this.netValue, this.causesSwap, this.isIceTrap);
}
class AIEngine {
@ -19,6 +21,7 @@ class AIEngine {
if (availableLines.isEmpty) return board.lines.first;
// Più il livello è alto, più l'IA è "intelligente"
double smartChance = 0.50 + ((level - 1) * 0.10);
if (smartChance > 1.0) smartChance = 1.0;
@ -29,9 +32,16 @@ class AIEngine {
List<Line> goodClosingMoves = [];
List<Line> badClosingMoves = [];
List<Line> iceTraps = []; // Le mosse da evitare assolutamente
for (var line in availableLines) {
var result = _checkClosure(board, line);
if (result.isIceTrap) {
iceTraps.add(line); // Segna la linea come trappola e passa alla prossima
continue;
}
if (result.closesSomething) {
if (result.causesSwap) {
if (myScore < oppScore) {
@ -56,10 +66,10 @@ class AIEngine {
}
}
// --- REGOLA 2: Mosse Sicure ---
// --- REGOLA 2: Mosse Sicure (Ora include le esche del ghiaccio!) ---
List<Line> safeMoves = [];
for (var line in availableLines) {
if (!badClosingMoves.contains(line) && !goodClosingMoves.contains(line) && _isSafeMove(board, line, myScore, oppScore)) {
if (!badClosingMoves.contains(line) && !goodClosingMoves.contains(line) && !iceTraps.contains(line) && _isSafeMove(board, line, myScore, oppScore)) {
safeMoves.add(line);
}
}
@ -76,17 +86,19 @@ class AIEngine {
// --- REGOLA 3: Scegliere il male minore ---
if (beSmart) {
List<Line> riskyButNotTerrible = availableLines.where((l) => !badClosingMoves.contains(l) && !goodClosingMoves.contains(l)).toList();
List<Line> riskyButNotTerrible = availableLines.where((l) => !badClosingMoves.contains(l) && !goodClosingMoves.contains(l) && !iceTraps.contains(l)).toList();
if (riskyButNotTerrible.isNotEmpty) {
return riskyButNotTerrible[random.nextInt(riskyButNotTerrible.length)];
}
}
List<Line> nonTerribleMoves = availableLines.where((l) => !badClosingMoves.contains(l)).toList();
// Ultima spiaggia prima del disastro: qualsiasi cosa tranne bombe e trappole ghiacciate
List<Line> nonTerribleMoves = availableLines.where((l) => !badClosingMoves.contains(l) && !iceTraps.contains(l)).toList();
if (nonTerribleMoves.isNotEmpty) {
return nonTerribleMoves[random.nextInt(nonTerribleMoves.length)];
}
// Se l'IA è messa all'angolo ed è costretta a suicidarsi... pesca a caso
return availableLines[random.nextInt(availableLines.length)];
}
@ -94,6 +106,7 @@ class AIEngine {
int netValue = 0;
bool closesSomething = false;
bool causesSwap = false;
bool isIceTrap = false;
for (var box in board.boxes) {
if (box.type == BoxType.invisible) continue;
@ -106,17 +119,20 @@ class AIEngine {
if (box.right.owner != Player.none || box.right == line) linesCount++;
if (linesCount == 4) {
if (box.type == BoxType.ice && !line.isIceCracked) {
// L'IA capisce che questa mossa non chiuderà il box, ma le farà perdere il turno.
isIceTrap = true;
} else {
closesSomething = true;
// FIX: Togliamo la "vista a raggi X" all'Intelligenza Artificiale!
if (box.hiddenJokerOwner == board.currentPlayer) {
// L'IA conosce il suo Jolly, sa che vale +2 e cercherà di chiuderlo
netValue += 2;
} else {
// Se c'è il Jolly del giocatore, l'IA NON DEVE SAPERLO e valuta la casella normalmente!
if (box.type == BoxType.gold) netValue += 2;
else if (box.type == BoxType.bomb) netValue -= 1;
else if (box.type == BoxType.swap) netValue += 0;
else if (box.type == BoxType.ice) netValue += 0; // Rompere il ghiaccio vale 0 punti, ma fa rigiocare
else if (box.type == BoxType.multiplier) netValue += 1; // Leggero boost per dare priorità al x2
else netValue += 1;
}
@ -124,7 +140,8 @@ class AIEngine {
}
}
}
return _ClosureResult(closesSomething, netValue, causesSwap);
}
return _ClosureResult(closesSomething, netValue, causesSwap, isIceTrap);
}
static bool _isSafeMove(GameBoard board, Line line, int myScore, int oppScore) {
@ -139,21 +156,24 @@ class AIEngine {
if (box.right.owner != Player.none) currentLinesCount++;
if (currentLinesCount == 2) {
// Nuova logica di sicurezza: cosa succede se l'IA lascia questa scatola all'avversario?
// L'IA valuta cosa succede se lascia questa casella con 3 linee all'avversario
int valueForOpponent = 0;
if (box.hiddenJokerOwner == board.currentPlayer) {
// Se l'avversario la chiude, becca la trappola dell'IA (-1).
// Quindi PER L'IA È SICURISSIMO LASCIARE QUESTA CASELLA APERTA!
if (box.type == BoxType.ice) {
// Il ghiaccio è la trappola perfetta. Lasciarlo con 3 linee spingerà l'avversario a incrinarlo e a perdere il turno.
// L'IA valuta questa mossa come SICURISSIMA!
valueForOpponent = -5;
} else if (box.hiddenJokerOwner == board.currentPlayer) {
valueForOpponent = -1;
} else {
if (box.type == BoxType.gold) valueForOpponent = 2;
else if (box.type == BoxType.bomb) valueForOpponent = -1;
else if (box.type == BoxType.swap) valueForOpponent = 0;
else if (box.type == BoxType.multiplier) valueForOpponent = 1;
else valueForOpponent = 1;
}
// Se per l'avversario vale -1 (bomba normale o trappola dell'IA), lasciamogliela!
// Se per l'avversario è una trappola (bomba o ghiaccio), lascia pure la mossa libera
if (valueForOpponent < 0) {
continue;
}
@ -166,7 +186,7 @@ class AIEngine {
}
}
return false;
return false; // La mossa regalerebbe punti, quindi NON è sicura
}
}
}

View file

@ -553,6 +553,7 @@ class GameController extends ChangeNotifier {
if (isOnline && roomCode != null) {
Map<String, dynamic> moveData = {
'id': DateTime.now().millisecondsSinceEpoch,
'x1': line.p1.x, 'y1': line.p1.y, 'x2': line.p2.x, 'y2': line.p2.y,
'player': myPlayer == Player.red ? 'red' : 'blue'
};
@ -609,7 +610,7 @@ class GameController extends ChangeNotifier {
return unlocks;
}
void _saveMatchResult() {
Future<void> _saveMatchResult() async {
if (_hasSavedResult) return;
_hasSavedResult = true;
@ -626,9 +627,9 @@ class GameController extends ChangeNotifier {
String oppName = isHost ? onlineGuestName : onlineHostName;
int myScore = isHost ? board.scoreRed : board.scoreBlue;
int oppScore = isHost ? board.scoreBlue : board.scoreRed;
StorageService.instance.saveMatchToHistory(myName: myRealName, opponent: oppName, myScore: myScore, oppScore: oppScore, isOnline: true);
await StorageService.instance.saveMatchToHistory(myName: myRealName, opponent: oppName, myScore: myScore, oppScore: oppScore, isOnline: true);
if (isWin) StorageService.instance.updateQuestProgress(0, 1);
if (isWin) await StorageService.instance.updateQuestProgress(0, 1);
} else if (isVsCPU) {
int myScore = board.scoreRed; int cpuScore = board.scoreBlue;
@ -636,23 +637,23 @@ class GameController extends ChangeNotifier {
calculatedXP = isWin ? (10 + (cpuLevel * 2)) : (isDraw ? 5 : 2);
if (isWin) {
StorageService.instance.addWin();
StorageService.instance.updateQuestProgress(1, 1);
await StorageService.instance.addWin();
await StorageService.instance.updateQuestProgress(1, 1);
} else if (cpuScore > myScore) {
StorageService.instance.addLoss();
await StorageService.instance.addLoss();
}
StorageService.instance.saveMatchToHistory(myName: myRealName, opponent: "CPU (Liv. $cpuLevel)", myScore: myScore, oppScore: cpuScore, isOnline: false);
await StorageService.instance.saveMatchToHistory(myName: myRealName, opponent: "CPU (Liv. $cpuLevel)", myScore: myScore, oppScore: cpuScore, isOnline: false);
} else {
calculatedXP = 2;
StorageService.instance.saveMatchToHistory(myName: myRealName, opponent: "Ospite (Locale)", myScore: board.scoreRed, oppScore: board.scoreBlue, isOnline: false);
await StorageService.instance.saveMatchToHistory(myName: myRealName, opponent: "Ospite (Locale)", myScore: board.scoreRed, oppScore: board.scoreBlue, isOnline: false);
}
if (board.shape != ArenaShape.classic) {
StorageService.instance.updateQuestProgress(2, 1);
await StorageService.instance.updateQuestProgress(2, 1);
}
lastMatchXP = calculatedXP;
StorageService.instance.addXP(calculatedXP);
await StorageService.instance.addXP(calculatedXP);
int newLevel = StorageService.instance.playerLevel;
if (newLevel > oldLevel) {

View file

@ -12,7 +12,7 @@ class AudioService extends ChangeNotifier {
AudioService._internal();
bool isMuted = false;
final AudioPlayer _sfxPlayer = AudioPlayer();
// Abbiamo rimosso _sfxPlayer perché ora ogni suono crea un player usa e getta
final AudioPlayer _bgmPlayer = AudioPlayer();
AppThemeType _currentTheme = AppThemeType.doodle;
@ -30,7 +30,6 @@ class AudioService extends ChangeNotifier {
if (isMuted) {
await _bgmPlayer.pause();
await _sfxPlayer.stop();
} else {
playBgm(_currentTheme);
}
@ -92,9 +91,11 @@ class AudioService extends ChangeNotifier {
if (file.isNotEmpty) {
try {
await _sfxPlayer.play(AssetSource('audio/sfx/$file'), volume: 1.0);
final player = AudioPlayer(); // Player dedicato
await player.play(AssetSource('audio/sfx/$file'), volume: 1.0);
player.onPlayerComplete.listen((_) => player.dispose());
} catch (e) {
debugPrint("Errore SFX Linea: $file");
debugPrint("Errore SFX Linea: $e");
}
}
}
@ -115,9 +116,11 @@ class AudioService extends ChangeNotifier {
if (file.isNotEmpty) {
try {
await _sfxPlayer.play(AssetSource('audio/sfx/$file'), volume: 1.0);
final player = AudioPlayer(); // Player dedicato
await player.play(AssetSource('audio/sfx/$file'), volume: 1.0);
player.onPlayerComplete.listen((_) => player.dispose());
} catch (e) {
debugPrint("Errore SFX Box: $file");
debugPrint("Errore SFX Box: $e");
}
}
}
@ -125,14 +128,18 @@ class AudioService extends ChangeNotifier {
void playBonusSfx() async {
if (isMuted) return;
try {
await _sfxPlayer.play(AssetSource('audio/sfx/bonus.wav'), volume: 1.0);
final player = AudioPlayer(); // Player dedicato
await player.play(AssetSource('audio/sfx/bonus.wav'), volume: 1.0);
player.onPlayerComplete.listen((_) => player.dispose());
} catch(e) {}
}
void playBombSfx() async {
if (isMuted) return;
try {
await _sfxPlayer.play(AssetSource('audio/sfx/bomb.wav'), volume: 1.0);
final player = AudioPlayer(); // Player dedicato
await player.play(AssetSource('audio/sfx/bomb.wav'), volume: 1.0);
player.onPlayerComplete.listen((_) => player.dispose());
} catch(e) {}
}
}

View file

@ -66,10 +66,7 @@ class StorageService {
// --- SICUREZZA XP: Inviamo solo INCREMENTI al server ---
Future<void> addXP(int xp) async {
// Aggiorniamo il locale per la UI
await _prefs.setInt('totalXP', totalXP + xp);
// Aggiorniamo il server in modo sicuro tramite incremento relativo
final user = FirebaseAuth.instance.currentUser;
if (user != null) {
await FirebaseFirestore.instance.collection('leaderboard').doc(user.uid).set({
@ -83,7 +80,6 @@ class StorageService {
int get wins => _prefs.getInt('wins') ?? 0;
// --- SICUREZZA WINS: Inviamo solo INCREMENTI al server ---
Future<void> addWin() async {
await _prefs.setInt('wins', wins + 1);
final user = FirebaseAuth.instance.currentUser;
@ -96,7 +92,6 @@ class StorageService {
int get losses => _prefs.getInt('losses') ?? 0;
// --- SICUREZZA LOSSES: Inviamo solo INCREMENTI al server ---
Future<void> addLoss() async {
await _prefs.setInt('losses', losses + 1);
final user = FirebaseAuth.instance.currentUser;
@ -116,10 +111,12 @@ class StorageService {
syncLeaderboard();
}
// ======================================================================
// FIX: ORA IL SYNC MANDA I DATI REALI ALLA DASHBOARD ADMIN!
// ======================================================================
Future<void> syncLeaderboard() async {
try {
final user = FirebaseAuth.instance.currentUser;
if (user == null) return;
String name = playerName;
@ -127,12 +124,53 @@ class StorageService {
String targetUid = user.uid;
// --- SICUREZZA: Non inviamo PIÙ i valori assoluti di xp, wins e losses! ---
// Vengono aggiornati solo dagli incrementi protetti nelle funzioni sopra.
// 1. Recupero Versione App e Modello Dispositivo
String appVer = "N/D";
String devModel = "N/D";
String osName = kIsWeb ? "Web" : Platform.operatingSystem;
try {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
appVer = "${packageInfo.version}+${packageInfo.buildNumber}";
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
if (!kIsWeb) {
if (Platform.isAndroid) {
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
devModel = "${androidInfo.brand} ${androidInfo.model}".toUpperCase();
osName = "Android";
} else if (Platform.isIOS) {
IosDeviceInfo iosInfo = await deviceInfo.iosInfo;
devModel = iosInfo.utsname.machine; // Es. "iPhone13,2"
osName = "iOS";
} else if (Platform.isMacOS) {
MacOsDeviceInfo macInfo = await deviceInfo.macOsInfo;
devModel = macInfo.model; // Es. "MacBookPro17,1"
osName = "macOS";
}
}
} catch (e) {
debugPrint("Errore device info: $e");
}
// 2. Calcolo del Playtime effettivo (aggiornato ad ogni sync)
int sessionDurationSec = (DateTime.now().millisecondsSinceEpoch - _sessionStart) ~/ 1000;
int savedPlaytime = _prefs.getInt('total_playtime') ?? 0;
int totalPlaytime = savedPlaytime + sessionDurationSec;
await _prefs.setInt('total_playtime', totalPlaytime);
_sessionStart = DateTime.now().millisecondsSinceEpoch; // Resetta il timer di sessione
// 3. Creazione del payload per Firebase
Map<String, dynamic> dataToSave = {
'name': name,
'level': playerLevel,
'lastActive': FieldValue.serverTimestamp(),
'appVersion': appVer,
'deviceModel': devModel,
'platform': osName,
'ip': lastIp,
'city': lastCity,
'playtime': totalPlaytime,
};
if (user.metadata.creationTime != null) {
@ -184,15 +222,12 @@ class StorageService {
if (today != lastDate) {
_prefs.setString('quest_date', today);
_prefs.setInt('q1_type', 0);
_prefs.setInt('q1_prog', 0);
_prefs.setInt('q1_target', 3);
_prefs.setInt('q2_type', 1);
_prefs.setInt('q2_prog', 0);
_prefs.setInt('q2_target', 2);
_prefs.setInt('q3_type', 2);
_prefs.setInt('q3_prog', 0);
_prefs.setInt('q3_target', 2);

View file

@ -4,6 +4,7 @@
import 'dart:ui';
import 'dart:math';
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter/services.dart';
@ -13,6 +14,9 @@ import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'dart:async';
import 'package:app_links/app_links.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:upgrader/upgrader.dart';
import 'package:in_app_update/in_app_update.dart';
import '../../logic/game_controller.dart';
import '../../core/theme_manager.dart';
@ -58,6 +62,9 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
String? _myRoomCode;
bool _roomStarted = false;
String _appVersion = '';
bool _updateAvailable = false;
final MultiplayerService _multiplayerService = MultiplayerService();
@override
@ -80,6 +87,59 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
_checkClipboardForInvite();
_initDeepLinks();
_listenToFavoritesOnline();
_loadAppVersion();
_checkStoreForUpdate();
}
Future<void> _loadAppVersion() async {
try {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
if (mounted) {
setState(() {
_appVersion = "v. ${packageInfo.version}";
});
}
} catch (e) {
debugPrint("Errore lettura versione: $e");
}
}
Future<void> _checkStoreForUpdate() async {
if (kIsWeb) return;
try {
if (Platform.isAndroid) {
final info = await InAppUpdate.checkForUpdate();
if (info.updateAvailability == UpdateAvailability.updateAvailable) {
if (mounted) setState(() => _updateAvailable = true);
}
} else if (Platform.isIOS || Platform.isMacOS) {
final upgrader = Upgrader();
await upgrader.initialize();
if (upgrader.isUpdateAvailable()) {
if (mounted) setState(() => _updateAvailable = true);
}
}
} catch (e) {
debugPrint("Errore controllo aggiornamenti: $e");
}
}
void _triggerUpdate() async {
if (kIsWeb) return;
if (Platform.isAndroid) {
try {
final info = await InAppUpdate.checkForUpdate();
if (info.updateAvailability == UpdateAvailability.updateAvailable) {
await InAppUpdate.performImmediateUpdate();
}
} catch(e) {
Upgrader().sendUserToAppStore();
}
} else {
Upgrader().sendUserToAppStore();
}
}
void _checkThemeSafety() {
@ -179,11 +239,13 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
if (diffInSeconds.abs() < 180) {
String name = data['name'] ?? 'Un amico';
if (ModalRoute.of(context)?.isCurrent == true) {
_showFavoriteOnlinePopup(name);
}
}
}
}
}
});
}
@ -245,10 +307,13 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
continue;
}
}
if (ModalRoute.of(context)?.isCurrent == true) {
_showInvitePopup(from, code, inviteId);
}
}
}
}
});
}
@ -742,12 +807,120 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
),
Positioned.fill(child: uiContent),
// --- NUMERO DI VERSIONE APP E BADGE AGGIORNAMENTO ---
if (_appVersion.isNotEmpty)
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 10,
left: 20,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Opacity(
opacity: 0.6,
child: Text(
_appVersion,
style: getSharedTextStyle(themeType, TextStyle(
color: themeType == AppThemeType.doodle ? inkColor : theme.text,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 1.0,
)),
),
),
if (_updateAvailable) ...[
const SizedBox(width: 15),
_PulsingUpdateBadge(
themeType: themeType,
theme: theme,
onTap: _triggerUpdate,
),
]
],
),
),
],
),
);
}
}
// --- NUOVO WIDGET: BADGE AGGIORNAMENTO PULSANTE ---
class _PulsingUpdateBadge extends StatefulWidget {
final AppThemeType themeType;
final ThemeColors theme;
final VoidCallback onTap;
const _PulsingUpdateBadge({
required this.themeType,
required this.theme,
required this.onTap,
});
@override
State<_PulsingUpdateBadge> createState() => _PulsingUpdateBadgeState();
}
class _PulsingUpdateBadgeState extends State<_PulsingUpdateBadge> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 800))..repeat(reverse: true);
_scaleAnimation = Tween<double>(begin: 0.95, end: 1.05).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
Color badgeColor = widget.themeType == AppThemeType.doodle ? Colors.red.shade700 : widget.theme.playerRed;
Color textColor = widget.themeType == AppThemeType.doodle ? Colors.white : widget.theme.playerRed;
Color bgColor = widget.themeType == AppThemeType.doodle ? badgeColor : badgeColor.withOpacity(0.15);
return GestureDetector(
onTap: widget.onTap,
child: ScaleTransition(
scale: _scaleAnimation,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: badgeColor, width: 1.5),
boxShadow: widget.themeType == AppThemeType.doodle
? [const BoxShadow(color: Colors.black26, offset: Offset(2, 2))]
: [BoxShadow(color: badgeColor.withOpacity(0.4), blurRadius: 8)],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.system_update_alt, color: textColor, size: 14),
const SizedBox(width: 6),
Text(
"AGGIORNAMENTO DISPONIBILE",
style: getSharedTextStyle(widget.themeType, TextStyle(
color: textColor,
fontSize: 10,
fontWeight: FontWeight.w900,
)),
),
],
),
),
),
);
}
}
// ----------------------------------------------------
class FullScreenGridPainter extends CustomPainter {
final Color gridColor;
FullScreenGridPainter(this.gridColor);

View file

@ -8,6 +8,7 @@ import '../logic/game_controller.dart';
import '../core/theme_manager.dart';
import '../core/app_colors.dart';
import '../services/storage_service.dart';
import 'painters.dart';
class GameOverDialog extends StatelessWidget {
const GameOverDialog({super.key});
@ -18,6 +19,7 @@ class GameOverDialog extends StatelessWidget {
final themeManager = context.read<ThemeManager>();
final theme = themeManager.currentColors;
final themeType = themeManager.currentThemeType;
Color inkColor = const Color(0xFF111122);
int red = game.board.scoreRed;
int blue = game.board.scoreBlue;
@ -51,172 +53,269 @@ class GameOverDialog extends StatelessWidget {
winnerColor = theme.playerBlue;
} else {
winnerText = "PAREGGIO!";
winnerColor = theme.text;
winnerColor = themeType == AppThemeType.doodle ? inkColor : theme.text;
}
return AlertDialog(
backgroundColor: theme.background,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(color: winnerColor.withOpacity(0.5), width: 2),
),
title: Text("FINE PARTITA", textAlign: TextAlign.center, style: TextStyle(color: theme.text, fontWeight: FontWeight.bold, fontSize: 22)),
content: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
Widget dialogContent = Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(winnerText, textAlign: TextAlign.center, style: TextStyle(fontSize: 26, fontWeight: FontWeight.w900, color: winnerColor)),
Text(winnerText, textAlign: TextAlign.center, style: getSharedTextStyle(themeType, TextStyle(fontSize: 26, fontWeight: FontWeight.w900, color: winnerColor))),
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: theme.text.withOpacity(0.05),
color: themeType == AppThemeType.doodle ? Colors.transparent : theme.text.withOpacity(0.05),
borderRadius: BorderRadius.circular(15),
border: themeType == AppThemeType.doodle ? Border.all(color: inkColor.withOpacity(0.3), width: 1.5) : null,
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text("$nameRed: $red", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: theme.playerRed)),
Text(" - ", style: TextStyle(fontSize: 18, color: theme.text)),
Text("$nameBlue: $blue", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: theme.playerBlue)),
Text("$nameRed: $red", style: getSharedTextStyle(themeType, TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: theme.playerRed))),
Text(" - ", style: getSharedTextStyle(themeType, TextStyle(fontSize: 18, color: themeType == AppThemeType.doodle ? inkColor : theme.text))),
Text("$nameBlue: $blue", style: getSharedTextStyle(themeType, TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: theme.playerBlue))),
],
),
),
),
if (game.lastMatchXP > 0) ...[
const SizedBox(height: 15),
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.15),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: themeType == AppThemeType.doodle ? Colors.green.shade700 : Colors.greenAccent, width: 1.5),
boxShadow: (themeType == AppThemeType.cyberpunk || themeType == AppThemeType.music) ? [const BoxShadow(color: Colors.greenAccent, blurRadius: 10, spreadRadius: -5)] : [],
),
child: Text("+ ${game.lastMatchXP} XP", style: getSharedTextStyle(themeType, TextStyle(color: themeType == AppThemeType.doodle ? Colors.green.shade700 : Colors.greenAccent, fontWeight: FontWeight.w900, fontSize: 16, letterSpacing: 1.5))),
),
],
if (game.isVsCPU) ...[
const SizedBox(height: 15),
Text("Difficoltà CPU: Livello ${game.cpuLevel}", style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: theme.text.withOpacity(0.7))),
Text("Difficoltà CPU: Livello ${game.cpuLevel}", style: getSharedTextStyle(themeType, TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: themeType == AppThemeType.doodle ? inkColor.withOpacity(0.7) : theme.text.withOpacity(0.7)))),
],
if (game.isOnline) ...[
const SizedBox(height: 20),
if (game.rematchRequested && !game.opponentWantsRematch)
Text("In attesa di $nameBlue...", style: getSharedTextStyle(themeType, TextStyle(color: themeType == AppThemeType.doodle ? Colors.orange.shade700 : Colors.amber, fontWeight: FontWeight.bold, fontStyle: FontStyle.italic))),
if (game.opponentWantsRematch && !game.rematchRequested)
Text("$nameBlue vuole la rivincita!", style: getSharedTextStyle(themeType, TextStyle(color: themeType == AppThemeType.doodle ? Colors.green.shade700 : Colors.greenAccent, fontWeight: FontWeight.bold))),
if (game.rematchRequested && game.opponentWantsRematch)
Text("Avvio nuova partita...", style: getSharedTextStyle(themeType, TextStyle(color: themeType == AppThemeType.doodle ? Colors.green.shade800 : Colors.green, fontWeight: FontWeight.bold))),
],
// --- SEZIONE LEVEL UP E ROADMAP DINAMICA ---
if (game.hasLeveledUp && game.unlockedRewards.isNotEmpty) ...[
const SizedBox(height: 30),
const Divider(),
Divider(color: themeType == AppThemeType.doodle ? inkColor.withOpacity(0.3) : theme.text.withOpacity(0.2)),
const SizedBox(height: 15),
Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 20),
decoration: BoxDecoration(
color: Colors.amber.withOpacity(0.2),
color: themeType == AppThemeType.doodle ? Colors.amber.withOpacity(0.1) : Colors.amber.withOpacity(0.2),
borderRadius: BorderRadius.circular(30),
border: Border.all(color: Colors.amber, width: 2)
border: Border.all(color: themeType == AppThemeType.doodle ? Colors.amber.shade700 : Colors.amber, width: 2)
),
child: Text("🎉 LIVELLO ${game.newlyReachedLevel}! 🎉", style: const TextStyle(color: Colors.amber, fontWeight: FontWeight.w900, fontSize: 18)),
child: Text("🎉 LIVELLO ${game.newlyReachedLevel}! 🎉", style: getSharedTextStyle(themeType, TextStyle(color: themeType == AppThemeType.doodle ? Colors.amber.shade700 : Colors.amber, fontWeight: FontWeight.w900, fontSize: 18))),
),
const SizedBox(height: 15),
...game.unlockedRewards.map((reward) => Container(
...game.unlockedRewards.map((reward) {
Color rewardColor = themeType == AppThemeType.doodle ? (reward['color'] as Color).withOpacity(0.8) : reward['color'];
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: (reward['color'] as Color).withOpacity(0.1),
color: rewardColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: (reward['color'] as Color).withOpacity(0.5), width: 1.5),
border: Border.all(color: rewardColor.withOpacity(0.5), width: 1.5),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: (reward['color'] as Color).withOpacity(0.2),
color: rewardColor.withOpacity(0.2),
shape: BoxShape.circle,
),
child: Icon(reward['icon'], color: reward['color'], size: 28),
child: Icon(reward['icon'], color: rewardColor, size: 28),
),
const SizedBox(width: 15),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(reward['title'], style: TextStyle(color: reward['color'], fontWeight: FontWeight.w900, fontSize: 16)),
Text(reward['title'], style: getSharedTextStyle(themeType, TextStyle(color: rewardColor, fontWeight: FontWeight.w900, fontSize: 16))),
const SizedBox(height: 4),
Text(reward['desc'], style: TextStyle(color: theme.text.withOpacity(0.9), fontSize: 12, height: 1.3)),
Text(reward['desc'], style: getSharedTextStyle(themeType, TextStyle(color: themeType == AppThemeType.doodle ? inkColor.withOpacity(0.9) : theme.text.withOpacity(0.9), fontSize: 12, height: 1.3))),
],
)
)
]
)
)),
]
// ---------------------------------------------
),
);
}),
],
),
),
actionsPadding: const EdgeInsets.only(left: 20, right: 20, bottom: 20, top: 10),
actionsAlignment: MainAxisAlignment.center,
actions: [
const SizedBox(height: 30),
// --- BOTTONI AZIONE ---
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (playerBeatCPU)
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: winnerColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
elevation: 5,
),
onPressed: () {
_buildPrimaryButton(
"PROSSIMO LIVELLO ➔",
winnerColor,
themeType,
inkColor,
() {
Navigator.pop(context);
game.increaseLevelAndRestart();
},
child: const Text("PROSSIMO LIVELLO ➔", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
)
else if (game.isOnline)
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: winnerColor == theme.text ? theme.playerBlue : winnerColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
elevation: 5,
),
onPressed: () {
Navigator.pop(context);
if (game.board.isGameOver) {
game.requestRematch();
}
},
child: const Text("RIGIOCA ONLINE", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, letterSpacing: 1.5)),
_buildPrimaryButton(
game.opponentWantsRematch ? "ACCETTA RIVINCITA" : "CHIEDI RIVINCITA",
game.rematchRequested ? Colors.grey : (winnerColor == (themeType == AppThemeType.doodle ? inkColor : theme.text) ? theme.playerBlue : winnerColor),
themeType,
inkColor,
game.rematchRequested ? () {} : () => game.requestRematch(),
)
else
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: winnerColor == theme.text ? theme.playerBlue : winnerColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
elevation: 5,
),
onPressed: () {
_buildPrimaryButton(
"RIGIOCA",
winnerColor == (themeType == AppThemeType.doodle ? inkColor : theme.text) ? theme.playerBlue : winnerColor,
themeType,
inkColor,
() {
Navigator.pop(context);
game.startNewGame(game.board.radius, vsCPU: game.isVsCPU);
},
child: const Text("RIGIOCA", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, letterSpacing: 2)),
),
const SizedBox(height: 12),
OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: theme.text,
side: BorderSide(color: theme.text.withOpacity(0.3), width: 2),
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
),
onPressed: () {
_buildSecondaryButton(
"TORNA AL MENU",
themeType,
inkColor,
theme,
() {
if (game.isOnline) {
game.disconnectOnlineGame();
}
Navigator.pop(context);
Navigator.pop(context);
},
child: Text("TORNA AL MENU", style: TextStyle(fontWeight: FontWeight.bold, color: theme.text, fontSize: 14, letterSpacing: 1.5)),
),
],
)
],
);
if (themeType == AppThemeType.doodle) {
dialogContent = Transform.rotate(
angle: 0.015,
child: CustomPaint(
painter: DoodleBackgroundPainter(fillColor: Colors.white.withOpacity(0.95), strokeColor: inkColor, seed: 500),
child: Padding(
padding: const EdgeInsets.all(25.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text("FINE PARTITA", textAlign: TextAlign.center, style: getSharedTextStyle(themeType, TextStyle(fontSize: 22, fontWeight: FontWeight.w900, color: inkColor, letterSpacing: 2))),
const SizedBox(height: 20),
dialogContent,
],
),
),
),
);
} else {
dialogContent = Container(
padding: const EdgeInsets.all(25.0),
decoration: BoxDecoration(
color: theme.background,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: winnerColor.withOpacity(0.5), width: 2),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text("FINE PARTITA", textAlign: TextAlign.center, style: getSharedTextStyle(themeType, TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: theme.text))),
const SizedBox(height: 20),
dialogContent,
],
),
);
}
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
child: dialogContent,
);
}
Widget _buildPrimaryButton(String label, Color color, AppThemeType themeType, Color inkColor, VoidCallback onTap) {
if (themeType == AppThemeType.doodle) {
return GestureDetector(
onTap: onTap,
child: CustomPaint(
painter: DoodleBackgroundPainter(fillColor: color, strokeColor: inkColor, seed: label.length * 7),
child: Container(
height: 55,
alignment: Alignment.center,
child: Text(label, style: getSharedTextStyle(themeType, const TextStyle(fontSize: 16, fontWeight: FontWeight.w900, color: Colors.white, letterSpacing: 1.5))),
),
),
);
}
return ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: color,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
elevation: 5,
),
onPressed: onTap,
child: Text(label, style: getSharedTextStyle(themeType, const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, letterSpacing: 1.5))),
);
}
Widget _buildSecondaryButton(String label, AppThemeType themeType, Color inkColor, ThemeColors theme, VoidCallback onTap) {
if (themeType == AppThemeType.doodle) {
return GestureDetector(
onTap: onTap,
child: CustomPaint(
painter: DoodleBackgroundPainter(fillColor: Colors.transparent, strokeColor: inkColor.withOpacity(0.5), seed: label.length * 3),
child: Container(
height: 55,
alignment: Alignment.center,
child: Text(label, style: getSharedTextStyle(themeType, TextStyle(fontSize: 14, fontWeight: FontWeight.w900, color: inkColor, letterSpacing: 1.5))),
),
),
);
}
return OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: theme.text,
side: BorderSide(color: theme.text.withOpacity(0.3), width: 2),
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
),
onPressed: onTap,
child: Text(label, style: getSharedTextStyle(themeType, TextStyle(fontWeight: FontWeight.bold, color: theme.text, fontSize: 14, letterSpacing: 1.5))),
);
}
}

View file

@ -3,65 +3,140 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Report Statistiche TetraQ</title>
<title>Report Giocatori - TetraQ</title>
<script defer src="/__/firebase/10.8.0/firebase-app-compat.js"></script>
<script defer src="/__/firebase/10.8.0/firebase-firestore-compat.js"></script>
<script defer src="/__/firebase/init.js"></script>
<style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f4f7f6; color: #333; padding: 20px; margin: 0; box-sizing: border-box;}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: #f8f9fa;
color: #2c3e50;
padding: 20px;
margin: 0;
box-sizing: border-box;
}
/* STILI LOGIN */
#login-view { background-color: #34495e; position: fixed; top: 0; left: 0; width: 100%; height: 100vh; display: flex; justify-content: center; align-items: center; z-index: 1000; padding: 20px; box-sizing: border-box; }
.login-box { background: white; padding: 40px; border-radius: 10px; box-shadow: 0 4px 15px rgba(0,0,0,0.2); text-align: center; width: 100%; max-width: 350px; box-sizing: border-box; }
.login-box h2 { color: #2c3e50; margin-top: 0; }
.login-box input[type="text"], .login-box input[type="password"] { width: 100%; padding: 12px; margin: 10px 0 20px 0; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 16px; }
.login-box button { width: 100%; background-color: #3498db; color: white; border: none; padding: 12px; font-size: 16px; border-radius: 5px; cursor: pointer; font-weight: bold; transition: 0.2s; }
.login-box button:hover { background-color: #2980b9; }
/* --- STILI LOGIN --- */
#login-view {
background-color: #34495e;
position: fixed;
top: 0; left: 0; width: 100%; height: 100vh;
display: flex; justify-content: center; align-items: center;
z-index: 1000; padding: 20px; box-sizing: border-box;
}
.login-box {
background: white; padding: 40px; border-radius: 12px;
box-shadow: 0 10px 25px rgba(0,0,0,0.1); text-align: center;
width: 100%; max-width: 350px; box-sizing: border-box;
}
.login-box h2 { color: #2c3e50; margin-top: 0; font-weight: 800; letter-spacing: 1px;}
.login-box input {
width: 100%; padding: 12px; margin: 10px 0 20px 0;
border: 1px solid #e1e5eb; border-radius: 8px; box-sizing: border-box;
font-size: 16px; outline: none; transition: border-color 0.2s;
}
.login-box input:focus { border-color: #3498db; }
.login-box button {
width: 100%; background-color: #e74c3c; color: white;
border: none; padding: 12px; font-size: 16px; border-radius: 8px;
cursor: pointer; font-weight: bold; transition: background-color 0.2s;
}
.login-box button:hover { background-color: #c0392b; }
.error { color: #e74c3c; font-weight: bold; font-size: 14px; margin-top: -5px; margin-bottom: 15px; }
/* STILI DASHBOARD */
/* --- STILI DASHBOARD --- */
#dashboard-view { display: none; }
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 30px; border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); box-sizing: border-box;}
.container {
max-width: 1200px; margin: 0 auto; background: white;
padding: 30px; border-radius: 10px; box-shadow: 0 4px 15px rgba(0,0,0,0.05);
box-sizing: border-box;
}
.header-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
h1 { color: #2c3e50; margin: 0; font-size: 24px;}
.btn-logout { background-color: #e74c3c; color: white; padding: 8px 15px; border-radius: 5px; text-decoration: none; font-weight: bold; font-size: 14px; white-space: nowrap; border: none; cursor: pointer;}
.btn-logout:hover { background-color: #c0392b; }
h1 { color: #2c3e50; margin: 0; font-size: 26px; font-weight: 800;}
.btn-logout {
background-color: transparent; color: #95a5a6; padding: 8px 15px;
border-radius: 5px; font-weight: bold; font-size: 14px;
border: 1px solid #ecf0f1; cursor: pointer; transition: 0.2s;
}
.btn-logout:hover { background-color: #f9f9f9; color: #e74c3c; border-color: #e74c3c; }
.filter-section { background: #eef2f5; padding: 20px; border-radius: 8px; margin-bottom: 30px; display: flex; flex-wrap: wrap; gap: 15px; align-items: flex-end; }
/* --- FILTRI --- */
.filter-section {
background: #f8f9fa; padding: 20px; border-radius: 8px; margin-bottom: 30px;
display: flex; flex-wrap: wrap; gap: 15px; align-items: flex-end;
border: 1px solid #e1e5eb;
}
.form-group { display: flex; flex-direction: column; flex: 1; min-width: 150px; }
.form-group label { font-size: 13px; font-weight: bold; color: #34495e; margin-bottom: 5px; }
.form-group input, .form-group select { padding: 10px; border: 1px solid #bdc3c7; border-radius: 5px; font-size: 14px; outline: none; box-sizing: border-box; width: 100%;}
.form-group label { font-size: 12px; font-weight: bold; color: #7f8c8d; margin-bottom: 5px; text-transform: uppercase; letter-spacing: 0.5px;}
.form-group input, .form-group select {
padding: 10px; border: 1px solid #bdc3c7; border-radius: 6px;
font-size: 14px; outline: none; box-sizing: border-box; width: 100%;
}
.form-group input:focus, .form-group select:focus { border-color: #3498db; }
.btn-filtra { background-color: #3498db; color: white; padding: 10px 20px; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; font-size: 14px; transition: 0.2s; height: 40px; }
.btn-filtra {
background-color: #3498db; color: white; padding: 10px 20px; border: none;
border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 14px;
transition: 0.2s; height: 40px;
}
.btn-filtra:hover { background-color: #2980b9; }
.btn-reset { background-color: #95a5a6; color: white; padding: 10px 15px; border: none; border-radius: 5px; cursor: pointer; text-decoration: none; font-size: 14px; height: 40px; line-height: 20px; box-sizing: border-box; display: inline-block; text-align: center;}
.btn-reset {
background-color: #95a5a6; color: white; padding: 0 15px; border: none;
border-radius: 6px; cursor: pointer; font-size: 14px; height: 40px;
line-height: 40px; text-align: center;
}
.btn-reset:hover { background-color: #7f8c8d; }
/* --- CARDS RIASSUNTIVE --- */
.dashboard { display: flex; justify-content: space-between; margin-bottom: 30px; gap: 20px; text-align: center; flex-wrap: wrap; }
.card { flex: 1; min-width: 150px; background: #ecf0f1; padding: 20px; border-radius: 8px; border-left: 5px solid #3498db; box-sizing: border-box;}
.card {
flex: 1; min-width: 150px; background: white; padding: 20px;
border-radius: 8px; border-left: 5px solid #3498db; box-sizing: border-box;
box-shadow: 0 2px 8px rgba(0,0,0,0.05); border: 1px solid #e1e5eb; border-left-width: 5px;
}
.card.ios { border-left-color: #e74c3c; }
.card.android { border-left-color: #2ecc71; }
.card h3 { margin: 0 0 10px 0; font-size: 14px; color: #7f8c8d; text-transform: uppercase; }
.card p { margin: 0; font-size: 28px; font-weight: bold; color: #2c3e50; }
.card h3 { margin: 0 0 10px 0; font-size: 12px; color: #7f8c8d; text-transform: uppercase; letter-spacing: 0.5px;}
.card p { margin: 0; font-size: 28px; font-weight: 800; color: #2c3e50; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 14px; table-layout: fixed; }
th, td { padding: 12px 15px; text-align: left; border-bottom: 1px solid #ddd; word-wrap: break-word;}
th { background-color: #34495e; color: white; }
tr:hover { background-color: #f1f1f1; }
/* --- TABELLA --- */
table { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 14px; table-layout: fixed; }
th, td { padding: 16px 20px; text-align: left; vertical-align: top;}
th {
background-color: #34495e; color: white; font-weight: 700;
font-size: 13px; letter-spacing: 0.5px;
}
th:first-child { border-top-left-radius: 6px; border-bottom-left-radius: 6px; }
th:last-child { border-top-right-radius: 6px; border-bottom-right-radius: 6px; }
td { border-bottom: 1px solid #ecf0f1; color: #2c3e50; }
tr:hover td { background-color: #fbfcfc; }
/* Larghezze specifiche per evitare colonne deformate */
th:nth-child(1) { width: 15%; }
th:nth-child(2) { width: 25%; }
th:nth-child(3) { width: 15%; }
th:nth-child(4) { width: 20%; }
th:nth-child(5) { width: 25%; }
th:nth-child(1) { width: 15%; } /* Accesso */
th:nth-child(2) { width: 25%; } /* Giocatore */
th:nth-child(3) { width: 15%; } /* Statistiche (Nuova) */
th:nth-child(4) { width: 25%; } /* Connessione */
th:nth-child(5) { width: 20%; } /* Dispositivo */
.badge { padding: 4px 8px; border-radius: 4px; color: white; font-weight: bold; font-size: 12px; }
/* Tipografia Specifica Tabella */
.player-name { font-weight: 800; color: #2c3e50; font-size: 15px; }
.player-level { font-weight: normal; color: #e74c3c; } /* Rosso come nell'app */
.sub-text { display: block; font-size: 12px; color: #95a5a6; margin-top: 4px; font-weight: 500; }
.data-text { font-weight: 500; color: #2c3e50; font-size: 14px; }
.stat-value { font-weight: bold; color: #3498db; }
/* Badge Sistema Operativo */
.badge {
padding: 4px 8px; border-radius: 4px; color: white;
font-weight: 800; font-size: 11px; text-transform: uppercase;
letter-spacing: 0.5px; display: inline-block; margin-bottom: 5px;
}
.badge-ios { background-color: #e74c3c; }
.badge-android { background-color: #2ecc71; }
.badge-desktop { background-color: #95a5a6; }
.empty { text-align: center; padding: 30px; color: #7f8c8d; font-style: italic; background: #f9f9f9; border-radius: 5px;}
.empty { text-align: center; padding: 40px; color: #95a5a6; font-weight: 500; }
/* MOBILE */
@media (max-width: 768px) {
@ -89,9 +164,9 @@
<h2>Area Riservata</h2>
<div id="login-error" class="error"></div>
<form id="login-form">
<input type="text" id="username" name="username" placeholder="Nome Utente (io)" autocomplete="username" required>
<input type="password" id="password" name="password" placeholder="Inserisci la password" autocomplete="current-password" required>
<button type="submit">Accedi</button>
<input type="text" id="username" autocomplete="username" required>
<input type="password" id="password" placeholder="Password" autocomplete="current-password" required>
<button type="submit">Accedi al Database</button>
</form>
</div>
</div>
@ -99,17 +174,17 @@
<div id="dashboard-view">
<div class="container">
<div class="header-top">
<h1>📊 Report Statistiche TetraQ</h1>
<button class="btn-logout" onclick="logout()">Esci 🚪</button>
<h1>Dettaglio Giocatori</h1>
<button class="btn-logout" onclick="logout()">Disconnetti</button>
</div>
<form class="filter-section" id="filter-form">
<div class="form-group">
<label for="data_da">Data Da:</label>
<label for="data_da">Ultimo Accesso Da:</label>
<input type="date" id="data_da">
</div>
<div class="form-group">
<label for="data_a">Data A:</label>
<label for="data_a">Ultimo Accesso A:</label>
<input type="date" id="data_a">
</div>
<div class="form-group">
@ -122,16 +197,16 @@
</select>
</div>
<div class="form-group">
<label for="loc">Località (es. Roma):</label>
<input type="text" id="loc" placeholder="Cerca città...">
<label for="loc">Cerca Giocatore/Città:</label>
<input type="text" id="loc" placeholder="Digita...">
</div>
<button type="submit" class="btn-filtra">🔍 Applica Filtri</button>
<button type="button" class="btn-reset" onclick="resetFilters()">Reset</button>
<button type="submit" class="btn-filtra">🔍 Applica</button>
<button type="button" class="btn-reset" onclick="resetFilters()">Reset</button>
</form>
<div class="dashboard">
<div class="card">
<h3>Giocatori Trovati</h3>
<h3>Totale Giocatori</h3>
<p id="totale-text">0</p>
</div>
<div class="card ios">
@ -143,35 +218,54 @@
<p id="android-text">0</p>
</div>
<div class="card">
<h3>Desktop / Altro</h3>
<h3>Desktop / Mac</h3>
<p id="desktop-text">0</p>
</div>
</div>
<h2>Dettaglio Giocatori</h2>
<table>
<thead>
<tr>
<th>Ultimo Accesso</th>
<th>Giocatore (Livello)</th>
<th>Sistema</th>
<th>Località (IP)</th>
<th>Dispositivo Hardware</th>
<th>Giocatore</th>
<th>Statistiche</th>
<th>Connessione</th>
<th>Dispositivo</th>
</tr>
</thead>
<tbody id="table-body">
<tr><td colspan="5" class="empty">Caricamento dati dal database...</td></tr>
<tr><td colspan="5" class="empty">Caricamento dati dal database in corso...</td></tr>
</tbody>
</table>
</div>
</div>
<script>
const PASSWORD_SEGRETA = "!!TetraQ!!"; // La tua password sicura
const UTENTE_SEGRETO = "io"; // Il nome utente richiesto
// --- CONFIGURAZIONE SICUREZZA ---
const PASSWORD_SEGRETA = "!!TetraQ!!";
const UTENTE_SEGRETO = "io";
let allData = [];
// GESTIONE LOGIN
// --- FUNZIONI DI UTILITÀ ---
function formatTime(seconds) {
if (!seconds || seconds <= 0) return "00:00";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h.toString().padStart(2, '0')}h ${m.toString().padStart(2, '0')}m`;
}
function formatDate(dateObj) {
if (!dateObj) return "N/D";
const options = { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' };
return dateObj.toLocaleDateString('it-IT', options).replace(',', '');
}
function formatDateShort(dateObj) {
if (!dateObj) return "N/D";
return dateObj.toLocaleDateString('it-IT', { year: 'numeric', month: '2-digit', day: '2-digit' });
}
// --- GESTIONE LOGIN ---
document.getElementById('login-form').addEventListener('submit', function(e) {
e.preventDefault();
const user = document.getElementById('username').value.trim().toLowerCase();
@ -195,29 +289,39 @@
allData = [];
}
// CONNESSIONE A FIREBASE E RECUPERO DATI
// --- CONNESSIONE A FIREBASE ---
function loadFirebaseData() {
const db = firebase.firestore();
db.collection('leaderboard').orderBy('lastActive', 'desc').get().then((snapshot) => {
db.collection('leaderboard').orderBy('lastActive', 'desc').onSnapshot((snapshot) => {
allData = [];
snapshot.forEach(doc => {
let data = doc.data();
// Gestione Date Firebase
if (data.lastActive) {
data.dateObj = data.lastActive.toDate();
data.dateStr = data.dateObj.toISOString().substring(0, 10);
} else {
data.dateStr = "2000-01-01";
}
if (data.accountCreated) {
data.createdObj = data.accountCreated.toDate();
}
// Nascondi account sviluppatore se necessario
if ((data.name || '').toUpperCase() !== 'PIPPO') {
allData.push(data);
}
});
applyFilters();
}).catch(error => {
}, error => {
console.error("Errore lettura database:", error);
document.getElementById('table-body').innerHTML = '<tr><td colspan="5" class="empty" style="color:red;">Errore di connessione a Firebase.</td></tr>';
document.getElementById('table-body').innerHTML = '<tr><td colspan="5" class="empty" style="color:#e74c3c;">Errore di connessione a Firebase. Riprova.</td></tr>';
});
}
// GESTIONE FILTRI
// --- GESTIONE FILTRI ---
document.getElementById('filter-form').addEventListener('submit', function(e) {
e.preventDefault();
applyFilters();
@ -245,52 +349,53 @@
let platform = row.platform || 'Sconosciuta';
let city = (row.city || '').toLowerCase();
let ip = row.ip || 'N/D';
let name = row.name || 'Sconosciuto';
let level = row.level || 1;
let device = row.deviceModel || 'N/D';
let appVersion = row.appVersion || 'N/D';
let name = (row.name || 'Sconosciuto').toLowerCase();
// Formattazione data precisa e sicura
let dateDisplay = row.dateObj ? row.dateObj.toLocaleString('it-IT', {
year: 'numeric', month: 'short', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false
}) : 'N/D';
// Filtro Data Da
// Filtri Logica
if (fDa !== '' && row.dateStr < fDa) mostra = false;
// Filtro Data A
if (fA !== '' && row.dateStr > fA) mostra = false;
// Filtro Sistema Operativo
if (fOs !== '') {
if (fOs === 'Desktop' && (platform === 'iOS' || platform === 'Android')) mostra = false;
if (fOs !== 'Desktop' && platform !== fOs) mostra = false;
}
// Filtro Località
if (fLoc !== '' && !city.includes(fLoc)) mostra = false;
if (fLoc !== '' && !city.includes(fLoc) && !name.includes(fLoc)) mostra = false;
if (mostra) {
tot++;
let badgeClass = 'badge-desktop';
let platformDisplay = platform;
if (platform === 'iOS') { ios++; badgeClass = 'badge-ios'; }
if (platform === 'iOS' || platform === 'macOS') { ios++; badgeClass = 'badge-ios'; }
else if (platform === 'Android') { android++; badgeClass = 'badge-android'; }
else { desktop++; platformDisplay = 'Desktop'; }
// Costruzione Riga Tabella
html += `
<tr>
<td data-label="Ultimo Accesso">${dateDisplay}</td>
<td data-label="Ultimo Accesso">
<span class="data-text">${formatDate(row.dateObj)}</span>
</td>
<td data-label="Giocatore">
<strong>${name}</strong> (Liv. ${level})<br>
<small style="color:#7f8c8d;">App v. ${appVersion}</small>
<span class="player-name">${(row.name || 'GUEST').toUpperCase()}</span>
<span class="player-level">(Liv. ${row.level || 1})</span>
<span class="sub-text">Iscritto il: ${formatDateShort(row.createdObj)}</span>
</td>
<td data-label="Sistema"><span class="badge ${badgeClass}">${platformDisplay}</span></td>
<td data-label="Località">
${row.city || 'N/D'}<br>
<small style="color:#7f8c8d;">IP: ${ip}</small>
<td data-label="Statistiche">
<span class="sub-text">XP: <span class="stat-value">${row.xp || 0}</span></span>
<span class="sub-text">Vittorie: <span class="stat-value" style="color:#2ecc71;">${row.wins || 0}</span></span>
<span class="sub-text">Tempo: <span class="stat-value" style="color:#2c3e50;">${formatTime(row.playtime)}</span></span>
</td>
<td data-label="Connessione">
<span class="badge ${badgeClass}">${platformDisplay}</span>
<span class="loc-text" style="display:block; margin-top:2px;">${row.city || 'N/D'}</span>
<span class="sub-text">IP: ${row.ip || 'N/D'}</span>
</td>
<td data-label="Dispositivo" style="font-size: 13px; color: #7f8c8d;">
<strong>${row.deviceModel || 'N/D'}</strong>
<span class="sub-text">App v. ${row.appVersion || 'N/D'}</span>
</td>
<td data-label="Dispositivo Hardware" style="font-size: 12px; color: #7f8c8d;">${device}</td>
</tr>
`;
}