Auto-sync: 20260324_140000

This commit is contained in:
Paolo 2026-03-24 14:00:01 +01:00
parent 1395fc32f6
commit 027c41a75c
22 changed files with 166301 additions and 76 deletions

View file

@ -549,7 +549,7 @@ class GameController extends ChangeNotifier {
if (!forced) _playEffects(newClosed, newGhosts: newGhosts, isOpponent: false);
_startTimer(); notifyListeners();
notifyListeners(); // Modificato per non riavviare ciecamente il timer
if (isOnline && roomCode != null) {
Map<String, dynamic> moveData = {
@ -567,17 +567,27 @@ class GameController extends ChangeNotifier {
if (board.isGameOver) {
_saveMatchResult();
if (isHost) FirebaseFirestore.instance.collection('games').doc(roomCode).update({'status': 'finished'});
} else {
_startTimer(); // Rimesso il timer se si continua a giocare online
}
} else {
if (board.isGameOver) _saveMatchResult();
else if (isVsCPU && board.currentPlayer == Player.blue) _checkCPUTurn();
if (board.isGameOver) {
_saveMatchResult();
} else if (isVsCPU && board.currentPlayer == Player.blue) {
_checkCPUTurn(); // Se tocca alla CPU, la CPU fermerà il timer internamente
} else {
_startTimer(); // Se tocca all'umano, facciamo (ri)partire il timer!
}
}
}
}
void _checkCPUTurn() async {
if (isVsCPU && board.currentPlayer == Player.blue && !board.isGameOver) {
isCPUThinking = true; _blitzTimer?.cancel(); notifyListeners();
isCPUThinking = true;
_blitzTimer?.cancel(); // La CPU inizia a pensare, congela il timer del giocatore
notifyListeners();
await Future.delayed(const Duration(milliseconds: 600));
if (!board.isGameOver) {
@ -592,10 +602,18 @@ class GameController extends ChangeNotifier {
_playEffects(newClosed, newGhosts: newGhosts, isOpponent: true);
isCPUThinking = false; _startTimer(); notifyListeners();
isCPUThinking = false;
notifyListeners();
if (board.isGameOver) _saveMatchResult();
else _checkCPUTurn();
if (board.isGameOver) {
_saveMatchResult();
} else if (board.currentPlayer == Player.blue) {
// La CPU ha chiuso un quadrato e ha diritto a un'altra mossa
_checkCPUTurn();
} else {
// Turno passato all'umano: il timer riparte!
_startTimer();
}
}
}
}

View file

@ -4,6 +4,7 @@
import 'dart:convert';
import 'dart:io' show Platform, HttpClient;
import 'dart:async'; // <--- AGGIUNTO PER IL TIMER DELL'HEARTBEAT
import 'package:shared_preferences/shared_preferences.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import '../core/app_colors.dart';
@ -18,6 +19,7 @@ class StorageService {
late SharedPreferences _prefs;
int _sessionStart = 0;
Timer? _heartbeatTimer; // <--- IL NOSTRO BATTITO CARDIACO
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
@ -26,6 +28,20 @@ class StorageService {
_sessionStart = DateTime.now().millisecondsSinceEpoch;
}
// --- NUOVI METODI PER GESTIRE LA PRESENZA ---
void startHeartbeat() {
_heartbeatTimer?.cancel();
// Esegue il sync leggero ogni 60 secondi
_heartbeatTimer = Timer.periodic(const Duration(seconds: 120), (_) {
syncLeaderboard(isHeartbeat: true);
});
}
void stopHeartbeat() {
_heartbeatTimer?.cancel();
}
// ----------------------------------------------
Future<void> _fetchLocationData() async {
if (kIsWeb) return;
try {
@ -112,9 +128,9 @@ class StorageService {
}
// ======================================================================
// FIX: ORA IL SYNC MANDA I DATI REALI ALLA DASHBOARD ADMIN!
// LOGICA SYNC AGGIORNATA: GESTIONE HEARTBEAT LEGGERO
// ======================================================================
Future<void> syncLeaderboard() async {
Future<void> syncLeaderboard({bool isHeartbeat = false}) async {
try {
final user = FirebaseAuth.instance.currentUser;
if (user == null) return;
@ -124,7 +140,23 @@ class StorageService {
String targetUid = user.uid;
// 1. Recupero Versione App e Modello Dispositivo
// 1. 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
// 2. Creazione del payload di base (dati leggeri che cambiano spesso)
Map<String, dynamic> dataToSave = {
'name': name,
'level': playerLevel,
'lastActive': FieldValue.serverTimestamp(),
'playtime': totalPlaytime,
};
// 3. Se NON è un heartbeat, raccogliamo anche i dati "pesanti" (Device info, ecc.)
if (!isHeartbeat) {
String appVer = "N/D";
String devModel = "N/D";
String osName = kIsWeb ? "Web" : Platform.operatingSystem;
@ -153,29 +185,16 @@ class StorageService {
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,
};
dataToSave['appVersion'] = appVer;
dataToSave['deviceModel'] = devModel;
dataToSave['platform'] = osName;
dataToSave['ip'] = lastIp;
dataToSave['city'] = lastCity;
if (user.metadata.creationTime != null) {
dataToSave['accountCreated'] = Timestamp.fromDate(user.metadata.creationTime!);
}
}
await FirebaseFirestore.instance.collection('leaderboard').doc(targetUid).set(dataToSave, SetOptions(merge: true));

View file

@ -57,6 +57,60 @@ class _GameScreenState extends State<GameScreen> with TickerProviderStateMixin {
@override
void dispose() { _blinkController.dispose(); super.dispose(); }
// --- NUOVO DIALOG: CONFERMA USCITA (ANTI-FUGA) ---
void _showExitConfirmationDialog(BuildContext context, GameController gameController, ThemeColors theme, AppThemeType themeType) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: theme.background,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(color: theme.playerRed, width: 2),
),
title: Row(
children: [
Icon(Icons.warning_amber_rounded, color: theme.playerRed),
const SizedBox(width: 10),
Expanded(
child: Text(
"ABBANDONARE?",
style: _getTextStyle(themeType, TextStyle(color: theme.playerRed, fontWeight: FontWeight.bold, fontSize: 18))
)
),
],
),
content: Text(
"Se esci ora, la partita verrà registrata automaticamente come una SCONFITTA.\n\nSei sicuro di voler fuggire?",
style: _getTextStyle(themeType, TextStyle(color: theme.text, fontSize: 15, height: 1.4)),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text("ANNULLA", style: _getTextStyle(themeType, TextStyle(color: theme.text.withOpacity(0.6), fontWeight: FontWeight.bold))),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: theme.playerRed,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: () {
// 1. Assegna la sconfitta!
StorageService.instance.addLoss();
// 2. Disconnette e pulisce
gameController.disconnectOnlineGame();
// 3. Chiude il dialog
Navigator.pop(ctx);
// 4. Torna al menu
Navigator.pop(context);
},
child: Text("SÌ, ESCI", style: _getTextStyle(themeType, const TextStyle(fontWeight: FontWeight.bold))),
),
],
),
);
}
void _showGameOverDialog(BuildContext context, GameController game, ThemeColors theme, AppThemeType themeType) {
_gameOverDialogShown = true;
@ -406,7 +460,18 @@ class _GameScreenState extends State<GameScreen> with TickerProviderStateMixin {
child: TextButton.icon(
style: TextButton.styleFrom(backgroundColor: bgImage != null || themeType == AppThemeType.arcade ? Colors.black87 : theme.background, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20), side: BorderSide(color: Colors.white.withOpacity(0.1), width: 1))),
icon: Icon(Icons.exit_to_app, color: bgImage != null || themeType == AppThemeType.arcade ? Colors.white : theme.text, size: 20),
onPressed: () { gameController.disconnectOnlineGame(); Navigator.pop(context); },
// --- NUOVO ON PRESSED ANTI-FUGA ---
onPressed: () {
if (!gameController.isGameOver && !gameController.isSetupPhase) {
_showExitConfirmationDialog(context, gameController, theme, themeType);
} else {
gameController.disconnectOnlineGame();
Navigator.pop(context);
}
},
// ------------------------
label: Text("ESCI", style: _getTextStyle(themeType, TextStyle(color: bgImage != null || themeType == AppThemeType.arcade ? Colors.white : theme.text, fontWeight: FontWeight.bold, fontSize: 12))),
),
),
@ -424,9 +489,20 @@ class _GameScreenState extends State<GameScreen> with TickerProviderStateMixin {
),
);
// --- NUOVA LOGICA: Impedisce la chiusura accidentale o la fuga (Tasto Indietro) ---
bool shouldConfirmExit = !gameController.isGameOver && !gameController.isSetupPhase;
return PopScope(
canPop: true,
onPopInvoked: (didPop) { gameController.disconnectOnlineGame(); },
canPop: !shouldConfirmExit,
onPopInvoked: (didPop) {
if (didPop) {
gameController.disconnectOnlineGame();
return;
}
if (shouldConfirmExit) {
_showExitConfirmationDialog(context, gameController, theme, themeType);
}
},
child: Scaffold(
backgroundColor: themeType == AppThemeType.doodle ? Colors.white : (bgImage != null ? Colors.transparent : theme.background),
body: Stack(

View file

@ -71,6 +71,10 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// --- AVVIA IL BATTITO CARDIACO ---
StorageService.instance.startHeartbeat();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (StorageService.instance.playerName.isEmpty) {
HomeModals.showNameDialog(context, () {
@ -105,7 +109,6 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
}
Future<void> _checkStoreForUpdate() async {
if (kIsWeb) return;
try {
if (Platform.isAndroid) {
@ -123,7 +126,6 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
} catch (e) {
debugPrint("Errore controllo aggiornamenti: $e");
}
}
void _triggerUpdate() async {
@ -153,6 +155,7 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
StorageService.instance.stopHeartbeat(); // <-- Assicurati di fermarlo
_cleanupGhostRoom();
_linkSubscription?.cancel();
_favoritesSubscription?.cancel();
@ -163,9 +166,19 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
// --- L'UTENTE TORNA NELL'APP: RIPRENDI IL BATTITO E AGGIORNA SUBITO ---
StorageService.instance.syncLeaderboard();
StorageService.instance.startHeartbeat();
_checkClipboardForInvite();
_listenToFavoritesOnline();
} else if (state == AppLifecycleState.detached) {
}
else if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) {
// --- L'UTENTE ESCE DALL'APP O LA METTE IN BACKGROUND: FERMA IL BATTITO ---
StorageService.instance.stopHeartbeat();
}
else if (state == AppLifecycleState.detached) {
StorageService.instance.stopHeartbeat();
_cleanupGhostRoom();
}
}
@ -252,8 +265,9 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
void _showFavoriteOnlinePopup(String name) {
if (!mounted) return;
// Se lo abbiamo già notificato nell'ultima ora, ignoriamo l'aggiornamento
if (_lastOnlineNotifications.containsKey(name)) {
if (DateTime.now().difference(_lastOnlineNotifications[name]!).inMinutes < 1) return;
if (DateTime.now().difference(_lastOnlineNotifications[name]!).inMinutes < 60) return;
}
_lastOnlineNotifications[name] = DateTime.now();

View file

@ -186,35 +186,93 @@ class _LobbyScreenState extends State<LobbyScreen> with WidgetsBindingObserver {
borderRadius: BorderRadius.circular(10)
),
child: favs.isEmpty
? Center(child: Padding(
? Center(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Text("Non hai ancora aggiunto nessun preferito dalla Classifica!", textAlign: TextAlign.center, style: getLobbyTextStyle(themeType, TextStyle(color: theme.text.withOpacity(0.6)))),
))
: ListView.builder(
)
)
: StreamBuilder<QuerySnapshot>(
// Interroghiamo Firebase solo per gli UID dei nostri preferiti (max 10 per limiti di Firestore)
stream: FirebaseFirestore.instance.collection('leaderboard')
.where(FieldPath.documentId, whereIn: favs.map((f) => f['uid']).take(10).toList())
.snapshots(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Center(child: CircularProgressIndicator(color: theme.playerBlue));
}
// Mappiamo i risultati di Firebase per un accesso rapido
Map<String, Map<String, dynamic>> liveData = {};
for (var doc in snapshot.data!.docs) {
liveData[doc.id] = doc.data() as Map<String, dynamic>;
}
return ListView.builder(
physics: const BouncingScrollPhysics(),
itemCount: favs.length,
itemBuilder: (c, i) {
String uid = favs[i]['uid']!;
String name = favs[i]['name']!;
bool isOnline = false;
if (liveData.containsKey(uid) && liveData[uid]!['lastActive'] != null) {
Timestamp lastActive = liveData[uid]!['lastActive'];
int diffInSeconds = DateTime.now().difference(lastActive.toDate()).inSeconds;
// Se ha fatto un'azione negli ultimi 3 minuti, lo consideriamo online
if (diffInSeconds.abs() < 180) isOnline = true;
}
return ListTile(
title: Text(favs[i]['name']!, style: getLobbyTextStyle(themeType, TextStyle(color: theme.text, fontSize: 18, fontWeight: FontWeight.bold))),
trailing: ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: theme.playerBlue, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))),
leading: Icon(
Icons.circle,
color: isOnline ? Colors.greenAccent : Colors.redAccent.withOpacity(0.5),
size: 14
),
title: Text(
name,
style: getLobbyTextStyle(themeType, TextStyle(
color: isOnline ? theme.text : theme.text.withOpacity(0.5),
fontSize: 18,
fontWeight: FontWeight.bold
))
),
trailing: isOnline
? ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: theme.playerBlue,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))
),
onPressed: () {
Navigator.pop(ctx);
_createRoomAndInvite(favs[i]['uid']!, favs[i]['name']!);
_createRoomAndInvite(uid, name);
},
child: Text("SFIDA", style: getLobbyTextStyle(themeType, const TextStyle(color: Colors.white, fontWeight: FontWeight.bold))),
)
: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.2),
borderRadius: BorderRadius.circular(10)
),
child: Text("OFFLINE", style: getLobbyTextStyle(themeType, const TextStyle(color: Colors.grey, fontSize: 12, fontWeight: FontWeight.bold))),
),
);
},
);
}
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text("CHIUDI", style: getLobbyTextStyle(themeType, TextStyle(color: theme.playerRed))))
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text("CHIUDI", style: getLobbyTextStyle(themeType, TextStyle(color: theme.playerRed, fontWeight: FontWeight.bold))),
),
],
);
}
);
}
void _showWaitingDialog(String code) {
showDialog(
context: context,
@ -434,9 +492,9 @@ class _LobbyScreenState extends State<LobbyScreen> with WidgetsBindingObserver {
Row(
children: [
_buildTimeOption('PRO', '-2s A PARTITA', 'dynamic', theme, themeType),
_buildTimeOption('10s', 'FISSO', 'fixed', theme, themeType),
_buildTimeOption('RELAX', 'INFINITO', 'relax', theme, themeType),
_buildTimeOption('DINAMICO', '-2s', 'dynamic', theme, themeType),
],
),
const SizedBox(height: 10),
@ -618,7 +676,7 @@ class _LobbyScreenState extends State<LobbyScreen> with WidgetsBindingObserver {
String tMode = data['timeMode'] is String ? data['timeMode'] : (data['timeMode'] == true ? 'fixed' : 'relax');
String prettyTime = "10s";
if (tMode == 'relax') prettyTime = "Relax";
else if (tMode == 'dynamic') prettyTime = "Dinamico";
else if (tMode == 'dynamic') prettyTime = "PRO";
String prettyShape = "Rombo";
if (shapeStr == 'cross') prettyShape = "Croce";

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff