From b4ab3590bea4476e3d8ea035bbcaeea908220ff3 Mon Sep 17 00:00:00 2001 From: Paolo Date: Fri, 20 Mar 2026 19:00:01 +0100 Subject: [PATCH] Auto-sync: 20260320_190000 --- lib/services/storage_service.dart | 82 ++------ lib/ui/home/dialog.dart | 110 ++++++++++- lib/ui/home/home_modals.dart | 174 ++-------------- lib/ui/home/home_screen.dart | 316 ++++++++++++++++++++++++++---- 4 files changed, 425 insertions(+), 257 deletions(-) diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 22f59b0..38802b2 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -43,19 +43,14 @@ class StorageService { String get lastIp => _prefs.getString('last_ip') ?? 'Sconosciuto'; String get lastCity => _prefs.getString('last_city') ?? 'Sconosciuta'; - // --- METODI TEMA AGGIORNATI CON GESTIONE MIGRAZIONE SICURA --- String getTheme() { final Object? savedTheme = _prefs.get('theme'); - if (savedTheme is String) { return savedTheme; } else if (savedTheme is int) { - // Trovato un vecchio salvataggio in formato intero (causa del crash). - // Puliamo la memoria per evitare futuri problemi. _prefs.remove('theme'); return AppThemeType.doodle.toString(); } - return AppThemeType.doodle.toString(); } @@ -94,71 +89,30 @@ class StorageService { syncLeaderboard(); } + // --- SINCRONIZZAZIONE BLINDATA: SOLO UTENTI REGISTRATI --- Future syncLeaderboard() async { - if (playerName.isNotEmpty) { - try { - final user = FirebaseAuth.instance.currentUser; + try { + final user = FirebaseAuth.instance.currentUser; - if (user != null) { - String currentPlatform = "Sconosciuta"; - String appVersion = "N/D"; - String deviceModel = "Sconosciuto"; + // BLOCCO TOTALE: Se non sei loggato con la password, niente database! + if (user == null) return; - if (!kIsWeb) { - if (Platform.isAndroid) currentPlatform = "Android"; - else if (Platform.isIOS) currentPlatform = "iOS"; - else if (Platform.isMacOS) currentPlatform = "macOS"; - else if (Platform.isWindows) currentPlatform = "Windows"; + String name = playerName; + if (name.isEmpty) name = "GIOCATORE"; // Fallback di sicurezza - try { - PackageInfo packageInfo = await PackageInfo.fromPlatform(); - appVersion = "${packageInfo.version}+${packageInfo.buildNumber}"; - } catch(e) { - debugPrint("Errore lettura versione: $e"); - } + String targetUid = user.uid; - try { - DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); - if (Platform.isAndroid) { - AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; - deviceModel = "${androidInfo.manufacturer} ${androidInfo.model}"; - } else if (Platform.isIOS) { - IosDeviceInfo iosInfo = await deviceInfo.iosInfo; - deviceModel = iosInfo.utsname.machine; - } else if (Platform.isMacOS) { - MacOsDeviceInfo macInfo = await deviceInfo.macOsInfo; - deviceModel = macInfo.model; - } - } catch(e) { - debugPrint("Errore lettura hardware: $e"); - } - } + await FirebaseFirestore.instance.collection('leaderboard').doc(targetUid).set({ + 'name': name, + 'xp': totalXP, + 'level': playerLevel, + 'wins': wins, + 'losses': losses, + 'lastActive': FieldValue.serverTimestamp(), + }, SetOptions(merge: true)); - if (_sessionStart != 0) { - int now = DateTime.now().millisecondsSinceEpoch; - int sessionSeconds = (now - _sessionStart) ~/ 1000; - await _prefs.setInt('totalPlaytime', (_prefs.getInt('totalPlaytime') ?? 0) + sessionSeconds); - _sessionStart = now; - } - int totalPlaytime = _prefs.getInt('totalPlaytime') ?? 0; - - await FirebaseFirestore.instance.collection('leaderboard').doc(user.uid).set({ - 'name': playerName, - 'xp': totalXP, - 'level': playerLevel, - 'wins': wins, - 'lastActive': FieldValue.serverTimestamp(), - 'platform': currentPlatform, - 'ip': lastIp, - 'city': lastCity, - 'playtime': totalPlaytime, - 'appVersion': appVersion, - 'deviceModel': deviceModel, - }, SetOptions(merge: true)); - } - } catch(e) { - debugPrint("Errore sinc. classifica: $e"); - } + } catch (e) { + debugPrint("Errore durante la sincronizzazione della classifica: $e"); } } diff --git a/lib/ui/home/dialog.dart b/lib/ui/home/dialog.dart index 94583e7..0291d45 100644 --- a/lib/ui/home/dialog.dart +++ b/lib/ui/home/dialog.dart @@ -119,10 +119,12 @@ class QuestsDialog extends StatelessWidget { } // =========================================================================== -// 2. DIALOGO CLASSIFICA (LEADERBOARD) +// 2. DIALOGO CLASSIFICA (LEADERBOARD) CON CALLBACK SFIDA // =========================================================================== class LeaderboardDialog extends StatelessWidget { - const LeaderboardDialog({super.key}); + final Function(String uid, String name)? onChallenge; // <-- Aggiunto Callback per inviare i dati alla HomeScreen + + const LeaderboardDialog({super.key, this.onChallenge}); @override Widget build(BuildContext context) { @@ -160,7 +162,6 @@ class LeaderboardDialog extends StatelessWidget { } final rawDocs = snapshot.data!.docs; - final filteredDocs = rawDocs.where((doc) { var data = doc.data() as Map; String name = (data['name'] ?? '').toString().toUpperCase(); @@ -181,6 +182,13 @@ class LeaderboardDialog extends StatelessWidget { bool isMe = doc.id == myUid; String playerName = data['name'] ?? 'Unknown'; + bool isOnline = false; + if (data['lastActive'] != null) { + Timestamp lastActive = data['lastActive']; + int diffInSeconds = DateTime.now().difference(lastActive.toDate()).inSeconds; + if (diffInSeconds.abs() < 180) isOnline = true; + } + return StatefulBuilder( builder: (context, setStateItem) { bool isFav = StorageService.instance.isFavorite(doc.id); @@ -197,7 +205,37 @@ class LeaderboardDialog extends StatelessWidget { children: [ Text("#${index + 1}", style: getSharedTextStyle(themeType, TextStyle(fontWeight: FontWeight.w900, color: index == 0 ? Colors.amber : (index == 1 ? Colors.grey.shade400 : (index == 2 ? Colors.brown.shade300 : theme.text.withOpacity(0.5)))))), const SizedBox(width: 15), - Expanded(child: Text(playerName, style: getSharedTextStyle(themeType, TextStyle(fontSize: 16, fontWeight: isMe ? FontWeight.w900 : FontWeight.bold, color: theme.text)))), + + Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + playerName, + style: getSharedTextStyle(themeType, TextStyle(fontSize: 16, fontWeight: isMe ? FontWeight.w900 : FontWeight.bold, color: theme.text)), + overflow: TextOverflow.ellipsis, + ) + ), + + if (isFav && !isMe && isOnline) ...[ + const SizedBox(width: 8), + PulsingChallengeButton( + themeType: themeType, + onTap: () { + Navigator.pop(context); + // Chiama la funzione passata dalla HomeScreen! + if (onChallenge != null) { + onChallenge!(doc.id, playerName); + } + }, + ), + ] + ], + ), + ), + + const SizedBox(width: 10), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ @@ -205,6 +243,7 @@ class LeaderboardDialog extends StatelessWidget { Text("${data['xp'] ?? 0} XP", style: TextStyle(color: theme.text.withOpacity(0.6), fontSize: 10)), ], ), + if (!isMe) ...[ const SizedBox(width: 8), GestureDetector( @@ -260,7 +299,6 @@ class TutorialDialog extends StatelessWidget { final themeType = themeManager.currentThemeType; Color inkColor = const Color(0xFF111122); - // ETICHETTE DINAMICHE PER I POTENZIAMENTI String goldLabel = "ORO:"; String bombLabel = "BOMBA:"; String swapLabel = "SCAMBIO:"; @@ -438,4 +476,66 @@ class TutorialStep extends StatelessWidget { ], ); } +} + +// =========================================================================== +// 4. WIDGET ANIMATO PER TASTO SFIDA +// =========================================================================== +class PulsingChallengeButton extends StatefulWidget { + final VoidCallback onTap; + final AppThemeType themeType; + + const PulsingChallengeButton({super.key, required this.onTap, required this.themeType}); + + @override + State createState() => _PulsingChallengeButtonState(); +} + +class _PulsingChallengeButtonState extends State with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 900))..repeat(reverse: true); + _animation = Tween(begin: 0.3, end: 1.0).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final Color softGreen = Colors.green.shade400; + + return GestureDetector( + onTap: widget.onTap, + child: FadeTransition( + opacity: _animation, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: softGreen.withOpacity(0.15), + border: Border.all(color: softGreen, width: 1.5), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.circle, color: softGreen, size: 8), + const SizedBox(width: 4), + Text( + "SFIDA", + style: getSharedTextStyle(widget.themeType, TextStyle(color: softGreen, fontSize: 10, fontWeight: FontWeight.bold)) + ), + ], + ), + ), + ), + ); + } } \ No newline at end of file diff --git a/lib/ui/home/home_modals.dart b/lib/ui/home/home_modals.dart index c8fa311..c1dda13 100644 --- a/lib/ui/home/home_modals.dart +++ b/lib/ui/home/home_modals.dart @@ -32,7 +32,9 @@ class HomeModals { String errorMessage = ""; showDialog( - context: context, barrierDismissible: false, barrierColor: Colors.black.withOpacity(0.8), + context: context, + barrierDismissible: false, // Impedisce di chiudere tappando fuori + barrierColor: Colors.black.withOpacity(0.8), builder: (dialogContext) { final themeManager = dialogContext.watch(); final themeType = themeManager.currentThemeType; @@ -102,7 +104,7 @@ class HomeModals { children: [ Text(loc.welcomeTitle, style: getSharedTextStyle(themeType, TextStyle(color: inkColor, fontWeight: FontWeight.w900, fontSize: 24, letterSpacing: 2.0)), textAlign: TextAlign.center), const SizedBox(height: 10), - Text('Scegli Nome e Password.\nTi serviranno per recuperare gli XP!', style: getSharedTextStyle(themeType, TextStyle(color: inkColor.withOpacity(0.8), fontSize: 13)), textAlign: TextAlign.center), + Text('Scegli una Password per il Cloud.\nI tuoi XP e il tuo Livello saranno protetti e non li perderai mai!', style: getSharedTextStyle(themeType, TextStyle(color: inkColor.withOpacity(0.8), fontSize: 13, fontWeight: FontWeight.bold)), textAlign: TextAlign.center), const SizedBox(height: 15), TextField( controller: nameController, textCapitalization: TextCapitalization.characters, textAlign: TextAlign.center, maxLength: 8, @@ -132,7 +134,7 @@ class HomeModals { const SizedBox(height: 15), if (errorMessage.isNotEmpty) Padding(padding: const EdgeInsets.only(bottom: 10), child: Text(errorMessage, style: getSharedTextStyle(themeType, const TextStyle(color: Colors.red, fontSize: 14, fontWeight: FontWeight.bold)), textAlign: TextAlign.center)), - Text("💡 Nota: Non serve una vera email. Usa una password facile da ricordare!", style: getSharedTextStyle(themeType, TextStyle(color: inkColor.withOpacity(0.6), fontSize: 11, height: 1.3)), textAlign: TextAlign.center), + Text("💡 Usa una password facile da ricordare!", style: getSharedTextStyle(themeType, TextStyle(color: inkColor.withOpacity(0.6), fontSize: 11, height: 1.3)), textAlign: TextAlign.center), const SizedBox(height: 15), isLoadingAuth ? CircularProgressIndicator(color: inkColor) : Row( children: [ @@ -157,7 +159,7 @@ class HomeModals { children: [ Text(loc.welcomeTitle, style: getSharedTextStyle(themeType, TextStyle(color: themeManager.currentColors.text, fontWeight: FontWeight.w900, fontSize: 20, letterSpacing: 1.5)), textAlign: TextAlign.center), const SizedBox(height: 10), - Text('Scegli Nome e Password.\nTi serviranno per recuperare gli XP!', style: getSharedTextStyle(themeType, TextStyle(color: themeManager.currentColors.text.withOpacity(0.8), fontSize: 13)), textAlign: TextAlign.center), + Text('Scegli una Password per il Cloud.\nI tuoi XP e il tuo Livello saranno protetti e non li perderai mai!', style: getSharedTextStyle(themeType, TextStyle(color: themeManager.currentColors.text.withOpacity(0.8), fontSize: 13, fontWeight: FontWeight.bold)), textAlign: TextAlign.center), const SizedBox(height: 15), TextField( controller: nameController, textCapitalization: TextCapitalization.characters, textAlign: TextAlign.center, maxLength: 8, @@ -187,7 +189,7 @@ class HomeModals { const SizedBox(height: 15), if (errorMessage.isNotEmpty) Padding(padding: const EdgeInsets.only(bottom: 10), child: Text(errorMessage, style: getSharedTextStyle(themeType, const TextStyle(color: Colors.redAccent, fontSize: 14, fontWeight: FontWeight.bold)), textAlign: TextAlign.center)), - Text("💡 Nota: Non serve una vera email. Usa una password facile da ricordare!", style: getSharedTextStyle(themeType, TextStyle(color: themeManager.currentColors.text.withOpacity(0.6), fontSize: 11, height: 1.3)), textAlign: TextAlign.center), + Text("💡 Usa una password facile da ricordare!", style: getSharedTextStyle(themeType, TextStyle(color: themeManager.currentColors.text.withOpacity(0.6), fontSize: 11, height: 1.3)), textAlign: TextAlign.center), const SizedBox(height: 20), isLoadingAuth ? CircularProgressIndicator(color: themeManager.currentColors.playerBlue) : Row( children: [ @@ -203,7 +205,12 @@ class HomeModals { ); if (themeType == AppThemeType.cyberpunk || themeType == AppThemeType.music) dialogContent = AnimatedCyberBorder(child: dialogContent); - return Dialog(backgroundColor: Colors.transparent, insetPadding: const EdgeInsets.all(20), child: dialogContent); + + // LA PROTEZIONE ANTI-BACK DI ANDROID: Impedisce l'uscita non autorizzata + return PopScope( + canPop: false, + child: Dialog(backgroundColor: Colors.transparent, insetPadding: const EdgeInsets.all(20), child: dialogContent) + ); }, ); }, @@ -277,7 +284,7 @@ class HomeModals { Transform.rotate( angle: -0.02, child: GestureDetector( - onTap: () { Navigator.pop(ctx); context.read().startNewGame(localRadius, vsCPU: isVsCPU, shape: localShape, timeMode: localTimeMode); Navigator.push(context, MaterialPageRoute(builder: (_) => GameScreen())); }, + onTap: () { Navigator.pop(ctx); context.read().startNewGame(localRadius, vsCPU: isVsCPU, shape: localShape, timeMode: localTimeMode); Navigator.push(context, MaterialPageRoute(builder: (_) => const GameScreen())); }, child: CustomPaint(painter: DoodleBackgroundPainter(fillColor: Colors.green.shade200, strokeColor: inkColor, seed: 300), child: Container(height: 65, width: double.infinity, alignment: Alignment.center, child: Text(loc.startGame, style: getSharedTextStyle(themeType, TextStyle(fontSize: 22, fontWeight: FontWeight.w900, letterSpacing: 3.0, color: inkColor))))), ), ) @@ -342,7 +349,7 @@ class HomeModals { width: double.infinity, height: 60, child: ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: isVsCPU ? Colors.purple.shade400 : theme.playerRed, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), - onPressed: () { Navigator.pop(ctx); context.read().startNewGame(localRadius, vsCPU: isVsCPU, shape: localShape, timeMode: localTimeMode); Navigator.push(context, MaterialPageRoute(builder: (_) => GameScreen())); }, + onPressed: () { Navigator.pop(ctx); context.read().startNewGame(localRadius, vsCPU: isVsCPU, shape: localShape, timeMode: localTimeMode); Navigator.push(context, MaterialPageRoute(builder: (_) => const GameScreen())); }, child: Text(loc.startGame, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900, letterSpacing: 2)), ), ) @@ -438,7 +445,7 @@ class HomeModals { WidgetsBinding.instance.addPostFrameCallback((_) { Navigator.pop(ctx); context.read().startNewGame(selectedRadius, isOnline: true, roomCode: code, isHost: true, shape: selectedShape, timeMode: isTimeMode); - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => GameScreen())); + Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const GameScreen())); }); } } @@ -549,154 +556,13 @@ class HomeModals { ), ), 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))), + ), ], ); } ); } -} - -// --- WIDGET POPUP PER AMICO ONLINE --- -class FavoriteOnlinePopup extends StatefulWidget { - final String name; - final VoidCallback onDismiss; - const FavoriteOnlinePopup({super.key, required this.name, required this.onDismiss}); - - @override - State createState() => _FavoriteOnlinePopupState(); -} - -class _FavoriteOnlinePopupState extends State with SingleTickerProviderStateMixin { - late AnimationController _ctrl; - late Animation _fade; - late Animation _slide; - - @override - void initState() { - super.initState(); - _ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 400)); - _fade = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut)); - _slide = Tween(begin: const Offset(0, -0.5), end: Offset.zero).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutBack)); - - _ctrl.forward(); - - Future.delayed(const Duration(seconds: 3), () async { - if (mounted) { - await _ctrl.reverse(); - widget.onDismiss(); - } - }); - } - - @override - void dispose() { - _ctrl.dispose(); - super.dispose(); - } - - void _handleTap() { - final game = context.read(); - final themeManager = context.read(); - final theme = themeManager.currentColors; - final themeType = themeManager.currentThemeType; - - // Se il gioco è attivo (non finito e siamo oltre la fase di setup) - bool isInGame = !game.isGameOver && (!game.isSetupPhase || game.isOnline || game.isVsCPU); - - if (isInGame) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: theme.background, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15), side: BorderSide(color: theme.playerRed, width: 2)), - title: Text("Sei in partita!", style: getSharedTextStyle(themeType, TextStyle(color: theme.text, fontWeight: FontWeight.bold))), - content: Text("Vuoi abbandonare la partita attuale per raggiungere la lobby multiplayer?", style: TextStyle(color: theme.text)), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: Text("Annulla", style: TextStyle(color: theme.text))), - ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: theme.playerRed), - onPressed: () { - game.disconnectOnlineGame(); - Navigator.pop(ctx); - widget.onDismiss(); - Navigator.popUntil(context, (route) => route.isFirst); - Navigator.push(context, MaterialPageRoute(builder: (_) => LobbyScreen())); - }, - child: const Text("Abbandona e Vai", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), - ) - ] - ) - ); - } else { - widget.onDismiss(); - Navigator.popUntil(context, (route) => route.isFirst); - Navigator.push(context, MaterialPageRoute(builder: (_) => LobbyScreen())); - } - } - - @override - Widget build(BuildContext context) { - final themeManager = context.watch(); - final theme = themeManager.currentColors; - final themeType = themeManager.currentThemeType; - - return Material( - color: Colors.transparent, - child: SlideTransition( - position: _slide, - child: FadeTransition( - opacity: _fade, - child: GestureDetector( - onTap: _handleTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [theme.playerBlue.withOpacity(0.95), theme.playerBlue.withOpacity(0.8)], - ), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: Colors.white.withOpacity(0.4), width: 1.5), - boxShadow: [ - BoxShadow(color: Colors.black.withOpacity(0.4), blurRadius: 15, offset: const Offset(0, 8)), - BoxShadow(color: theme.playerBlue.withOpacity(0.4), blurRadius: 10, spreadRadius: 1), - ], - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - shape: BoxShape.circle, - ), - child: const Icon(Icons.star_rounded, color: Colors.amber, size: 24), - ), - const SizedBox(width: 15), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text("Giocatore Online!", style: getSharedTextStyle(themeType, TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 11, fontWeight: FontWeight.bold, letterSpacing: 1.5))), - const SizedBox(height: 2), - Text("${widget.name} è in partita", style: getSharedTextStyle(themeType, const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w900))), - ], - ), - ), - GestureDetector( - onTap: () async { - await _ctrl.reverse(); - widget.onDismiss(); - }, - child: Icon(Icons.close, color: Colors.white.withOpacity(0.6), size: 20), - ) - ], - ), - ), - ), - ), - ), - ); - } } \ No newline at end of file diff --git a/lib/ui/home/home_screen.dart b/lib/ui/home/home_screen.dart index c6d32b5..596f81c 100644 --- a/lib/ui/home/home_screen.dart +++ b/lib/ui/home/home_screen.dart @@ -3,11 +3,13 @@ // =========================================================================== import 'dart:ui'; +import 'dart:math'; // Aggiunto per generare il codice della stanza randomico import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:flutter/services.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; import 'dart:async'; import 'package:app_links/app_links.dart'; @@ -43,6 +45,7 @@ class _HomeScreenState extends State with WidgetsBindingObserver { late AppLinks _appLinks; StreamSubscription? _linkSubscription; StreamSubscription? _favoritesSubscription; + StreamSubscription? _invitesSubscription; // <--- Nuovo Listener per gli inviti in arrivo Map _lastOnlineNotifications = {}; @@ -62,10 +65,16 @@ class _HomeScreenState extends State with WidgetsBindingObserver { super.initState(); WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) { - if (StorageService.instance.playerName.isEmpty) { - HomeModals.showNameDialog(context, () => setState(() {})); + if (FirebaseAuth.instance.currentUser == null) { + HomeModals.showNameDialog(context, () { + StorageService.instance.syncLeaderboard(); + _listenToInvites(); // <--- Ascoltiamo gli inviti appena loggati + setState(() {}); + }); + } else { + StorageService.instance.syncLeaderboard(); + _listenToInvites(); // <--- Ascoltiamo gli inviti se eravamo già loggati } - StorageService.instance.syncLeaderboard(); _checkThemeSafety(); }); _checkClipboardForInvite(); @@ -87,6 +96,7 @@ class _HomeScreenState extends State with WidgetsBindingObserver { _cleanupGhostRoom(); _linkSubscription?.cancel(); _favoritesSubscription?.cancel(); + _invitesSubscription?.cancel(); // <--- Chiusura Listener super.dispose(); } @@ -158,12 +168,16 @@ class _HomeScreenState extends State with WidgetsBindingObserver { .where(FieldPath.documentId, whereIn: favUids) .snapshots() .listen((snapshot) { + if (!mounted) return; + for (var change in snapshot.docChanges) { if (change.type == DocumentChangeType.modified || change.type == DocumentChangeType.added) { var data = change.doc.data(); if (data != null && data['lastActive'] != null) { Timestamp lastActive = data['lastActive']; - if (DateTime.now().difference(lastActive.toDate()).inSeconds < 15) { + int diffInSeconds = DateTime.now().difference(lastActive.toDate()).inSeconds; + + if (diffInSeconds.abs() < 180) { String name = data['name'] ?? 'Un amico'; _showFavoriteOnlinePopup(name); } @@ -174,8 +188,10 @@ class _HomeScreenState extends State with WidgetsBindingObserver { } void _showFavoriteOnlinePopup(String name) { + if (!mounted) return; + if (_lastOnlineNotifications.containsKey(name)) { - if (DateTime.now().difference(_lastOnlineNotifications[name]!).inMinutes < 5) return; + if (DateTime.now().difference(_lastOnlineNotifications[name]!).inMinutes < 1) return; } _lastOnlineNotifications[name] = DateTime.now(); @@ -202,6 +218,141 @@ class _HomeScreenState extends State with WidgetsBindingObserver { overlay.insert(entry); } + // ========================================================================= + // SISTEMA INVITI DIRETTO TRAMITE FIRESTORE + // ========================================================================= + void _listenToInvites() { + final user = FirebaseAuth.instance.currentUser; + if (user == null) return; + + _invitesSubscription?.cancel(); + _invitesSubscription = FirebaseFirestore.instance + .collection('invites') + .where('toUid', isEqualTo: user.uid) + .snapshots() + .listen((snapshot) { + if (!mounted) return; + + for (var change in snapshot.docChanges) { + if (change.type == DocumentChangeType.added) { + var data = change.doc.data(); + if (data != null) { + String code = data['roomCode']; + String from = data['fromName']; + String inviteId = change.doc.id; + + // Filtro sicurezza: Evita di mostrare inviti fantasma vecchi di oltre 2 minuti + Timestamp? ts = data['timestamp']; + if (ts != null) { + if (DateTime.now().difference(ts.toDate()).inMinutes > 2) { + FirebaseFirestore.instance.collection('invites').doc(inviteId).delete(); + continue; + } + } + _showInvitePopup(from, code, inviteId); + } + } + } + }); + } + + void _showInvitePopup(String fromName, String roomCode, String inviteId) { + final themeType = context.read().currentThemeType; + final theme = context.read().currentColors; + + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + backgroundColor: themeType == AppThemeType.doodle ? Colors.white : 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), + Text("SFIDA IN ARRIVO!", style: getSharedTextStyle(themeType, TextStyle(color: theme.playerRed, fontWeight: FontWeight.bold, fontSize: 18))), + ], + ), + content: Text("$fromName ti ha sfidato a duello!\nAccetti la sfida?", style: getSharedTextStyle(themeType, TextStyle(color: theme.text, fontSize: 16))), + actions: [ + TextButton( + onPressed: () { + FirebaseFirestore.instance.collection('invites').doc(inviteId).delete(); + Navigator.pop(ctx); + }, + child: Text("RIFIUTA", style: getSharedTextStyle(themeType, const TextStyle(color: Colors.grey, fontWeight: FontWeight.bold))), + ), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: theme.playerBlue, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))), + onPressed: () { + FirebaseFirestore.instance.collection('invites').doc(inviteId).delete(); + Navigator.pop(ctx); + _joinRoomByCode(roomCode); + }, + child: Text("ACCETTA!", style: getSharedTextStyle(themeType, const TextStyle(fontWeight: FontWeight.bold))), + ), + ], + ) + ); + } + + Future _sendChallenge(String targetUid, String targetName) async { + setState(() => _isLoading = true); + + // Generiamo un codice stanza casuale univoco + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + final rnd = Random(); + String roomCode = String.fromCharCodes(Iterable.generate(5, (_) => chars.codeUnitAt(rnd.nextInt(chars.length)))); + + try { + // 1. Creiamo la stanza sul database + await FirebaseFirestore.instance.collection('games').doc(roomCode).set({ + 'status': 'waiting', + 'hostName': StorageService.instance.playerName, + 'hostUid': FirebaseAuth.instance.currentUser?.uid, + 'radius': 4, + 'shape': 'classic', + 'timeMode': true, + 'isPublic': false, // È una stanza privata! + 'createdAt': FieldValue.serverTimestamp(), + }); + + // 2. Inviamo l'invito al nostro avversario + await FirebaseFirestore.instance.collection('invites').add({ + 'toUid': targetUid, + 'fromName': StorageService.instance.playerName, + 'roomCode': roomCode, + 'timestamp': FieldValue.serverTimestamp(), + }); + + setState(() => _isLoading = false); + + // 3. Apriamo il radar d'attesa (che ascolta quando lui accetta) + if (mounted) { + HomeModals.showWaitingDialog( + context: context, + code: roomCode, + isPublicRoom: false, + selectedRadius: 4, + selectedShape: ArenaShape.classic, + isTimeMode: true, + multiplayerService: _multiplayerService, + onRoomStarted: () {}, + onCleanup: () { + // Se noi annulliamo, cancelliamo la stanza + FirebaseFirestore.instance.collection('games').doc(roomCode).delete(); + } + ); + } + } catch (e) { + setState(() => _isLoading = false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Errore: $e", style: const TextStyle(color: Colors.white)), backgroundColor: Colors.red)); + } + } + } + // ========================================================================= + Future _joinRoomByCode(String code) async { if (_isLoading) return; FocusScope.of(context).unfocus(); @@ -225,7 +376,7 @@ class _HomeScreenState extends State with WidgetsBindingObserver { bool hostTimeMode = roomData['timeMode'] ?? true; context.read().startNewGame(hostRadius, isOnline: true, roomCode: code, isHost: false, shape: hostShape, timeMode: hostTimeMode); - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => GameScreen())); + Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const GameScreen())); } else { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Stanza non trovata, piena o partita già iniziata.", style: TextStyle(color: Colors.white)), backgroundColor: Colors.red)); } @@ -286,14 +437,17 @@ class _HomeScreenState extends State with WidgetsBindingObserver { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - // Proteggiamo anche nome e livello Text( - "\u00A0${playerName.toUpperCase()}\u00A0\u00A0", - style: getSharedTextStyle(themeType, TextStyle(color: themeType == AppThemeType.doodle ? inkColor : theme.text, fontWeight: FontWeight.bold, fontSize: 16, letterSpacing: 1.0)), + playerName.toUpperCase(), + style: getSharedTextStyle(themeType, TextStyle(color: themeType == AppThemeType.doodle ? inkColor : theme.text, fontWeight: FontWeight.bold, fontSize: 16)), + overflow: TextOverflow.visible, + softWrap: false, ), Text( - "\u00A0LIV. $playerLevel\u00A0\u00A0", - style: getSharedTextStyle(themeType, TextStyle(color: themeType == AppThemeType.doodle ? inkColor.withOpacity(0.8) : theme.playerBlue, fontWeight: FontWeight.bold, fontSize: 11, letterSpacing: 1.0)), + "LIV. $playerLevel", + style: getSharedTextStyle(themeType, TextStyle(color: themeType == AppThemeType.doodle ? inkColor.withOpacity(0.8) : theme.playerBlue, fontWeight: FontWeight.bold, fontSize: 11)), + overflow: TextOverflow.visible, + softWrap: false, ), ], ), @@ -302,42 +456,44 @@ class _HomeScreenState extends State with WidgetsBindingObserver { ), ), - // --- BOX STATISTICHE BLINDATO --- + // --- BOX STATISTICHE --- Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), // Padding compensato + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: _glassBoxDecoration(theme, themeType), child: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ Icon(themeType == AppThemeType.music ? FontAwesomeIcons.microphone : Icons.emoji_events, color: Colors.amber.shade600, size: 16), + const SizedBox(width: 6), - // Doppio spazio invisibile \u00A0\u00A0 e letterSpacing per salvare la pancia a destra! Text( - "\u00A0${StorageService.instance.wins}\u00A0\u00A0", + "${StorageService.instance.wins}", style: getSharedTextStyle(themeType, TextStyle( color: themeType == AppThemeType.doodle ? inkColor : theme.text, fontWeight: FontWeight.w900, fontSize: 16, - letterSpacing: 2.0, // Forza ulteriore spazio orizzontale )), + overflow: TextOverflow.visible, + softWrap: false, ), - const SizedBox(width: 4), + const SizedBox(width: 10), Icon(themeType == AppThemeType.music ? FontAwesomeIcons.compactDisc : Icons.sentiment_very_dissatisfied, color: theme.playerRed.withOpacity(0.8), size: 16), + const SizedBox(width: 6), - // Idem per le sconfitte Text( - "\u00A0${StorageService.instance.losses}\u00A0\u00A0", + "${StorageService.instance.losses}", style: getSharedTextStyle(themeType, TextStyle( color: themeType == AppThemeType.doodle ? inkColor : theme.text, fontWeight: FontWeight.w900, fontSize: 16, - letterSpacing: 2.0, )), + overflow: TextOverflow.visible, + softWrap: false, ), - const SizedBox(width: 4), + const SizedBox(width: 10), Container(width: 1, height: 20, color: (themeType == AppThemeType.doodle ? inkColor : Colors.white).withOpacity(0.2)), const SizedBox(width: 10), @@ -431,18 +587,19 @@ class _HomeScreenState extends State with WidgetsBindingObserver { }, child: FittedBox( fit: BoxFit.scaleDown, - // IL TRUCCO: Spazio vuoto anche nel titolo principale child: Text( - "${loc.appTitle.toUpperCase()} ", - style: getSharedTextStyle(themeType, TextStyle( - fontSize: 65 * vScale, - fontWeight: FontWeight.w900, - color: themeType == AppThemeType.doodle ? inkColor : theme.text, - letterSpacing: 10 * vScale, - shadows: themeType == AppThemeType.doodle - ? [const Shadow(color: Colors.white, offset: Offset(2.5, 2.5), blurRadius: 2), const Shadow(color: Colors.white, offset: Offset(-2.5, -2.5), blurRadius: 2)] - : [Shadow(color: Colors.black.withOpacity(0.8), offset: const Offset(3, 4), blurRadius: 8), Shadow(color: theme.playerBlue.withOpacity(0.4), offset: const Offset(0, 0), blurRadius: 20)] - )) + loc.appTitle.toUpperCase(), + style: getSharedTextStyle(themeType, TextStyle( + fontSize: 65 * vScale, + fontWeight: FontWeight.w900, + color: themeType == AppThemeType.doodle ? inkColor : theme.text, + letterSpacing: 10 * vScale, + shadows: themeType == AppThemeType.doodle + ? [const Shadow(color: Colors.white, offset: Offset(2.5, 2.5), blurRadius: 2), const Shadow(color: Colors.white, offset: Offset(-2.5, -2.5), blurRadius: 2)] + : [Shadow(color: Colors.black.withOpacity(0.8), offset: const Offset(3, 4), blurRadius: 8), Shadow(color: theme.playerBlue.withOpacity(0.4), offset: const Offset(0, 0), blurRadius: 20)] + )), + overflow: TextOverflow.visible, + softWrap: false, ), ), ), @@ -460,7 +617,7 @@ class _HomeScreenState extends State with WidgetsBindingObserver { Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded(child: MusicKnobCard(title: loc.leaderboardTitle, icon: FontAwesomeIcons.compactDisc, iconColor: Colors.amber, themeType: themeType, onTap: () => showDialog(context: context, builder: (ctx) => const LeaderboardDialog()))), + Expanded(child: MusicKnobCard(title: loc.leaderboardTitle, icon: FontAwesomeIcons.compactDisc, iconColor: Colors.amber, themeType: themeType, onTap: () => showDialog(context: context, builder: (ctx) => LeaderboardDialog(onChallenge: _sendChallenge)))), Expanded(child: MusicKnobCard(title: loc.questsTitle, icon: FontAwesomeIcons.microphoneLines, themeType: themeType, onTap: () => showDialog(context: context, builder: (ctx) => const QuestsDialog()))), Expanded(child: MusicKnobCard(title: loc.themesTitle, icon: FontAwesomeIcons.palette, themeType: themeType, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => SettingsScreen())))), Expanded(child: MusicKnobCard(title: loc.tutorialTitle, icon: FontAwesomeIcons.bookOpen, themeType: themeType, onTap: () => showDialog(context: context, builder: (ctx) => const TutorialDialog()))), @@ -479,7 +636,7 @@ class _HomeScreenState extends State with WidgetsBindingObserver { Row( children: [ - Expanded(child: _buildCyberCard(FeatureCard(title: loc.leaderboardTitle, subtitle: "Top 50 Globale", icon: Icons.leaderboard, color: Colors.amber.shade200, theme: theme, themeType: themeType, onTap: () => showDialog(context: context, builder: (ctx) => const LeaderboardDialog()), compact: true), themeType)), + Expanded(child: _buildCyberCard(FeatureCard(title: loc.leaderboardTitle, subtitle: "Top 50 Globale", icon: Icons.leaderboard, color: Colors.amber.shade200, theme: theme, themeType: themeType, onTap: () => showDialog(context: context, builder: (ctx) => LeaderboardDialog(onChallenge: _sendChallenge)), compact: true), themeType)), const SizedBox(width: 12), Expanded(child: _buildCyberCard(FeatureCard(title: loc.questsTitle, subtitle: "Missioni", icon: Icons.assignment_turned_in, color: Colors.green.shade200, theme: theme, themeType: themeType, onTap: () => showDialog(context: context, builder: (ctx) => const QuestsDialog()), compact: true), themeType)), ], @@ -578,4 +735,95 @@ class FullScreenGridPainter extends CustomPainter { @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +// --- WIDGET POPUP AMICO ONLINE (Ripristinato in coda!) --- +class FavoriteOnlinePopup extends StatefulWidget { + final String name; + final VoidCallback onDismiss; + + const FavoriteOnlinePopup({super.key, required this.name, required this.onDismiss}); + + @override + State createState() => _FavoriteOnlinePopupState(); +} + +class _FavoriteOnlinePopupState extends State with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _offsetAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 400)); + _offsetAnimation = Tween(begin: const Offset(0.0, -1.5), end: Offset.zero) + .animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutBack)); + + _controller.forward(); + + // Chiude il popup automaticamente dopo 3 secondi + Future.delayed(const Duration(seconds: 3), () { + if (mounted) { + _controller.reverse().then((_) => widget.onDismiss()); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final themeManager = context.watch(); + final themeType = themeManager.currentThemeType; + final theme = themeManager.currentColors; + Color inkColor = const Color(0xFF111122); + + return SlideTransition( + position: _offsetAnimation, + child: Material( + color: Colors.transparent, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: themeType == AppThemeType.doodle ? Colors.white : theme.background, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: themeType == AppThemeType.doodle ? inkColor : theme.playerBlue, + width: 2 + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 10, + offset: const Offset(0, 5) + ) + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.circle, color: Colors.greenAccent, size: 14), + const SizedBox(width: 10), + Text( + "${widget.name} è online!", + style: getSharedTextStyle( + themeType, + TextStyle( + color: themeType == AppThemeType.doodle ? inkColor : theme.text, + fontWeight: FontWeight.bold, + fontSize: 15 + ) + ), + ), + ], + ), + ), + ), + ); + } } \ No newline at end of file