Auto-sync: 20260320_190000

This commit is contained in:
Paolo 2026-03-20 19:00:01 +01:00
parent e50ae8f689
commit b4ab3590be
4 changed files with 425 additions and 257 deletions

View file

@ -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<void> syncLeaderboard() async {
if (playerName.isNotEmpty) {
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");
}
}
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,
await FirebaseFirestore.instance.collection('leaderboard').doc(targetUid).set({
'name': name,
'xp': totalXP,
'level': playerLevel,
'wins': wins,
'losses': losses,
'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");
}
}

View file

@ -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, dynamic>;
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:";
@ -439,3 +477,65 @@ 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<PulsingChallengeButton> createState() => _PulsingChallengeButtonState();
}
class _PulsingChallengeButtonState extends State<PulsingChallengeButton> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 900))..repeat(reverse: true);
_animation = Tween<double>(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))
),
],
),
),
),
);
}
}

View file

@ -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<ThemeManager>();
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<GameController>().startNewGame(localRadius, vsCPU: isVsCPU, shape: localShape, timeMode: localTimeMode); Navigator.push(context, MaterialPageRoute(builder: (_) => GameScreen())); },
onTap: () { Navigator.pop(ctx); context.read<GameController>().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<GameController>().startNewGame(localRadius, vsCPU: isVsCPU, shape: localShape, timeMode: localTimeMode); Navigator.push(context, MaterialPageRoute(builder: (_) => GameScreen())); },
onPressed: () { Navigator.pop(ctx); context.read<GameController>().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<GameController>().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<FavoriteOnlinePopup> createState() => _FavoriteOnlinePopupState();
}
class _FavoriteOnlinePopupState extends State<FavoriteOnlinePopup> with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
late Animation<double> _fade;
late Animation<Offset> _slide;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 400));
_fade = Tween<double>(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
_slide = Tween<Offset>(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<GameController>();
final themeManager = context.read<ThemeManager>();
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<ThemeManager>();
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),
)
],
),
),
),
),
),
);
}
}

View file

@ -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<HomeScreen> with WidgetsBindingObserver {
late AppLinks _appLinks;
StreamSubscription<Uri>? _linkSubscription;
StreamSubscription<QuerySnapshot>? _favoritesSubscription;
StreamSubscription<QuerySnapshot>? _invitesSubscription; // <--- Nuovo Listener per gli inviti in arrivo
Map<String, DateTime> _lastOnlineNotifications = {};
@ -62,10 +65,16 @@ class _HomeScreenState extends State<HomeScreen> 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
}
_checkThemeSafety();
});
_checkClipboardForInvite();
@ -87,6 +96,7 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
_cleanupGhostRoom();
_linkSubscription?.cancel();
_favoritesSubscription?.cancel();
_invitesSubscription?.cancel(); // <--- Chiusura Listener
super.dispose();
}
@ -158,12 +168,16 @@ class _HomeScreenState extends State<HomeScreen> 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<HomeScreen> 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<HomeScreen> 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<ThemeManager>().currentThemeType;
final theme = context.read<ThemeManager>().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<void> _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<void> _joinRoomByCode(String code) async {
if (_isLoading) return;
FocusScope.of(context).unfocus();
@ -225,7 +376,7 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
bool hostTimeMode = roomData['timeMode'] ?? true;
context.read<GameController>().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<HomeScreen> 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<HomeScreen> 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,9 +587,8 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
},
child: FittedBox(
fit: BoxFit.scaleDown,
// IL TRUCCO: Spazio vuoto anche nel titolo principale
child: Text(
"${loc.appTitle.toUpperCase()} ",
loc.appTitle.toUpperCase(),
style: getSharedTextStyle(themeType, TextStyle(
fontSize: 65 * vScale,
fontWeight: FontWeight.w900,
@ -442,7 +597,9 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
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<HomeScreen> 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<HomeScreen> 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)),
],
@ -579,3 +736,94 @@ 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<FavoriteOnlinePopup> createState() => _FavoriteOnlinePopupState();
}
class _FavoriteOnlinePopupState extends State<FavoriteOnlinePopup> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _offsetAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 400));
_offsetAnimation = Tween<Offset>(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<ThemeManager>();
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
)
),
),
],
),
),
),
);
}
}