=== TETRAQ PROJECT BACKUP === === PROJECT STRUCTURE (LIB & ASSETS) === assets/.DS_Store assets/audio/sfx/cyber_box.wav assets/audio/sfx/cyber_line.wav assets/audio/sfx/doodle_box.wav assets/audio/sfx/doodle_line.wav assets/audio/sfx/minimal_box.wav assets/audio/sfx/minimal_line.wav assets/icon/icona_master.png assets/images/.DS_Store assets/images/doodle_bg.jpg assets/images/doodle_bg1.jpg assets/images/icona_big.jpeg assets/images/wood_bg.jpg lib/.DS_Store lib/core/app_colors.dart lib/core/constants.dart lib/core/theme_manager.dart lib/firebase_options.dart lib/l10n/app_en.arb lib/l10n/app_it.arb lib/logic/ai_engine.dart lib/logic/game_controller.dart lib/main.dart lib/models/game_board.dart lib/models/player_info.dart lib/services/audio_service.dart lib/services/firebase_service.dart lib/services/multiplayer_service.dart lib/services/storage_service.dart lib/ui/game/board_painter.dart lib/ui/game/game_screen.dart lib/ui/game/score_board.dart lib/ui/home/history_screen.dart lib/ui/home/home_screen.dart lib/ui/multiplayer/lobby_screen.dart lib/ui/settings/settings_screen.dart lib/widgets/custom_button.dart lib/widgets/custom_settings_button.dart lib/widgets/game_over_dialog.dart === pubspec.yaml === name: tetraq description: A new Flutter project. publish_to: 'none' version: 1.0.0+4 environment: sdk: ^3.10.7 dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.8 # I nostri "Superpoteri" provider: ^6.1.2 # Per gestire lo stato (Temi, Punteggi) shared_preferences: ^2.5.4 # Per salvare opzioni e record sul telefono audioplayers: ^5.2.1 # Per la musica e gli effetti sonori intl: ^0.20.2 # Necessario per le traduzioni flutter_localizations: # Il sistema multilingua ufficiale sdk: flutter firebase_core: ^4.4.0 cloud_firestore: ^6.1.2 share_plus: ^12.0.1 app_links: ^7.0.0 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 flutter_launcher_icons: ^0.13.1 flutter: uses-material-design: true # Abilita la generazione automatica delle traduzioni generate: true # Dichiariamo le cartelle dove metteremo immagini e suoni assets: - assets/images/ - assets/audio/bgm/ - assets/audio/sfx/ - assets/images/ flutter_icons: android: "ic_launcher" ios: true macos: generate: true image_path: "assets/icon/icona_master.png" min_sdk_android: 21 # Serve per compatibilità con Android recenti === MAC OS CONFIG: Info.plist === LSApplicationCategoryType public.app-category.puzzle-games CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIconFile CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName $(PRODUCT_NAME) CFBundlePackageType APPL CFBundleShortVersionString $(FLUTTER_BUILD_NAME) CFBundleVersion $(FLUTTER_BUILD_NUMBER) LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) NSHumanReadableCopyright $(PRODUCT_COPYRIGHT) NSMainNibFile MainMenu NSPrincipalClass NSApplication === MAC OS CONFIG: DebugProfile.entitlements === com.apple.security.app-sandbox com.apple.security.cs.allow-jit com.apple.security.network.client com.apple.security.network.server === IOS CONFIG: Info.plist === CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName Tetraq CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName tetraq CFBundlePackageType APPL CFBundleShortVersionString $(FLUTTER_BUILD_NAME) CFBundleSignature ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS UILaunchStoryboardName LaunchScreen UIMainStoryboardFile Main UISupportedInterfaceOrientations UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UISupportedInterfaceOrientations~ipad UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight CADisableMinimumFrameDurationOnPhone UIApplicationSupportsIndirectInputEvents === ANDROID CONFIG: AndroidManifest.xml === === ANDROID CONFIG: build.gradle.kts (app) === plugins { id("com.android.application") // START: FlutterFire Configuration id("com.google.gms.google-services") // END: FlutterFire Configuration id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } android { namespace = "com.sanza.tetraq" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() } defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.sanza.tetraq" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName } buildTypes { release { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. signingConfig = signingConfigs.getByName("debug") } } } flutter { source = "../.." } === SOURCE CODE (lib/) === // =========================================================================== // FILE: lib/core/app_colors.dart // =========================================================================== import 'package:flutter/material.dart'; enum AppThemeType { minimal, doodle, cyberpunk, wood } class ThemeColors { final Color background; final Color gridLine; final Color playerRed; final Color playerBlue; final Color text; const ThemeColors({ required this.background, required this.gridLine, required this.playerRed, required this.playerBlue, required this.text, }); } class AppColors { static const ThemeColors minimal = ThemeColors( background: Color(0xFFF5F7FA), gridLine: Color(0xFFCFD8DC), playerRed: Color(0xFFE53935), playerBlue: Color(0xFF1E88E5), text: Color(0xFF263238), ); static const ThemeColors doodle = ThemeColors( background: Color(0xFFFFF9E6), gridLine: Color(0xFFB0BEC5), playerRed: Color(0xFFD32F2F), playerBlue: Color(0xFF1565C0), text: Color(0xFF37474F), ); // --- TEMA CYBERPUNK AGGIORNATO --- static const ThemeColors cyberpunk = ThemeColors( background: Color(0xFF0A001A), // Sfondo notte profonda gridLine: Color(0xFF6200EA), // Viola scuro elettrico (non fa confusione con le mosse) playerRed: Color(0xFFFF007F), // Rosa Neon (invariato) playerBlue: Color(0xFF69F0AE), // Verde Fluo brillante! (Green Accent) text: Color(0xFFFFFFFF), ); // --- TEMA LEGNO POTENZIATO --- static const ThemeColors wood = ThemeColors( background: Color(0xFF905D3B), // Marrone caldo e ricco (vero legno) gridLine: Color(0xFF4A301E), // Marrone scurissimo per i solchi playerRed: Color(0xFFE53935), // Rosso acceso per i fiammiferi playerBlue: Color(0xFF29B6F6), // Azzurro acceso per i fiammiferi text: Color(0xFFFBE9E7), // Panna chiaro per contrastare lo scuro ); static ThemeColors getTheme(AppThemeType type) { switch (type) { case AppThemeType.minimal: return minimal; case AppThemeType.doodle: return doodle; case AppThemeType.cyberpunk: return cyberpunk; case AppThemeType.wood: return wood; } } } // =========================================================================== // FILE: lib/core/constants.dart // =========================================================================== class Constants { // Chiavi per salvare i dati sul telefono static const String prefThemeKey = 'selected_theme'; static const String prefLanguageKey = 'selected_language'; static const String prefBoardSizeKey = 'board_size'; // Impostazioni della scacchiera a rombo - RAGGI INCREMENTATI static const int minBoardRadius = 2; // Ex Normale, ora è Piccola static const int maxBoardRadius = 5; // Formato MAX, enorme static const int defaultBoardRadius = 3; // Ora il default è più grande } // =========================================================================== // FILE: lib/core/theme_manager.dart // =========================================================================== import 'package:flutter/material.dart'; import 'app_colors.dart'; import '../services/storage_service.dart'; class ThemeManager extends ChangeNotifier { late AppThemeType _currentThemeType; ThemeManager() { // Quando l'app parte, legge il tema dalla memoria! _currentThemeType = AppThemeType.values[StorageService.instance.savedThemeIndex]; } AppThemeType get currentThemeType => _currentThemeType; ThemeColors get currentColors => AppColors.getTheme(_currentThemeType); void setTheme(AppThemeType type) { _currentThemeType = type; StorageService.instance.saveTheme(type); // Salva la scelta nel "disco fisso" notifyListeners(); } } // =========================================================================== // FILE: lib/firebase_options.dart // =========================================================================== // File generated by FlutterFire CLI. // ignore_for_file: type=lint import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb, TargetPlatform; /// Default [FirebaseOptions] for use with your Firebase apps. /// /// Example: /// ```dart /// import 'firebase_options.dart'; /// // ... /// await Firebase.initializeApp( /// options: DefaultFirebaseOptions.currentPlatform, /// ); /// ``` class DefaultFirebaseOptions { static FirebaseOptions get currentPlatform { if (kIsWeb) { throw UnsupportedError( 'DefaultFirebaseOptions have not been configured for web - ' 'you can reconfigure this by running the FlutterFire CLI again.', ); } switch (defaultTargetPlatform) { case TargetPlatform.android: return android; case TargetPlatform.iOS: return ios; case TargetPlatform.macOS: return macos; case TargetPlatform.windows: throw UnsupportedError( 'DefaultFirebaseOptions have not been configured for windows - ' 'you can reconfigure this by running the FlutterFire CLI again.', ); case TargetPlatform.linux: throw UnsupportedError( 'DefaultFirebaseOptions have not been configured for linux - ' 'you can reconfigure this by running the FlutterFire CLI again.', ); default: throw UnsupportedError( 'DefaultFirebaseOptions are not supported for this platform.', ); } } static const FirebaseOptions android = FirebaseOptions( apiKey: 'AIzaSyBsXO595xVITDPrRnXrW8HPQLOe7Rz4Gg4', appId: '1:705460445314:android:4d35fef29cfd63727b949b', messagingSenderId: '705460445314', projectId: 'tetraq-32a4a', storageBucket: 'tetraq-32a4a.firebasestorage.app', ); static const FirebaseOptions ios = FirebaseOptions( apiKey: 'AIzaSyB77j18Jgeb9gBAEwp-uyOQvr4m-RJ_rAE', appId: '1:705460445314:ios:da11cbca5d1f6bc27b949b', messagingSenderId: '705460445314', projectId: 'tetraq-32a4a', storageBucket: 'tetraq-32a4a.firebasestorage.app', iosBundleId: 'com.sanza.tetraq', ); static const FirebaseOptions macos = FirebaseOptions( apiKey: 'AIzaSyB77j18Jgeb9gBAEwp-uyOQvr4m-RJ_rAE', appId: '1:705460445314:ios:da11cbca5d1f6bc27b949b', messagingSenderId: '705460445314', projectId: 'tetraq-32a4a', storageBucket: 'tetraq-32a4a.firebasestorage.app', iosBundleId: 'com.sanza.tetraq', ); } // =========================================================================== // FILE: lib/logic/ai_engine.dart // =========================================================================== import 'dart:math'; import '../models/game_board.dart'; class AIEngine { static Line getBestMove(GameBoard board, int level) { // IL FIX FONDAMENTALE È QUI: // Filtriamo le linee in modo che la CPU veda SOLO quelle libere E giocabili! List availableLines = board.lines.where((l) => l.owner == Player.none && l.isPlayable).toList(); final random = Random(); // Se per qualche assurdo motivo non ci sono mosse, restituisce la prima linea a caso // (Non dovrebbe mai succedere, ma ci protegge dai crash) if (availableLines.isEmpty) return board.lines.first; // --- FORMULA DI DIFFICOLTÀ --- double smartChance = 0.50 + ((level - 1) * 0.10); if (smartChance > 1.0) smartChance = 1.0; bool beSmart = random.nextDouble() < smartChance; // --- REGOLA 1: Chiudere i quadrati --- List closingMoves = []; for (var line in availableLines) { if (_willCloseBox(board, line)) { closingMoves.add(line); } } if (closingMoves.isNotEmpty) { if (beSmart || random.nextDouble() < 0.70) { return closingMoves[random.nextInt(closingMoves.length)]; } } // --- REGOLA 2: Mosse Sicure --- List safeMoves = []; for (var line in availableLines) { if (_isSafeMove(board, line)) { safeMoves.add(line); } } if (safeMoves.isNotEmpty) { if (beSmart) { return safeMoves[random.nextInt(safeMoves.length)]; } else { if (random.nextDouble() < 0.5) { return safeMoves[random.nextInt(safeMoves.length)]; } } } // --- REGOLA 3: Mossa a caso --- return availableLines[random.nextInt(availableLines.length)]; } static bool _willCloseBox(GameBoard board, Line line) { for (var box in board.boxes) { // Ignora i box invisibili! if (box.type == BoxType.invisible) continue; if (box.top == line || box.bottom == line || box.left == line || box.right == line) { int linesCount = 0; if (box.top.owner != Player.none || box.top == line) linesCount++; if (box.bottom.owner != Player.none || box.bottom == line) linesCount++; if (box.left.owner != Player.none || box.left == line) linesCount++; if (box.right.owner != Player.none || box.right == line) linesCount++; if (linesCount == 4) return true; } } return false; } static bool _isSafeMove(GameBoard board, Line line) { for (var box in board.boxes) { // Ignora i box invisibili! if (box.type == BoxType.invisible) continue; if (box.top == line || box.bottom == line || box.left == line || box.right == line) { int currentLinesCount = 0; if (box.top.owner != Player.none) currentLinesCount++; if (box.bottom.owner != Player.none) currentLinesCount++; if (box.left.owner != Player.none) currentLinesCount++; if (box.right.owner != Player.none) currentLinesCount++; if (currentLinesCount == 2) return false; } } return true; } } // =========================================================================== // FILE: lib/logic/game_controller.dart // =========================================================================== import 'dart:async'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../models/game_board.dart'; import 'ai_engine.dart'; import '../services/audio_service.dart'; import '../services/storage_service.dart'; import '../core/app_colors.dart'; class GameController extends ChangeNotifier { late GameBoard board; bool isVsCPU = false; bool isCPUThinking = false; bool isOnline = false; String? roomCode; bool isHost = false; StreamSubscription? _onlineSubscription; bool opponentLeft = false; bool _hasSavedResult = false; Timer? _blitzTimer; int timeLeft = 15; final int maxTime = 15; String effectText = ''; Color effectColor = Colors.transparent; Timer? _effectTimer; Player get myPlayer => isHost ? Player.red : Player.blue; bool get isGameOver => board.isGameOver; int cpuLevel = 1; int currentMatchLevel = 1; int? currentSeed; AppThemeType _activeTheme = AppThemeType.cyberpunk; String onlineHostName = "ROSSO"; String onlineGuestName = "BLU"; ArenaShape onlineShape = ArenaShape.classic; GameController({int radius = 3}) { cpuLevel = StorageService.instance.cpuLevel; startNewGame(radius); } void startNewGame(int radius, {bool vsCPU = false, bool isOnline = false, String? roomCode, bool isHost = false, ArenaShape shape = ArenaShape.classic}) { _onlineSubscription?.cancel(); _onlineSubscription = null; _blitzTimer?.cancel(); _effectTimer?.cancel(); effectText = ''; _hasSavedResult = false; this.isVsCPU = vsCPU; this.isOnline = isOnline; this.roomCode = roomCode; this.isHost = isHost; // Salviamo la forma scelta anche se siamo offline onlineShape = shape; int levelToUse = isOnline ? (currentMatchLevel == 1 ? 2 : currentMatchLevel) : cpuLevel; // PASSIAMO LA FORMA AL MOTORE! board = GameBoard(radius: radius, level: levelToUse, seed: currentSeed, shape: onlineShape); isCPUThinking = false; opponentLeft = false; if (this.isOnline && this.roomCode != null) { _listenToOnlineGame(this.roomCode!); } _startTimer(); notifyListeners(); } void triggerSpecialEffect(String text, Color color) { effectText = text; effectColor = color; notifyListeners(); _effectTimer?.cancel(); _effectTimer = Timer(const Duration(milliseconds: 1200), () { effectText = ''; notifyListeners(); }); } void _playEffects(List newClosed, {required bool isOpponent}) { if (newClosed.isEmpty) { AudioService.instance.playLineSfx(_activeTheme); if (!isOpponent) HapticFeedback.lightImpact(); } else { bool isGold = newClosed.any((b) => b.type == BoxType.gold); bool isBomb = newClosed.any((b) => b.type == BoxType.bomb); if (isGold) { AudioService.instance.playBonusSfx(); triggerSpecialEffect("+2", Colors.amber); HapticFeedback.heavyImpact(); } else if (isBomb) { AudioService.instance.playBombSfx(); triggerSpecialEffect("-1", Colors.redAccent); HapticFeedback.heavyImpact(); } else { AudioService.instance.playBoxSfx(_activeTheme); HapticFeedback.heavyImpact(); } } } // --- FIX TIMER: SCENDE PER ENTRAMBI, MA SCADE SOLO PER CHI HA IL TURNO --- void _startTimer() { _blitzTimer?.cancel(); timeLeft = maxTime; _blitzTimer = Timer.periodic(const Duration(seconds: 1), (timer) { if (isGameOver || isCPUThinking) { timer.cancel(); return; } if (timeLeft > 0) { timeLeft--; notifyListeners(); } else { timer.cancel(); // IL TIMEOUT FORZA LA MOSSA SOLO SE È IL MIO TURNO // Evita che il telefono in attesa forzi mosse per conto dell'avversario if (!isOnline || board.currentPlayer == myPlayer) { _handleTimeOut(); } } }); } void _handleTimeOut() { if (isOnline) { Line randomMove = AIEngine.getBestMove(board, 5); handleLineTap(randomMove, _activeTheme, forced: true); } else if (isVsCPU && board.currentPlayer == Player.red) { Line randomMove = AIEngine.getBestMove(board, cpuLevel); handleLineTap(randomMove, _activeTheme, forced: true); } else if (!isVsCPU) { Line randomMove = AIEngine.getBestMove(board, 5); handleLineTap(randomMove, _activeTheme, forced: true); } } void disconnectOnlineGame() { _onlineSubscription?.cancel(); _onlineSubscription = null; _blitzTimer?.cancel(); _effectTimer?.cancel(); if (isOnline && roomCode != null) { FirebaseFirestore.instance.collection('games').doc(roomCode).update({'status': 'abandoned'}).catchError((e) => null); } isOnline = false; roomCode = null; currentMatchLevel = 1; currentSeed = null; } void requestRematch() { if (isOnline && roomCode != null) { currentMatchLevel++; int newSeed = DateTime.now().millisecondsSinceEpoch % 1000000; FirebaseFirestore.instance.collection('games').doc(roomCode).update({ 'moves': [], 'matchLevel': currentMatchLevel, 'status': 'playing', 'seed': newSeed, 'shape': onlineShape.name }); } } @override void dispose() { disconnectOnlineGame(); super.dispose(); } void _listenToOnlineGame(String code) { _onlineSubscription = FirebaseFirestore.instance.collection('games').doc(code).snapshots().listen((doc) { if (!doc.exists) return; var data = doc.data() as Map; onlineHostName = data['hostName'] ?? "ROSSO"; onlineGuestName = (data['guestName'] != null && data['guestName'] != '') ? data['guestName'] : "BLU"; if (data['status'] == 'abandoned' && !board.isGameOver && !opponentLeft) { opponentLeft = true; notifyListeners(); return; } List moves = data['moves'] ?? []; int hostLevel = data['matchLevel'] ?? 1; int? hostSeed = data['seed']; String shapeStr = data['shape'] ?? 'classic'; ArenaShape hostShape = ArenaShape.values.firstWhere((e) => e.name == shapeStr, orElse: () => ArenaShape.classic); onlineShape = hostShape; if (hostLevel > currentMatchLevel || (isOnline && currentSeed == null && hostSeed != null) || (hostSeed != null && hostSeed != currentSeed)) { currentMatchLevel = hostLevel; currentSeed = hostSeed; int levelToUse = (currentMatchLevel == 1) ? 2 : currentMatchLevel; board = GameBoard(radius: board.radius, level: levelToUse, seed: currentSeed, shape: onlineShape); isCPUThinking = false; notifyListeners(); return; } int firebaseMovesCount = moves.length; int localMovesCount = board.lines.where((l) => l.owner != Player.none).length; if (firebaseMovesCount == 0 && localMovesCount > 0) { int levelToUse = (currentMatchLevel == 1) ? 2 : currentMatchLevel; board = GameBoard(radius: board.radius, level: levelToUse, seed: currentSeed, shape: onlineShape); notifyListeners(); return; } if (firebaseMovesCount > localMovesCount) { bool newMovesApplied = false; // <-- FIX: Ci segniamo se abbiamo ricevuto mosse for (int i = localMovesCount; i < firebaseMovesCount; i++) { var m = moves[i]; Line? lineToPlay; for (var line in board.lines) { if ((line.p1.x == m['x1'] && line.p1.y == m['y1'] && line.p2.x == m['x2'] && line.p2.y == m['y2']) || (line.p1.x == m['x2'] && line.p1.y == m['y2'] && line.p2.x == m['x1'] && line.p2.y == m['y1'])) { lineToPlay = line; break; } } if (lineToPlay != null && lineToPlay.owner == Player.none) { Player playerFromFirebase = (m['player'] == 'red') ? Player.red : Player.blue; bool isOpponentMove = (playerFromFirebase != myPlayer); List closedBefore = board.boxes.where((b) => b.owner != Player.none).toList(); board.playMove(lineToPlay, forcedPlayer: playerFromFirebase); newMovesApplied = true; List newClosed = board.boxes.where((b) => b.owner != Player.none && !closedBefore.contains(b)).toList(); if (isOpponentMove) _playEffects(newClosed, isOpponent: true); } } // --- FIX TIMER: SE L'AVVERSARIO HA MOSSO, RESETTIAMO IL TIMER --- if (newMovesApplied) { _startTimer(); } if (board.isGameOver) _saveMatchResult(); notifyListeners(); } }); } void handleLineTap(Line line, AppThemeType theme, {bool forced = false}) { if ((isCPUThinking || board.isGameOver || opponentLeft) && !forced) return; if (isOnline && board.currentPlayer != myPlayer && !forced) return; _activeTheme = theme; List closedBefore = board.boxes.where((b) => b.owner != Player.none).toList(); if (board.playMove(line)) { List newClosed = board.boxes.where((b) => b.owner != Player.none && !closedBefore.contains(b)).toList(); if (!forced) _playEffects(newClosed, isOpponent: false); _startTimer(); notifyListeners(); if (isOnline && roomCode != null) { Map moveData = { 'x1': line.p1.x, 'y1': line.p1.y, 'x2': line.p2.x, 'y2': line.p2.y, 'player': myPlayer == Player.red ? 'red' : 'blue' }; FirebaseFirestore.instance.collection('games').doc(roomCode).update({ 'moves': FieldValue.arrayUnion([moveData]) }).catchError((e) => debugPrint("Errore: $e")); if (board.isGameOver) _saveMatchResult(); } else { if (board.isGameOver) _saveMatchResult(); else if (isVsCPU && board.currentPlayer == Player.blue) _checkCPUTurn(); } } } void _checkCPUTurn() async { if (isVsCPU && board.currentPlayer == Player.blue && !board.isGameOver) { isCPUThinking = true; _blitzTimer?.cancel(); notifyListeners(); await Future.delayed(const Duration(milliseconds: 600)); if (!board.isGameOver) { List closedBefore = board.boxes.where((b) => b.owner != Player.none).toList(); Line bestMove = AIEngine.getBestMove(board, cpuLevel); board.playMove(bestMove); List newClosed = board.boxes.where((b) => b.owner != Player.none && !closedBefore.contains(b)).toList(); _playEffects(newClosed, isOpponent: true); isCPUThinking = false; _startTimer(); notifyListeners(); if (board.isGameOver) _saveMatchResult(); else _checkCPUTurn(); } } } void _saveMatchResult() { if (_hasSavedResult) return; _hasSavedResult = true; String myRealName = StorageService.instance.playerName; if (myRealName.isEmpty) myRealName = "IO"; if (isOnline) { String oppName = isHost ? onlineGuestName : onlineHostName; int myScore = isHost ? board.scoreRed : board.scoreBlue; int oppScore = isHost ? board.scoreBlue : board.scoreRed; StorageService.instance.saveMatchToHistory(myName: myRealName, opponent: oppName, myScore: myScore, oppScore: oppScore, isOnline: true); } else if (isVsCPU) { int myScore = board.scoreRed; int cpuScore = board.scoreBlue; if (myScore > cpuScore) StorageService.instance.addWin(); else if (cpuScore > myScore) StorageService.instance.addLoss(); StorageService.instance.saveMatchToHistory(myName: myRealName, opponent: "CPU (Liv. $cpuLevel)", myScore: myScore, oppScore: cpuScore, isOnline: false); } else { StorageService.instance.saveMatchToHistory(myName: myRealName, opponent: "Ospite (Locale)", myScore: board.scoreRed, oppScore: board.scoreBlue, isOnline: false); } } void increaseLevelAndRestart() { cpuLevel++; StorageService.instance.saveCpuLevel(cpuLevel); startNewGame(board.radius, vsCPU: true, shape: board.shape); } } // =========================================================================== // FILE: lib/main.dart // =========================================================================== import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'core/theme_manager.dart'; import 'logic/game_controller.dart'; import 'ui/home/home_screen.dart'; import 'services/storage_service.dart'; // <-- Importiamo il servizio import 'package:firebase_core/firebase_core.dart'; import 'firebase_options.dart'; void main() async { // Assicuriamoci che i motori di Flutter siano pronti WidgetsFlutterBinding.ensureInitialized(); // 1. Accendiamo Firebase! (Questo ti era sfuggito) await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); // 2. Accendiamo la Memoria Locale! await StorageService.instance.init(); runApp( MultiProvider( providers: [ ChangeNotifierProvider(create: (_) => ThemeManager()), ChangeNotifierProvider(create: (_) => GameController()), ], child: const TetraQApp(), ), ); } class TetraQApp extends StatelessWidget { const TetraQApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'TetraQ', debugShowCheckedModeBanner: false, theme: ThemeData( fontFamily: 'Roboto', useMaterial3: true, ), home: const HomeScreen(), ); } } // =========================================================================== // FILE: lib/models/game_board.dart // =========================================================================== import 'dart:math'; enum Player { red, blue, none } enum BoxType { normal, gold, bomb, invisible } enum ArenaShape { classic, cross, donut, hourglass } class Dot { final int x; final int y; Dot(this.x, this.y); @override bool operator ==(Object other) => identical(this, other) || other is Dot && runtimeType == other.runtimeType && x == other.x && y == other.y; @override int get hashCode => x.hashCode ^ y.hashCode; } class Line { final Dot p1; final Dot p2; Player owner = Player.none; bool isPlayable = false; Line(this.p1, this.p2); bool connects(Dot a, Dot b) { return (p1 == a && p2 == b) || (p1 == b && p2 == a); } } class Box { final int x; final int y; Player owner = Player.none; late Line top, bottom, left, right; BoxType type = BoxType.normal; Box(this.x, this.y); bool isClosed() { if (type == BoxType.invisible) return false; return top.owner != Player.none && bottom.owner != Player.none && left.owner != Player.none && right.owner != Player.none; } int get value { if (type == BoxType.gold) return 2; if (type == BoxType.bomb) return -1; return 1; } } class GameBoard { final int radius; final int level; final int? seed; final ArenaShape shape; List dots = []; List lines = []; List boxes = []; Player currentPlayer = Player.red; int scoreRed = 0; int scoreBlue = 0; bool isGameOver = false; GameBoard({required this.radius, this.level = 1, this.seed, this.shape = ArenaShape.classic}) { _generateBoard(); } void _generateBoard() { int size = radius * 2 + 1; final random = seed != null ? Random(seed) : Random(); dots.clear(); lines.clear(); boxes.clear(); for (int y = 0; y < size; y++) { for (int x = 0; x < size; x++) { var box = Box(x, y); // Distanze dal centro della scacchiera int dx = (x - radius).abs(); int dy = (y - radius).abs(); // 1. Controllo base: Deve appartenere al Rombo primario bool isVisible = (dx + dy) <= radius; // 2. LOGICA FORME (Scolpiamo i blocchi dall'interno) if (isVisible) { switch (shape) { case ArenaShape.classic: break; // Nessun taglio case ArenaShape.cross: // Per fare una croce *all'interno* di un rombo, dobbiamo svuotare // 4 "triangolini" vicini agli assi mediani. // La logica è: se non sei sull'asse centrale verticale o orizzontale, sparisci. // Aumentiamo lo spessore del braccio se il raggio è grande. int spessoreBraccio = radius > 3 ? 1 : 0; if (dx > spessoreBraccio && dy > spessoreBraccio) { isVisible = false; } break; case ArenaShape.donut: // Il Buco Nero (Donut). Svuotiamo il nucleo centrale. int dimensioneBuco = radius > 3 ? 2 : 1; if ((dx + dy) <= dimensioneBuco) { isVisible = false; } break; case ArenaShape.hourglass: // La Clessidra. Svuotiamo i due lobi laterali (sinistro e destro). // Vogliamo mantenere solo l'alto e il basso. if (dx > dy) { isVisible = false; } // Manteniamo una singola scatola di giunzione al centro esatto if (x == radius && y == radius) { isVisible = true; } break; } } if (!isVisible) { box.type = BoxType.invisible; } else if (level > 1) { double chance = random.nextDouble(); if (chance < 0.10) { box.type = BoxType.gold; } else if (chance > 0.90) { box.type = BoxType.bomb; } } boxes.add(box); } } // --- PULIZIA DEFINITIVA: ASSEGNAZIONE DELLE LINEE --- for (var box in boxes) { Dot tl = _getOrAddDot(box.x, box.y); Dot tr = _getOrAddDot(box.x + 1, box.y); Dot bl = _getOrAddDot(box.x, box.y + 1); Dot br = _getOrAddDot(box.x + 1, box.y + 1); box.top = _getOrAddLine(tl, tr); box.bottom = _getOrAddLine(bl, br); box.left = _getOrAddLine(tl, bl); box.right = _getOrAddLine(tr, br); // Le linee sono "giocabili" SOLO se appartengono a una scatola VISIBILE if (box.type != BoxType.invisible) { box.top.isPlayable = true; box.bottom.isPlayable = true; box.left.isPlayable = true; box.right.isPlayable = true; } } } Dot _getOrAddDot(int x, int y) { for (var dot in dots) { if (dot.x == x && dot.y == y) return dot; } var newDot = Dot(x, y); dots.add(newDot); return newDot; } Line _getOrAddLine(Dot a, Dot b) { for (var line in lines) { if (line.connects(a, b)) return line; } var newLine = Line(a, b); lines.add(newLine); return newLine; } bool playMove(Line lineToPlay, {Player? forcedPlayer}) { if (isGameOver) return false; Player playerMakingMove = forcedPlayer ?? currentPlayer; Line? actualLine; for (var l in lines) { if (l.connects(lineToPlay.p1, lineToPlay.p2)) { actualLine = l; break; } } if (actualLine == null || actualLine.owner != Player.none || !actualLine.isPlayable) return false; actualLine.owner = playerMakingMove; bool boxedClosed = false; for (var box in boxes) { if (box.owner == Player.none && box.isClosed()) { box.owner = playerMakingMove; boxedClosed = true; if (playerMakingMove == Player.red) { scoreRed += box.value; } else { scoreBlue += box.value; } } } if (lines.where((l) => l.isPlayable).every((l) => l.owner != Player.none)) { isGameOver = true; } if (forcedPlayer == null) { if (!boxedClosed && !isGameOver) { currentPlayer = (currentPlayer == Player.red) ? Player.blue : Player.red; } } else { if (!boxedClosed && !isGameOver) { currentPlayer = (forcedPlayer == Player.red) ? Player.blue : Player.red; } else { currentPlayer = forcedPlayer; } } return true; } } // =========================================================================== // FILE: lib/models/player_info.dart // =========================================================================== // =========================================================================== // FILE: lib/services/audio_service.dart // =========================================================================== import 'package:flutter/material.dart'; import 'package:audioplayers/audioplayers.dart'; import '../core/app_colors.dart'; class AudioService extends ChangeNotifier { static final AudioService instance = AudioService._internal(); AudioService._internal(); bool isMuted = false; final AudioPlayer _sfxPlayer = AudioPlayer(); void toggleMute() { isMuted = !isMuted; notifyListeners(); } void playLineSfx(AppThemeType theme) async { if (isMuted) return; String file = ''; switch (theme) { case AppThemeType.minimal: file = 'minimal_line.wav'; break; case AppThemeType.doodle: case AppThemeType.wood: file = 'doodle_line.wav'; break; case AppThemeType.cyberpunk: file = 'cyber_line.wav'; break; } await _sfxPlayer.play(AssetSource('audio/sfx/$file')); } void playBoxSfx(AppThemeType theme) async { if (isMuted) return; String file = ''; switch (theme) { case AppThemeType.minimal: file = 'minimal_box.wav'; break; case AppThemeType.doodle: case AppThemeType.wood: file = 'doodle_box.wav'; break; case AppThemeType.cyberpunk: file = 'cyber_box.wav'; break; } await _sfxPlayer.play(AssetSource('audio/sfx/$file')); } // --- NUOVI EFFETTI SPECIALI --- void playBonusSfx() async { if (isMuted) return; // Assicurati di aggiungere questo file nella cartella assets/audio/sfx/ await _sfxPlayer.play(AssetSource('audio/sfx/bonus.wav')); } void playBombSfx() async { if (isMuted) return; // Assicurati di aggiungere questo file nella cartella assets/audio/sfx/ await _sfxPlayer.play(AssetSource('audio/sfx/bomb.wav')); } } // =========================================================================== // FILE: lib/services/firebase_service.dart // =========================================================================== // =========================================================================== // FILE: lib/services/multiplayer_service.dart // =========================================================================== import 'dart:math'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:share_plus/share_plus.dart'; class MultiplayerService { final FirebaseFirestore _firestore = FirebaseFirestore.instance; CollectionReference get _gamesCollection => _firestore.collection('games'); // AGGIUNTO shapeName Future createGameRoom(int boardRadius, String hostName, String shapeName) async { String roomCode = _generateRoomCode(); int randomSeed = Random().nextInt(1000000); await _gamesCollection.doc(roomCode).set({ 'status': 'waiting', 'radius': boardRadius, 'createdAt': FieldValue.serverTimestamp(), 'players': ['host'], 'turn': 'host', 'moves': [], 'seed': randomSeed, 'hostName': hostName, 'guestName': '', 'shape': shapeName, // <-- SALVIAMO LA FORMA DELL'ARENA SU FIREBASE }); return roomCode; } Future?> joinGameRoom(String roomCode, String guestName) async { DocumentSnapshot doc = await _gamesCollection.doc(roomCode).get(); if (doc.exists && doc['status'] == 'waiting') { await _gamesCollection.doc(roomCode).update({ 'status': 'playing', 'players': FieldValue.arrayUnion(['guest']), 'guestName': guestName, }); return doc.data() as Map; } return null; } void shareInviteLink(String roomCode) { String message = "Ehi! Giochiamo a TetraQ? 🎮\nCopia questo intero messaggio e apri l'app per entrare direttamente, oppure inserisci manualmente il codice: $roomCode"; Share.share(message); } Stream listenToRoom(String roomCode) { return _gamesCollection.doc(roomCode).snapshots(); } String _generateRoomCode() { const chars = 'ACDEFGHJKLMNPQRSTUVWXYZ2345679'; final random = Random(); return String.fromCharCodes(Iterable.generate( 5, (_) => chars.codeUnitAt(random.nextInt(chars.length)), )); } } // =========================================================================== // FILE: lib/services/storage_service.dart // =========================================================================== import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; import '../core/app_colors.dart'; class StorageService { static final StorageService instance = StorageService._internal(); StorageService._internal(); late SharedPreferences _prefs; // Si avvia quando apriamo l'app Future init() async { _prefs = await SharedPreferences.getInstance(); } // --- IMPOSTAZIONI --- int get savedThemeIndex => _prefs.getInt('theme') ?? AppThemeType.cyberpunk.index; Future saveTheme(AppThemeType theme) async => await _prefs.setInt('theme', theme.index); int get savedRadius => _prefs.getInt('radius') ?? 2; Future saveRadius(int radius) async => await _prefs.setInt('radius', radius); bool get isMuted => _prefs.getBool('isMuted') ?? false; Future saveMuted(bool muted) async => await _prefs.setBool('isMuted', muted); // --- STATISTICHE VS CPU --- int get wins => _prefs.getInt('wins') ?? 0; Future addWin() async => await _prefs.setInt('wins', wins + 1); int get losses => _prefs.getInt('losses') ?? 0; Future addLoss() async => await _prefs.setInt('losses', losses + 1); int get cpuLevel => _prefs.getInt('cpuLevel') ?? 1; Future saveCpuLevel(int level) async => await _prefs.setInt('cpuLevel', level); // --- MULTIPLAYER --- String get playerName => _prefs.getString('playerName') ?? ''; Future savePlayerName(String name) async => await _prefs.setString('playerName', name); // --- STORICO PARTITE --- List> get matchHistory { List history = _prefs.getStringList('matchHistory') ?? []; return history.map((e) => jsonDecode(e) as Map).toList(); } // Salviamo sia il nostro nome che quello dell'avversario Future saveMatchToHistory({required String myName, required String opponent, required int myScore, required int oppScore, required bool isOnline}) async { List history = _prefs.getStringList('matchHistory') ?? []; Map match = { 'date': DateTime.now().toIso8601String(), 'myName': myName, 'opponent': opponent, 'myScore': myScore, 'oppScore': oppScore, 'isOnline': isOnline, }; // Aggiungiamo in cima (il più recente per primo) history.insert(0, jsonEncode(match)); // Teniamo solo le ultime 50 partite per non intasare la memoria if (history.length > 50) { history = history.sublist(0, 50); } await _prefs.setStringList('matchHistory', history); } } // =========================================================================== // FILE: lib/ui/game/board_painter.dart // =========================================================================== import 'dart:math'; import 'package:flutter/material.dart'; import '../../models/game_board.dart'; import '../../core/app_colors.dart'; class BoardPainter extends CustomPainter { final GameBoard board; final ThemeColors theme; final AppThemeType themeType; BoardPainter({required this.board, required this.theme, required this.themeType}); @override void paint(Canvas canvas, Size size) { if (themeType == AppThemeType.doodle) { final Paint paperGridPaint = Paint() ..color = Colors.grey.withOpacity(0.3) ..strokeWidth = 1.0 ..style = PaintingStyle.stroke; double paperStep = 20.0; for (double i = 0; i <= size.width; i += paperStep) { canvas.drawLine(Offset(i, 0), Offset(i, size.height), paperGridPaint); } for (double i = 0; i <= size.height; i += paperStep) { canvas.drawLine(Offset(0, i), Offset(size.width, i), paperGridPaint); } } int gridPoints = board.radius * 2 + 2; double spacing = size.width / gridPoints; double offset = spacing / 2; Offset getScreenPos(int x, int y) => Offset(x * spacing + offset, y * spacing + offset); // --- 1. DISEGNO AREE CONQUISTATE E ICONE --- for (var box in board.boxes) { // Ignoriamo totalmente le scatole invisibili (tagliate via dalle nuove forme) if (box.type == BoxType.invisible) continue; Offset p1 = getScreenPos(box.x, box.y); Offset p2 = getScreenPos(box.x + 1, box.y + 1); Rect rect = Rect.fromPoints(p1, p2); if (box.owner != Player.none) { final boxPaint = Paint() ..style = PaintingStyle.fill ..color = box.owner == Player.red ? theme.playerRed.withOpacity(0.6) : theme.playerBlue.withOpacity(0.6); if (themeType == AppThemeType.wood) { _drawFlameBox(canvas, rect, box.owner == Player.red); } else if (themeType == AppThemeType.doodle) { Color penColor = box.owner == Player.red ? Colors.redAccent.shade700 : Colors.blueAccent.shade700; _drawScribbleBox(canvas, rect, penColor); } else { canvas.drawRect(rect, boxPaint); } } if (box.type == BoxType.gold) { _drawIconInBox(canvas, rect, Icons.star_rounded, Colors.amber); } else if (box.type == BoxType.bomb) { _drawIconInBox(canvas, rect, Icons.mood_bad_rounded, themeType == AppThemeType.cyberpunk ? Colors.greenAccent : Colors.deepPurple); } } // --- 2. DISEGNO LINEE --- for (var line in board.lines) { // FIX FONDAMENTALE PER LE FORME: Non disegnare linee non giocabili! if (!line.isPlayable) continue; Offset p1 = getScreenPos(line.p1.x, line.p1.y); Offset p2 = getScreenPos(line.p2.x, line.p2.y); Color lineColor = line.owner == Player.none ? theme.gridLine.withOpacity(0.4) : (line.owner == Player.red ? theme.playerRed : theme.playerBlue); if (themeType == AppThemeType.wood) { if (line.owner == Player.none) { canvas.drawLine(p1, p2, Paint()..color = const Color(0xFF3E2723).withOpacity(0.3)..strokeWidth = 4.5..strokeCap = StrokeCap.round); } else { _drawRealisticMatch(canvas, p1, p2, lineColor); } } else if (themeType == AppThemeType.cyberpunk) { _drawNeonLine(canvas, p1, p2, lineColor, line.owner != Player.none); } else if (themeType == AppThemeType.doodle) { Color doodleColor = line.owner == Player.none ? Colors.black.withOpacity(0.05) : lineColor; _drawWobblyLine(canvas, p1, p2, doodleColor, line.owner != Player.none); } else { canvas.drawLine(p1, p2, Paint()..color = lineColor..strokeWidth = 6.0..strokeCap = StrokeCap.round); } } // --- 3. DISEGNO PUNTINI (SOLO QUELLI UTILI) --- final dotPaint = Paint()..style = PaintingStyle.fill; // Raccogliamo solo i puntini che appartengono a linee giocabili Set activeDots = {}; for (var line in board.lines) { if (line.isPlayable) { activeDots.add(line.p1); activeDots.add(line.p2); } } for (var dot in activeDots) { Offset pos = getScreenPos(dot.x, dot.y); if (themeType == AppThemeType.wood) { canvas.drawCircle(pos, 3.5, dotPaint..color = const Color(0xFF3E2723).withOpacity(0.2)); } else if (themeType == AppThemeType.cyberpunk) { canvas.drawCircle(pos, 6.0, Paint()..color = theme.gridLine.withOpacity(0.3)); canvas.drawCircle(pos, 3.0, Paint()..color = Colors.white.withOpacity(0.5)); } else if (themeType == AppThemeType.doodle) { canvas.drawRect(Rect.fromCenter(center: pos, width: 4, height: 4), dotPaint..color = Colors.black.withOpacity(0.25)); } else { canvas.drawCircle(pos, 5.0, dotPaint..color = theme.text.withOpacity(0.6)); } } } void _drawIconInBox(Canvas canvas, Rect rect, IconData icon, Color color) { TextPainter textPainter = TextPainter(textDirection: TextDirection.ltr); textPainter.text = TextSpan( text: String.fromCharCode(icon.codePoint), style: TextStyle( color: color.withOpacity(0.7), fontSize: rect.width * 0.45, fontFamily: icon.fontFamily, package: icon.fontPackage, shadows: [Shadow(color: color.withOpacity(0.6), blurRadius: 10, offset: const Offset(0, 0))] ), ); textPainter.layout(); textPainter.paint(canvas, Offset(rect.center.dx - textPainter.width / 2, rect.center.dy - textPainter.height / 2)); } void _drawFlameBox(Canvas canvas, Rect baseRect, bool isRed) { final rand = Random((baseRect.left + baseRect.top).toInt()); Offset center = baseRect.center; double w = baseRect.width * 0.35; double h = baseRect.height * 0.55; Offset bottomCenter = Offset(center.dx, center.dy + h * 0.5); Color outerColor = isRed ? Colors.red.shade600.withOpacity(0.85) : Colors.blue.shade700.withOpacity(0.85); Color midColor = isRed ? Colors.orangeAccent : Colors.lightBlueAccent; Color coreColor = isRed ? Colors.yellowAccent : Colors.white; canvas.drawOval( Rect.fromCenter(center: bottomCenter, width: w * 1.5, height: w * 0.5), Paint() ..color = Colors.black.withOpacity(0.4) ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4.0), ); void drawFlameLayer(double scale, Color color, double tipOffsetX) { Path path = Path(); double fw = w * scale; double fh = h * scale; path.moveTo(bottomCenter.dx, bottomCenter.dy); path.cubicTo( bottomCenter.dx + fw, bottomCenter.dy, bottomCenter.dx + fw * 0.8, bottomCenter.dy - fh * 0.6, bottomCenter.dx + tipOffsetX, bottomCenter.dy - fh, ); path.cubicTo( bottomCenter.dx - fw * 0.8, bottomCenter.dy - fh * 0.6, bottomCenter.dx - fw, bottomCenter.dy, bottomCenter.dx, bottomCenter.dy, ); canvas.drawPath( path, Paint() ..color = color ..style = PaintingStyle.fill ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 1.5), ); } double randomTipX = (rand.nextDouble() - 0.5) * w * 0.8; drawFlameLayer(1.0, outerColor, randomTipX); drawFlameLayer(0.65, midColor.withOpacity(0.9), randomTipX * 0.6); drawFlameLayer(0.35, coreColor.withOpacity(0.9), randomTipX * 0.2); } void _drawScribbleBox(Canvas canvas, Rect baseRect, Color color) { final rand = Random((baseRect.left + baseRect.top).toInt()); final paint = Paint() ..color = color.withOpacity(0.85) ..style = PaintingStyle.stroke ..strokeWidth = 3.5 ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round; final path = Path(); Rect rect = baseRect.deflate(4.0); int numZigs = 15 + rand.nextInt(6); double stepY = rect.height / numZigs; path.moveTo(rect.left + rand.nextDouble() * 5, rect.top + rand.nextDouble() * 5); for (int i = 1; i <= numZigs; i++) { double targetX = (i % 2 != 0) ? rect.right + (rand.nextDouble() * 4 - 2) : rect.left + (rand.nextDouble() * 4 - 2); double targetY = rect.top + stepY * i + (rand.nextDouble() - 0.5) * 3; double ctrlX = rect.center.dx + (rand.nextDouble() - 0.5) * 20; double ctrlY = targetY - stepY / 2; path.quadraticBezierTo(ctrlX, ctrlY, targetX, targetY); } canvas.drawPath(path, paint); } void _drawRealisticMatch(Canvas canvas, Offset p1, Offset p2, Color headColor) { int seed = (p1.dx * 1000 + p1.dy).toInt(); Random rand = Random(seed); Vector2 dir = Vector2(p2.dx - p1.dx, p2.dy - p1.dy).normalized(); double shrink = 8.0; Offset start = Offset(p1.dx + dir.x * shrink, p1.dy + dir.y * shrink); Offset end = Offset(p2.dx - dir.x * shrink, p2.dy - dir.y * shrink); start += Offset(rand.nextDouble() * 4 - 2, rand.nextDouble() * 4 - 2); end += Offset(rand.nextDouble() * 4 - 2, rand.nextDouble() * 4 - 2); bool headAtEnd = rand.nextBool(); Offset headPos = headAtEnd ? end : start; Offset tailPos = headAtEnd ? start : end; Vector2 matchDir = Vector2(headPos.dx - tailPos.dx, headPos.dy - tailPos.dy).normalized(); canvas.drawLine(tailPos + const Offset(4, 4), headPos + const Offset(4, 4), Paint()..color = Colors.black.withOpacity(0.6)..strokeWidth = 7.0..strokeCap = StrokeCap.round); canvas.drawLine(tailPos, headPos, Paint()..color = const Color(0xFF6D4C41)..strokeWidth = 7.0..strokeCap = StrokeCap.round); canvas.drawLine(tailPos, headPos, Paint()..color = const Color(0xFFEDC498)..strokeWidth = 4.0..strokeCap = StrokeCap.round); Offset burnPos = Offset(headPos.dx - matchDir.x * 8, headPos.dy - matchDir.y * 8); canvas.drawLine(burnPos, headPos, Paint()..color = const Color(0xFF2E1A14)..strokeWidth = 6.0..strokeCap = StrokeCap.round); canvas.save(); canvas.translate(headPos.dx, headPos.dy); double angle = atan2(matchDir.y, matchDir.x); canvas.rotate(angle); Rect headOval = Rect.fromCenter(center: Offset.zero, width: 18.0, height: 13.0); canvas.drawOval(headOval.shift(const Offset(1, 2)), Paint()..color = Colors.black.withOpacity(0.6)); canvas.drawOval(headOval, Paint()..color = headColor); canvas.restore(); } void _drawNeonLine(Canvas canvas, Offset p1, Offset p2, Color color, bool isConquered) { double mainWidth = isConquered ? 6.0 : 3.0; canvas.drawLine(p1, p2, Paint() ..color = color.withOpacity(isConquered ? 0.4 : 0.2) ..strokeWidth = mainWidth * 4 ..strokeCap = StrokeCap.round ..maskFilter = MaskFilter.blur(BlurStyle.normal, isConquered ? 12.0 : 6.0) ); if (isConquered) { canvas.drawLine(p1, p2, Paint() ..color = color.withOpacity(0.7) ..strokeWidth = mainWidth * 2 ..strokeCap = StrokeCap.round ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6.0) ); } canvas.drawLine(p1, p2, Paint() ..color = isConquered ? Colors.white.withOpacity(0.9) : color.withOpacity(0.6) ..strokeWidth = mainWidth ..strokeCap = StrokeCap.round ); } void _drawWobblyLine(Canvas canvas, Offset p1, Offset p2, Color color, bool isConquered) { final random = Random((p1.dx + p1.dy + p2.dx + p2.dy).toInt()); final dx = p2.dx - p1.dx; final dy = p2.dy - p1.dy; double strokeW = isConquered ? 4.5 : 2.0; final basePaint = Paint() ..color = color ..strokeWidth = strokeW ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round; final mid1 = Offset(p1.dx + dx / 2 + (random.nextDouble() - 0.5) * 8, p1.dy + dy / 2 + (random.nextDouble() - 0.5) * 8); canvas.drawPath(Path()..moveTo(p1.dx, p1.dy)..quadraticBezierTo(mid1.dx, mid1.dy, p2.dx, p2.dy), basePaint); final mid2 = Offset(p1.dx + dx / 2 + (random.nextDouble() - 0.5) * 6, p1.dy + dy / 2 + (random.nextDouble() - 0.5) * 6); canvas.drawPath(Path()..moveTo(p1.dx, p1.dy)..quadraticBezierTo(mid2.dx, mid2.dy, p2.dx, p2.dy), basePaint..strokeWidth = strokeW * 0.5..color = color.withOpacity(0.8)); } @override bool shouldRepaint(covariant BoardPainter oldDelegate) => true; } class Vector2 { final double x, y; Vector2(this.x, this.y); double get length => sqrt(x * x + y * y); Vector2 normalized() { double l = length; return l == 0 ? Vector2(0, 0) : Vector2(x / l, y / l); } } // =========================================================================== // FILE: lib/ui/game/game_screen.dart // =========================================================================== import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../logic/game_controller.dart'; import '../../core/theme_manager.dart'; import '../../core/app_colors.dart'; import 'board_painter.dart'; import 'score_board.dart'; import '../../widgets/game_over_dialog.dart'; import '../../models/game_board.dart'; class GameScreen extends StatelessWidget { const GameScreen({super.key}); @override Widget build(BuildContext context) { final themeManager = context.watch(); final themeType = themeManager.currentThemeType; final theme = themeManager.currentColors; final gameController = context.watch(); // --- CONTROLLO EVENTI DI FINE PARTITA O ABBANDONO --- WidgetsBinding.instance.addPostFrameCallback((_) { if (gameController.opponentLeft) { showDialog( barrierDismissible: false, context: context, builder: (dialogContext) => AlertDialog( backgroundColor: theme.background, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), title: Text("VITTORIA A TAVOLINO!", textAlign: TextAlign.center, style: TextStyle(color: theme.playerRed, fontWeight: FontWeight.bold)), content: Text("L'avversario ha abbandonato la stanza.\nSei il vincitore incontestato!", textAlign: TextAlign.center, style: TextStyle(color: theme.text, fontSize: 16)), actionsAlignment: MainAxisAlignment.center, actions: [ ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: theme.playerBlue, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)) ), onPressed: () { gameController.disconnectOnlineGame(); Navigator.pop(dialogContext); Navigator.pop(context); }, child: const Text("MENU PRINCIPALE", style: TextStyle(fontWeight: FontWeight.bold)), ) ], ) ); } else if (gameController.board.isGameOver) { showDialog(barrierDismissible: false, context: context, builder: (_) => const GameOverDialog()); } }); String? bgImage; if (themeType == AppThemeType.wood) bgImage = 'assets/images/wood_bg.jpg'; if (themeType == AppThemeType.doodle) bgImage = 'assets/images/doodle_bg.jpg'; Color indicatorColor = themeType == AppThemeType.cyberpunk ? Colors.white : Colors.black; // --- IL CONTENUTO PRINCIPALE (Plancia e UI) --- Widget gameContent = SafeArea( child: Column( children: [ const ScoreBoard(), Expanded( child: Center( child: Padding( padding: const EdgeInsets.all(10.0), child: AspectRatio( aspectRatio: 1, child: LayoutBuilder( builder: (context, constraints) { return GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (details) => _handleTap(details.localPosition, constraints.maxWidth, gameController, themeType), child: CustomPaint( size: Size(constraints.maxWidth, constraints.maxHeight), painter: BoardPainter( board: gameController.board, theme: theme, themeType: themeType, ), ), ); } ), ), ), ), ), // --- ZONA INFERIORE: INDICATORE A SX, PULSANTE ESCI A DX --- Padding( padding: const EdgeInsets.only(bottom: 20.0, left: 20.0, right: 20.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ if (gameController.isVsCPU) Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: indicatorColor.withOpacity(0.1), borderRadius: BorderRadius.circular(20), border: Border.all(color: indicatorColor.withOpacity(0.3)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.smart_toy_rounded, size: 16, color: indicatorColor), const SizedBox(width: 8), Text( "LIVELLO CPU: ${gameController.cpuLevel}", style: TextStyle( color: indicatorColor, fontWeight: FontWeight.bold, fontSize: 13, letterSpacing: 1.0, ), ), ], ), ) else const SizedBox(), // --- PULSANTE ESCI --- Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.4), offset: const Offset(0, 4), blurRadius: 5, ), ], ), child: TextButton.icon( style: TextButton.styleFrom( backgroundColor: bgImage != null ? 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 ? Colors.white : theme.text, size: 20), onPressed: () { gameController.disconnectOnlineGame(); Navigator.pop(context); }, label: Text("ESCI", style: TextStyle(color: bgImage != null ? Colors.white : theme.text, fontWeight: FontWeight.bold, fontSize: 14)), ), ), ], ), ) ], ), ); // --- STRUTTURA A LIVELLI (STACK) DELLO SCHERMO --- return PopScope( canPop: true, onPopInvoked: (didPop) { gameController.disconnectOnlineGame(); }, child: Scaffold( backgroundColor: bgImage != null ? Colors.transparent : theme.background, body: CustomPaint( painter: themeType == AppThemeType.minimal ? FullScreenGridPainter(Colors.black.withOpacity(0.06)) : null, child: Container( decoration: bgImage != null ? BoxDecoration( image: DecorationImage( image: AssetImage(bgImage), fit: BoxFit.cover, colorFilter: themeType == AppThemeType.doodle ? ColorFilter.mode(Colors.white.withOpacity(0.7), BlendMode.lighten) : null, ), ) : null, // ========================================== // LA MAGIA DELLO SFARFALLIO AVVIENE QUI // ========================================== child: Stack( children: [ // EFFETTO 1: TIMER LAMPEGGIANTE (Se restano 5 secondi) if (!gameController.isCPUThinking && !gameController.isGameOver && gameController.timeLeft > 0 && gameController.timeLeft <= 5) Positioned.fill( child: BlitzBackgroundEffect( timeLeft: gameController.timeLeft, color: theme.playerRed, // Rosso Allarme! ), ), // --- NUOVO EFFETTO 2: POPUP ORO (+2) E BOMBA (-1) --- if (gameController.effectText.isNotEmpty) Positioned.fill( child: SpecialEventBackgroundEffect( text: gameController.effectText, color: gameController.effectColor, ), ), // LA PLANCIA DI GIOCO SOPRA TUTTI GLI EFFETTI Positioned.fill( child: gameContent, ), ], ), ), ), ), ); } // ========================================== // NUOVA GESTIONE DEI TOCCHI E DELLE DISTANZE // ========================================== void _handleTap(Offset tapPos, double size, GameController controller, AppThemeType themeType) { final board = controller.board; if (board.isGameOver) return; int gridPoints = board.radius * 2 + 2; double spacing = size / gridPoints; double offset = spacing / 2; Line? closestLine; double minDistance = double.infinity; double maxTouchDistance = spacing * 0.4; // Scorriamo TUTTE le linee valide della plancia for (var line in board.lines) { // FIX FONDAMENTALE: Ignoriamo le linee invisibili o già prese! if (line.owner != Player.none || !line.isPlayable) continue; Offset screenP1 = Offset(line.p1.x * spacing + offset, line.p1.y * spacing + offset); Offset screenP2 = Offset(line.p2.x * spacing + offset, line.p2.y * spacing + offset); double dist = _distanceToSegment(tapPos, screenP1, screenP2); if (dist < minDistance && dist < maxTouchDistance) { minDistance = dist; closestLine = line; } } if (closestLine != null) { controller.handleLineTap(closestLine, themeType); } } // Calcolo della distanza su coordinate schermo reali double _distanceToSegment(Offset p, Offset a, Offset b) { double l2 = (a.dx - b.dx) * (a.dx - b.dx) + (a.dy - b.dy) * (a.dy - b.dy); if (l2 == 0) return (p - a).distance; double t = (((p.dx - a.dx) * (b.dx - a.dx) + (p.dy - a.dy) * (b.dy - a.dy)) / l2).clamp(0.0, 1.0); Offset projection = Offset(a.dx + t * (b.dx - a.dx), a.dy + t * (b.dy - a.dy)); return (p - projection).distance; } } // --- CLASSE PER LO SFONDO A QUADRETTI --- class FullScreenGridPainter extends CustomPainter { final Color gridColor; FullScreenGridPainter(this.gridColor); @override void paint(Canvas canvas, Size size) { final Paint paperGridPaint = Paint() ..color = gridColor ..strokeWidth = 1.0 ..style = PaintingStyle.stroke; double paperStep = 20.0; for (double i = 0; i <= size.width; i += paperStep) { canvas.drawLine(Offset(i, 0), Offset(i, size.height), paperGridPaint); } for (double i = 0; i <= size.height; i += paperStep) { canvas.drawLine(Offset(0, i), Offset(size.width, i), paperGridPaint); } } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } // ============================================================== // WIDGET ANIMATO PER IL TIMER (BATTITO CARDIACO / ALLARME) // ============================================================== class BlitzBackgroundEffect extends StatefulWidget { final int timeLeft; final Color color; const BlitzBackgroundEffect({super.key, required this.timeLeft, required this.color}); @override State createState() => _BlitzBackgroundEffectState(); } class _BlitzBackgroundEffectState extends State with SingleTickerProviderStateMixin { late AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 400))..repeat(reverse: true); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, builder: (context, child) { return Container( color: widget.color.withOpacity(0.12 * _controller.value), child: Center( child: ImageFiltered( imageFilter: ImageFilter.blur(sigmaX: 2.0, sigmaY: 2.0), child: Text( '${widget.timeLeft}', style: TextStyle( fontSize: 300, fontWeight: FontWeight.w900, color: widget.color.withOpacity(0.35 + (0.3 * _controller.value)), height: 1.0, ), ), ), ), ); }, ); } } // ============================================================== // WIDGET ANIMATO PER LE CASELLE SPECIALI (+2 / -1) // ============================================================== class SpecialEventBackgroundEffect extends StatefulWidget { final String text; final Color color; const SpecialEventBackgroundEffect({super.key, required this.text, required this.color}); @override State createState() => _SpecialEventBackgroundEffectState(); } class _SpecialEventBackgroundEffectState extends State with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _scaleAnimation; late Animation _opacityAnimation; @override void initState() { super.initState(); _controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 1000))..forward(); _scaleAnimation = Tween(begin: 0.5, end: 1.5).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); _opacityAnimation = Tween(begin: 0.9, end: 0.0).animate(CurvedAnimation(parent: _controller, curve: Curves.easeIn)); } @override void didUpdateWidget(covariant SpecialEventBackgroundEffect oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.text != widget.text) { _controller.reset(); _controller.forward(); } } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, builder: (context, child) { return Center( child: Transform.scale( scale: _scaleAnimation.value, child: Opacity( opacity: _opacityAnimation.value, child: ImageFiltered( imageFilter: ImageFilter.blur(sigmaX: 3.0, sigmaY: 3.0), child: Text( widget.text, style: TextStyle( fontSize: 250, fontWeight: FontWeight.w900, color: widget.color, height: 1.0, ), ), ), ), ), ); }, ); } } // =========================================================================== // FILE: lib/ui/game/score_board.dart // =========================================================================== import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../logic/game_controller.dart'; import '../../models/game_board.dart'; import '../../core/theme_manager.dart'; import '../../services/audio_service.dart'; import '../../core/app_colors.dart'; class ScoreBoard extends StatefulWidget { const ScoreBoard({super.key}); @override State createState() => _ScoreBoardState(); } class _ScoreBoardState extends State { @override Widget build(BuildContext context) { final controller = context.watch(); final themeManager = context.watch(); final theme = themeManager.currentColors; final themeType = themeManager.currentThemeType; int redScore = controller.board.scoreRed; int blueScore = controller.board.scoreBlue; bool isRedTurn = controller.board.currentPlayer == Player.red; bool isMuted = AudioService.instance.isMuted; // --- LOGICA PER I NOMI --- String nameRed = "ROSSO"; String nameBlue = themeType == AppThemeType.cyberpunk ? "VERDE" : "BLU"; if (controller.isOnline) { nameRed = controller.onlineHostName.toUpperCase(); nameBlue = controller.onlineGuestName.toUpperCase(); } else if (controller.isVsCPU) { nameRed = "TU"; nameBlue = "CPU"; } return Container( padding: const EdgeInsets.only(top: 10, bottom: 20, left: 20, right: 20), decoration: BoxDecoration( color: theme.background.withOpacity(0.95), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.3), offset: const Offset(0, 4), blurRadius: 8, ), ], borderRadius: const BorderRadius.only( bottomLeft: Radius.circular(30), bottomRight: Radius.circular(30), ), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _PlayerScore(color: theme.playerRed, score: redScore, isTurn: isRedTurn, textColor: theme.text, title: nameRed), Column( mainAxisSize: MainAxisSize.min, children: [ Text( "TETRAQ", style: TextStyle( fontSize: 24, fontWeight: FontWeight.w900, color: theme.text, letterSpacing: 4, shadows: [Shadow(color: Colors.black.withOpacity(0.3), offset: const Offset(1, 2), blurRadius: 2)] ) ), IconButton( icon: Icon(isMuted ? Icons.volume_off : Icons.volume_up, color: theme.text.withOpacity(0.7)), onPressed: () { setState(() { AudioService.instance.toggleMute(); }); }, ), ], ), _PlayerScore(color: theme.playerBlue, score: blueScore, isTurn: !isRedTurn, textColor: theme.text, title: nameBlue), ], ), ); } } class _PlayerScore extends StatelessWidget { final Color color; final int score; final bool isTurn; final Color textColor; final String title; const _PlayerScore({required this.color, required this.score, required this.isTurn, required this.textColor, required this.title}); @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: [ Text(title, style: TextStyle(fontWeight: FontWeight.bold, color: isTurn ? color : textColor.withOpacity(0.5), fontSize: 12)), const SizedBox(height: 5), AnimatedContainer( duration: const Duration(milliseconds: 300), padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), decoration: BoxDecoration( color: color.withOpacity(isTurn ? 1.0 : 0.2), borderRadius: BorderRadius.circular(15), border: isTurn ? Border.all(color: Colors.white.withOpacity(0.4), width: 2) : Border.all(color: Colors.transparent, width: 2), boxShadow: isTurn ? [ BoxShadow(color: color.withOpacity(0.5), offset: const Offset(0, 4), blurRadius: 6) ] : [], ), child: Text('$score', style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: isTurn ? Colors.white : textColor.withOpacity(0.5))), ), ], ); } } // =========================================================================== // FILE: lib/ui/home/history_screen.dart // =========================================================================== import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; import '../../core/theme_manager.dart'; import '../../services/storage_service.dart'; class HistoryScreen extends StatelessWidget { const HistoryScreen({super.key}); @override Widget build(BuildContext context) { final theme = context.watch().currentColors; final history = StorageService.instance.matchHistory; return Scaffold( backgroundColor: theme.background, appBar: AppBar( title: Text("STORICO PARTITE", style: TextStyle(fontWeight: FontWeight.w900, color: theme.text, letterSpacing: 2)), backgroundColor: Colors.transparent, elevation: 0, iconTheme: IconThemeData(color: theme.text), ), body: history.isEmpty ? Center( child: Text( "Nessuna partita giocata.\nScendi in campo!", textAlign: TextAlign.center, style: TextStyle(color: theme.text.withOpacity(0.5), fontSize: 18, fontWeight: FontWeight.bold), ), ) : ListView.builder( padding: const EdgeInsets.all(20), itemCount: history.length, itemBuilder: (context, index) { final match = history[index]; DateTime date = DateTime.parse(match['date']); String formattedDate = DateFormat('dd MMM yyyy - HH:mm').format(date); // Leggiamo entrambi i nomi String myName = match['myName'] ?? "IO"; // Usa 'IO' se è una partita vecchia String opponent = match['opponent']; int myScore = match['myScore']; int oppScore = match['oppScore']; bool isOnline = match['isOnline']; bool isWin = myScore > oppScore; bool isDraw = myScore == oppScore; Color resultColor = isWin ? Colors.green : (isDraw ? Colors.grey : theme.playerRed); String resultText = isWin ? "VITTORIA" : (isDraw ? "PAREGGIO" : "SCONFITTA"); return Container( margin: const EdgeInsets.only(bottom: 15), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: theme.text.withOpacity(0.05), borderRadius: BorderRadius.circular(20), border: Border.all(color: resultColor.withOpacity(0.5), width: 2), boxShadow: [ BoxShadow(color: Colors.black.withOpacity(0.2), offset: const Offset(0, 4), blurRadius: 6), ], ), child: Row( children: [ // Icona Tipo di Partita Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: resultColor.withOpacity(0.1), shape: BoxShape.circle, ), child: Icon( isOnline ? Icons.public : (opponent.contains("CPU") ? Icons.smart_toy : Icons.people_alt), color: resultColor, size: 28, ), ), const SizedBox(width: 15), // Dati Partita (Ora con i nomi chiari) Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(resultText, style: TextStyle(color: resultColor, fontWeight: FontWeight.w900, fontSize: 16, letterSpacing: 1.5)), const SizedBox(height: 5), // NOMI GIOCATORI RichText( text: TextSpan( children: [ TextSpan(text: myName, style: TextStyle(color: theme.playerBlue, fontWeight: FontWeight.bold, fontSize: 15)), TextSpan(text: " vs ", style: TextStyle(color: theme.text.withOpacity(0.5), fontStyle: FontStyle.italic, fontSize: 12)), TextSpan(text: opponent, style: TextStyle(color: theme.playerRed, fontWeight: FontWeight.bold, fontSize: 15)), ] ) ), const SizedBox(height: 5), Text(formattedDate, style: TextStyle(color: theme.text.withOpacity(0.5), fontSize: 12)), ], ), ), // Punteggio Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: theme.background, borderRadius: BorderRadius.circular(15), border: Border.all(color: theme.gridLine.withOpacity(0.3)), ), child: Row( children: [ Text("$myScore", style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900, color: theme.playerBlue)), Text(" - ", style: TextStyle(fontSize: 18, color: theme.text.withOpacity(0.5))), Text("$oppScore", style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900, color: theme.playerRed)), ], ), ), ], ), ); }, ), ); } } // =========================================================================== // FILE: lib/ui/home/home_screen.dart // =========================================================================== // =========================================================================== // FILE: lib/ui/home/home_screen.dart // =========================================================================== import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:flutter/services.dart'; import 'dart:math' as math; import '../../core/theme_manager.dart'; import '../../core/app_colors.dart'; import '../../models/game_board.dart'; import '../game/game_screen.dart'; import '../settings/settings_screen.dart'; import '../../logic/game_controller.dart'; import '../../services/storage_service.dart'; import '../multiplayer/lobby_screen.dart'; import 'history_screen.dart'; // --- NUOVI WIDGET PERSONALIZZATI --- class _NeonShapeButton extends StatelessWidget { final IconData icon; final String label; final bool isSelected; final ThemeColors theme; final VoidCallback onTap; const _NeonShapeButton({required this.icon, required this.label, required this.isSelected, required this.theme, required this.onTap}); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: BoxDecoration( borderRadius: BorderRadius.circular(15), color: isSelected ? theme.playerBlue.withOpacity(0.2) : theme.text.withOpacity(0.05), border: Border.all( color: isSelected ? theme.playerBlue : theme.gridLine.withOpacity(0.3), width: isSelected ? 2 : 1, ), boxShadow: isSelected ? [BoxShadow(color: theme.playerBlue.withOpacity(0.4), blurRadius: 12, spreadRadius: 2)] : [], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, color: isSelected ? theme.playerBlue : theme.text.withOpacity(0.5), size: 24), const SizedBox(height: 6), Text(label, style: TextStyle(color: isSelected ? theme.text : theme.text.withOpacity(0.5), fontSize: 11, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal)), ], ), ), ); } } class _NeonSizeButton extends StatelessWidget { final String label; final bool isSelected; final ThemeColors theme; final VoidCallback onTap; const _NeonSizeButton({required this.label, required this.isSelected, required this.theme, required this.onTap}); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, width: 50, height: 50, decoration: BoxDecoration( shape: BoxShape.circle, color: isSelected ? theme.playerRed.withOpacity(0.2) : theme.text.withOpacity(0.05), border: Border.all( color: isSelected ? theme.playerRed : theme.gridLine.withOpacity(0.3), width: isSelected ? 2 : 1, ), boxShadow: isSelected ? [BoxShadow(color: theme.playerRed.withOpacity(0.4), blurRadius: 10, spreadRadius: 1.5)] : [], ), child: Center( child: Text(label, style: TextStyle(color: isSelected ? theme.text : theme.text.withOpacity(0.5), fontSize: 14, fontWeight: isSelected ? FontWeight.w900 : FontWeight.bold)), ), ), ); } } class _NeonTimeSwitch extends StatelessWidget { final bool isTimeMode; final ThemeColors theme; final VoidCallback onTap; const _NeonTimeSwitch({required this.isTimeMode, required this.theme, required this.onTap}); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), color: isTimeMode ? Colors.amber.withOpacity(0.2) : theme.text.withOpacity(0.05), border: Border.all( color: isTimeMode ? Colors.amber : theme.gridLine.withOpacity(0.3), width: isTimeMode ? 2 : 1, ), boxShadow: isTimeMode ? [BoxShadow(color: Colors.amber.withOpacity(0.4), blurRadius: 12, spreadRadius: 2)] : [], ), child: Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(isTimeMode ? Icons.timer : Icons.timer_off, color: isTimeMode ? Colors.amber : theme.text.withOpacity(0.5), size: 28), const SizedBox(width: 12), Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( isTimeMode ? 'A TEMPO' : 'RELAX', style: TextStyle(color: isTimeMode ? theme.text : theme.text.withOpacity(0.5), fontWeight: FontWeight.w900, fontSize: 14, letterSpacing: 1.5) ), Text( isTimeMode ? '15 sec a mossa' : 'Nessun limite di tempo', style: TextStyle(color: isTimeMode ? Colors.amber.shade200 : theme.text.withOpacity(0.4), fontSize: 11, fontWeight: FontWeight.bold) ), ], ), ], ), ), ); } } // --------------------------------------------------------------------------- class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @override State createState() => _HomeScreenState(); } class _HomeScreenState extends State with WidgetsBindingObserver { // Il raggio di partenza è 4 (Taglia M) come richiesto int _selectedRadius = 4; ArenaShape _selectedShape = ArenaShape.classic; bool _isTimeMode = false; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) { _checkPlayerName(); }); _checkClipboardForInvite(); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { _checkClipboardForInvite(); } } void _checkPlayerName() { if (StorageService.instance.playerName.isEmpty) { _showNameDialog(); } } void _showNameDialog() { final TextEditingController nameController = TextEditingController(); showDialog( context: context, barrierDismissible: false, barrierColor: Colors.black.withOpacity(0.8), builder: (context) { final themeManager = context.watch(); final theme = themeManager.currentColors; final themeType = themeManager.currentThemeType; Widget dialogContent; if (themeType == AppThemeType.cyberpunk) { dialogContent = _AnimatedCyberBorder(child: _buildDialogContent(context, theme, nameController)); } else if (themeType == AppThemeType.wood) { dialogContent = Container( decoration: BoxDecoration( color: theme.background, borderRadius: BorderRadius.circular(25), border: Border.all(color: const Color(0xFF8B4513), width: 5), boxShadow: [ BoxShadow(color: Colors.black.withOpacity(0.6), blurRadius: 15, offset: const Offset(0, 8)), const BoxShadow(color: Color(0xFFD2691E), blurRadius: 0, spreadRadius: 2, offset: Offset(0, 0)), ], ), padding: const EdgeInsets.all(5), child: _buildDialogContent(context, theme, nameController), ); } else if (themeType == AppThemeType.doodle) { dialogContent = Container( decoration: BoxDecoration( color: theme.background, borderRadius: BorderRadius.circular(25), border: Border.all(color: theme.text, width: 3, style: BorderStyle.solid), boxShadow: [ BoxShadow(color: theme.text.withOpacity(0.3), blurRadius: 0, offset: const Offset(4, 4)), BoxShadow(color: theme.text.withOpacity(0.3), blurRadius: 0, offset: const Offset(-2, -2)), ], ), padding: const EdgeInsets.all(4), child: _buildDialogContent(context, theme, nameController), ); } else { dialogContent = Container( decoration: BoxDecoration( color: theme.background, borderRadius: BorderRadius.circular(25), border: Border.all(color: theme.playerBlue.withOpacity(0.5), width: 2), boxShadow: [ BoxShadow(color: theme.playerBlue.withOpacity(0.3), blurRadius: 20, spreadRadius: 5), ] ), child: _buildDialogContent(context, theme, nameController), ); } return Dialog(backgroundColor: Colors.transparent, insetPadding: const EdgeInsets.all(20), child: dialogContent); }, ); } Widget _buildDialogContent(BuildContext context, ThemeColors theme, TextEditingController controller) { return SingleChildScrollView( child: Padding( padding: const EdgeInsets.symmetric(vertical: 30.0, horizontal: 25.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text('BENVENUTO IN TETRAQ!', style: TextStyle(color: theme.text, fontWeight: FontWeight.w900, fontSize: 24, letterSpacing: 1.5), textAlign: TextAlign.center), const SizedBox(height: 20), Text('Scegli il tuo nome da battaglia per sfidare i tuoi amici online.', style: TextStyle(color: theme.text.withOpacity(0.8), fontSize: 16), textAlign: TextAlign.center), const SizedBox(height: 40), TextField( controller: controller, textCapitalization: TextCapitalization.characters, textAlign: TextAlign.center, maxLength: 5, style: TextStyle(color: theme.text, fontSize: 28, fontWeight: FontWeight.bold, letterSpacing: 4), decoration: InputDecoration( hintText: 'NOME', hintStyle: TextStyle(color: theme.text.withOpacity(0.3), letterSpacing: 4), filled: true, fillColor: theme.text.withOpacity(0.05), counterText: "", enabledBorder: OutlineInputBorder(borderSide: BorderSide(color: theme.gridLine.withOpacity(0.5), width: 2), borderRadius: BorderRadius.circular(15)), focusedBorder: OutlineInputBorder(borderSide: BorderSide(color: theme.playerBlue, width: 3), borderRadius: BorderRadius.circular(15)), ), ), const SizedBox(height: 40), SizedBox( width: double.infinity, height: 55, child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: theme.playerBlue, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), elevation: 8, shadowColor: theme.playerBlue.withOpacity(0.5), ), onPressed: () { final name = controller.text.trim(); if (name.isNotEmpty) { StorageService.instance.savePlayerName(name); Navigator.of(context).pop(); setState(() {}); } }, child: const Text('SALVA E GIOCA', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 1.5)), ), ), ], ), ), ); } Future _checkClipboardForInvite() async { try { ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain); String? text = data?.text; if (text != null && text.contains("TetraQ") && text.contains("codice:")) { RegExp regExp = RegExp(r'codice:\s*([A-Z0-9]{5})', caseSensitive: false); Match? match = regExp.firstMatch(text); if (match != null) { String roomCode = match.group(1)!.toUpperCase(); await Clipboard.setData(const ClipboardData(text: '')); if (mounted && ModalRoute.of(context)?.isCurrent == true) { _promptJoinRoom(roomCode); } } } } catch (e) { debugPrint("Errore lettura appunti: $e"); } } void _promptJoinRoom(String roomCode) { showDialog( context: context, builder: (context) { final theme = context.watch().currentColors; return AlertDialog( backgroundColor: theme.background, title: Text("Invito Trovato!", style: TextStyle(color: theme.text, fontWeight: FontWeight.bold)), content: Text("Vuoi unirti alla stanza $roomCode?", style: TextStyle(color: theme.text)), actions: [ TextButton(onPressed: () => Navigator.pop(context), child: const Text("No", style: TextStyle(color: Colors.red))), ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: theme.playerBlue), onPressed: () { Navigator.of(context).pop(); Navigator.push(context, MaterialPageRoute(builder: (_) => LobbyScreen(initialRoomCode: roomCode, selectedRadius: _selectedRadius, selectedShape: _selectedShape))); }, child: const Text("Unisciti", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), ), ], ); } ); } @override Widget build(BuildContext context) { // --- RIPRISTINATO: Forza la modalità a schermo intero e l'orientamento verticale --- SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); SystemChrome.setPreferredOrientations([ DeviceOrientation.portraitUp, DeviceOrientation.portraitDown, ]); final themeManager = context.watch(); final themeType = themeManager.currentThemeType; final theme = themeManager.currentColors; String? bgImage; if (themeType == AppThemeType.wood) bgImage = 'assets/images/wood_bg.jpg'; if (themeType == AppThemeType.doodle) bgImage = 'assets/images/doodle_bg.jpg'; int wins = StorageService.instance.wins; int losses = StorageService.instance.losses; String playerName = StorageService.instance.playerName; if (playerName.isEmpty) playerName = "GUEST"; Widget uiContent = SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 10.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ CircleAvatar( backgroundColor: theme.playerBlue.withOpacity(0.2), radius: 25, child: Icon(Icons.person, color: theme.playerBlue, size: 30), ), const SizedBox(width: 10), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text("BENVENUTO,", style: TextStyle(color: theme.text.withOpacity(0.5), fontSize: 10, fontWeight: FontWeight.bold, letterSpacing: 1.2)), Text(playerName, style: TextStyle(color: theme.text, fontSize: 18, fontWeight: FontWeight.w900, letterSpacing: 1.5)), ], ), ], ), Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: theme.text.withOpacity(0.05), borderRadius: BorderRadius.circular(20), border: Border.all(color: theme.gridLine.withOpacity(0.2)), ), child: Row( children: [ Icon(Icons.emoji_events, color: Colors.amber.shade600, size: 18), const SizedBox(width: 4), Text("$wins", style: TextStyle(color: theme.text, fontWeight: FontWeight.bold)), const SizedBox(width: 10), Icon(Icons.sentiment_very_dissatisfied, color: theme.playerRed.withOpacity(0.8), size: 18), const SizedBox(width: 4), Text("$losses", style: TextStyle(color: theme.text, fontWeight: FontWeight.bold)), ], ), ) ], ), const SizedBox(height: 20), Center( child: Column( children: [ Text( "TETRAQ", style: TextStyle( fontSize: 50, fontWeight: FontWeight.w900, color: theme.text, letterSpacing: 8, shadows: [Shadow(color: Colors.black.withOpacity(0.3), offset: const Offset(2, 4), blurRadius: 4)] ) ), Text( "THE ROMBUS PUZZLE", style: TextStyle( fontSize: 12, fontWeight: FontWeight.bold, color: theme.gridLine, letterSpacing: 3, ) ), ], ), ), const SizedBox(height: 20), // --- PANNELLO IMPOSTAZIONI ARENA CON LOGICA GRANDEZZA CORRETTA --- Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: theme.background.withOpacity(0.8), borderRadius: BorderRadius.circular(20), border: Border.all(color: theme.gridLine.withOpacity(0.3)), boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.2), blurRadius: 10, offset: const Offset(0, 4))], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 1. SEZIONE FORMA Text("FORMA DELL'ARENA", style: TextStyle(fontSize: 11, fontWeight: FontWeight.w900, color: theme.text.withOpacity(0.5), letterSpacing: 1.5)), const SizedBox(height: 10), Wrap( spacing: 10, runSpacing: 10, alignment: WrapAlignment.center, children: [ _NeonShapeButton( icon: Icons.diamond_outlined, label: 'Rombo', isSelected: _selectedShape == ArenaShape.classic, theme: theme, onTap: () => setState(() => _selectedShape = ArenaShape.classic), ), _NeonShapeButton( icon: Icons.add, label: 'Croce', isSelected: _selectedShape == ArenaShape.cross, theme: theme, onTap: () => setState(() => _selectedShape = ArenaShape.cross), ), _NeonShapeButton( icon: Icons.donut_large, label: 'Buco Nero', isSelected: _selectedShape == ArenaShape.donut, theme: theme, onTap: () => setState(() => _selectedShape = ArenaShape.donut), ), _NeonShapeButton( icon: Icons.hourglass_bottom, label: 'Clessidra', isSelected: _selectedShape == ArenaShape.hourglass, theme: theme, onTap: () => setState(() => _selectedShape = ArenaShape.hourglass), ), ], ), const SizedBox(height: 20), Divider(color: theme.gridLine.withOpacity(0.2), height: 1), const SizedBox(height: 20), // 2. SEZIONE TAGLIA (SCALA CORRETTA DA 3 A 6) Text("GRANDEZZA", style: TextStyle(fontSize: 11, fontWeight: FontWeight.w900, color: theme.text.withOpacity(0.5), letterSpacing: 1.5)), const SizedBox(height: 10), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _NeonSizeButton(label: 'S', isSelected: _selectedRadius == 3, theme: theme, onTap: () => setState(() => _selectedRadius = 3)), _NeonSizeButton(label: 'M', isSelected: _selectedRadius == 4, theme: theme, onTap: () => setState(() => _selectedRadius = 4)), _NeonSizeButton(label: 'L', isSelected: _selectedRadius == 5, theme: theme, onTap: () => setState(() => _selectedRadius = 5)), _NeonSizeButton(label: 'MAX', isSelected: _selectedRadius == 6, theme: theme, onTap: () => setState(() => _selectedRadius = 6)), ], ), const SizedBox(height: 20), Divider(color: theme.gridLine.withOpacity(0.2), height: 1), const SizedBox(height: 20), // 3. SEZIONE TEMPO Text("MODALITÀ DI GIOCO", style: TextStyle(fontSize: 11, fontWeight: FontWeight.w900, color: theme.text.withOpacity(0.5), letterSpacing: 1.5)), const SizedBox(height: 10), _NeonTimeSwitch( isTimeMode: _isTimeMode, theme: theme, onTap: () => setState(() => _isTimeMode = !_isTimeMode), ), ], ), ), // -------------------------------------------------------------- const SizedBox(height: 20), Expanded( child: GridView.count( crossAxisCount: 2, crossAxisSpacing: 15, mainAxisSpacing: 15, childAspectRatio: 1.1, physics: const BouncingScrollPhysics(), children: [ _FeatureCard( title: "ONLINE", subtitle: "Sfida il mondo", icon: Icons.public, color: theme.playerBlue, theme: theme, isFeatured: true, onTap: () { Navigator.push(context, MaterialPageRoute(builder: (_) => LobbyScreen(selectedRadius: _selectedRadius, selectedShape: _selectedShape))); }, ), _FeatureCard( title: "VS CPU", subtitle: "Allenati", icon: Icons.smart_toy, color: Colors.purple.shade400, theme: theme, onTap: () async { context.read().startNewGame(_selectedRadius, vsCPU: true, shape: _selectedShape); await Navigator.push(context, MaterialPageRoute(builder: (_) => const GameScreen())); setState(() {}); }, ), _FeatureCard( title: "LOCALE", subtitle: "Stesso schermo", icon: Icons.people_alt, color: theme.playerRed, theme: theme, onTap: () { context.read().startNewGame(_selectedRadius, vsCPU: false, shape: _selectedShape); Navigator.push(context, MaterialPageRoute(builder: (_) => const GameScreen())); }, ), Column( children: [ Expanded(child: _MiniCard(title: "STORICO", icon: Icons.history, color: Colors.orange.shade400, theme: theme, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const HistoryScreen())))), const SizedBox(height: 15), Expanded(child: _MiniCard(title: "TEMI", icon: Icons.palette, color: Colors.teal.shade400, theme: theme, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen())))), ], ) ], ), ), ], ), ), ); return Scaffold( backgroundColor: bgImage != null ? Colors.transparent : theme.background, body: Container( decoration: bgImage != null ? BoxDecoration(image: DecorationImage(image: AssetImage(bgImage), fit: BoxFit.cover)) : null, child: bgImage != null ? BackdropFilter(filter: ImageFilter.blur(sigmaX: 3.5, sigmaY: 3.5), child: Container(color: themeType == AppThemeType.doodle ? Colors.white.withOpacity(0.1) : Colors.transparent, child: uiContent)) : uiContent, ), ); } } class _FeatureCard extends StatelessWidget { final String title; final String subtitle; final IconData icon; final Color color; final ThemeColors theme; final VoidCallback onTap; final bool isFeatured; const _FeatureCard({required this.title, required this.subtitle, required this.icon, required this.color, required this.theme, required this.onTap, this.isFeatured = false}); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Container( decoration: BoxDecoration( color: isFeatured ? color : theme.background, borderRadius: BorderRadius.circular(25), border: Border.all(color: isFeatured ? Colors.transparent : color.withOpacity(0.5), width: 2), boxShadow: [BoxShadow(color: color.withOpacity(isFeatured ? 0.4 : 0.1), offset: const Offset(0, 8), blurRadius: 15)] ), child: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: isFeatured ? Colors.white.withOpacity(0.2) : color.withOpacity(0.1), shape: BoxShape.circle), child: Icon(icon, color: isFeatured ? Colors.white : color, size: 32), ), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: TextStyle(color: isFeatured ? Colors.white : theme.text, fontSize: 20, fontWeight: FontWeight.w900)), Text(subtitle, style: TextStyle(color: isFeatured ? Colors.white.withOpacity(0.8) : theme.text.withOpacity(0.5), fontSize: 12, fontWeight: FontWeight.bold)), ], ) ], ), ), ), ); } } class _MiniCard extends StatelessWidget { final String title; final IconData icon; final Color color; final ThemeColors theme; final VoidCallback onTap; const _MiniCard({required this.title, required this.icon, required this.color, required this.theme, required this.onTap}); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Container( decoration: BoxDecoration( color: theme.background, borderRadius: BorderRadius.circular(20), border: Border.all(color: color.withOpacity(0.5), width: 1.5), boxShadow: [BoxShadow(color: color.withOpacity(0.1), blurRadius: 10, offset: const Offset(0, 5))] ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(icon, color: color, size: 24), const SizedBox(width: 8), Text(title, style: TextStyle(color: theme.text, fontWeight: FontWeight.bold, fontSize: 14)), ], ), ), ); } } class _AnimatedCyberBorder extends StatefulWidget { final Widget child; const _AnimatedCyberBorder({required this.child}); @override State<_AnimatedCyberBorder> createState() => _AnimatedCyberBorderState(); } class _AnimatedCyberBorderState extends State<_AnimatedCyberBorder> with SingleTickerProviderStateMixin { late AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController(vsync: this, duration: const Duration(seconds: 3))..repeat(); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = context.watch().currentColors; return AnimatedBuilder( animation: _controller, builder: (context, child) { return CustomPaint( painter: _CyberBorderPainter(animationValue: _controller.value, color1: theme.playerBlue, color2: theme.playerRed), child: Container( decoration: BoxDecoration(color: theme.background.withOpacity(0.9), borderRadius: BorderRadius.circular(25), boxShadow: [BoxShadow(color: theme.playerBlue.withOpacity(0.3), blurRadius: 25, spreadRadius: 2)]), padding: const EdgeInsets.all(3), child: widget.child, ), ); }, child: widget.child, ); } } class _CyberBorderPainter extends CustomPainter { final double animationValue; final Color color1; final Color color2; _CyberBorderPainter({required this.animationValue, required this.color1, required this.color2}); @override void paint(Canvas canvas, Size size) { final rect = Offset.zero & size; final RRect rrect = RRect.fromRectAndRadius(rect, const Radius.circular(25)); final Paint paint = Paint() ..shader = SweepGradient(colors: [color1, color2, color1, color2, color1], stops: const [0.0, 0.25, 0.5, 0.75, 1.0], transform: GradientRotation(animationValue * 2 * math.pi)).createShader(rect) ..style = PaintingStyle.stroke ..strokeWidth = 4.0 ..maskFilter = const MaskFilter.blur(BlurStyle.solid, 4); canvas.drawRRect(rrect, paint); } @override bool shouldRepaint(covariant _CyberBorderPainter oldDelegate) => oldDelegate.animationValue != animationValue; } // =========================================================================== // FILE: lib/ui/multiplayer/lobby_screen.dart // =========================================================================== import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; import '../../core/theme_manager.dart'; import '../../models/game_board.dart'; // <-- IMPORTANTE import '../../services/multiplayer_service.dart'; import '../../services/storage_service.dart'; import '../game/game_screen.dart'; import '../../logic/game_controller.dart'; class LobbyScreen extends StatefulWidget { final String? initialRoomCode; final int selectedRadius; final ArenaShape selectedShape; // <-- RICEVIAMO LA FORMA const LobbyScreen({super.key, this.initialRoomCode, this.selectedRadius = 2, this.selectedShape = ArenaShape.classic}); @override State createState() => _LobbyScreenState(); } class _LobbyScreenState extends State { final MultiplayerService _multiplayerService = MultiplayerService(); late TextEditingController _codeController; bool _isLoading = false; String? _myRoomCode; String _playerName = ''; @override void initState() { super.initState(); _codeController = TextEditingController(); _playerName = StorageService.instance.playerName; if (widget.initialRoomCode != null && widget.initialRoomCode!.isNotEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { setState(() { _codeController.text = widget.initialRoomCode!; }); }); } } @override void dispose() { _codeController.dispose(); super.dispose(); } Future _createRoom() async { if (_isLoading) return; setState(() => _isLoading = true); try { int radius = widget.selectedRadius; // PASSIAMO ANCHE IL NOME DELLA FORMA! String code = await _multiplayerService.createGameRoom(radius, _playerName, widget.selectedShape.name); if (!mounted) return; setState(() { _myRoomCode = code; _isLoading = false; }); _multiplayerService.shareInviteLink(code); _showWaitingDialog(code); } catch (e) { if (mounted) { setState(() => _isLoading = false); _showError("Errore durante la creazione della partita."); } } } Future _joinRoom() async { if (_isLoading) return; FocusScope.of(context).unfocus(); String code = _codeController.text.trim().toUpperCase(); if (code.isEmpty || code.length != 5) { _showError("Inserisci un codice valido di 5 caratteri."); return; } setState(() => _isLoading = true); try { Map? roomData = await _multiplayerService.joinGameRoom(code, _playerName); if (!mounted) return; setState(() => _isLoading = false); if (roomData != null) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Stanza trovata! Partita in avvio..."), backgroundColor: Colors.green)); int hostRadius = roomData['radius'] ?? 2; // LEGGIAMO LA FORMA DELL'HOST String shapeStr = roomData['shape'] ?? 'classic'; ArenaShape hostShape = ArenaShape.values.firstWhere((e) => e.name == shapeStr, orElse: () => ArenaShape.classic); context.read().startNewGame(hostRadius, isOnline: true, roomCode: code, isHost: false, shape: hostShape); Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const GameScreen())); } else { _showError("Stanza non trovata, piena o partita già iniziata."); } } catch (e) { if (mounted) { setState(() => _isLoading = false); _showError("Errore di connessione: $e"); } } } void _showError(String message) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message, style: const TextStyle(color: Colors.white)), backgroundColor: Colors.red)); } void _showWaitingDialog(String code) { showDialog( context: context, barrierDismissible: false, builder: (context) { final theme = context.watch().currentColors; return StreamBuilder( stream: _multiplayerService.listenToRoom(code), builder: (context, snapshot) { if (snapshot.hasData && snapshot.data!.exists) { var data = snapshot.data!.data() as Map; if (data['status'] == 'playing') { WidgetsBinding.instance.addPostFrameCallback((_) { Navigator.pop(context); int radius = widget.selectedRadius; // AVVIAMO IL GIOCO CON LA FORMA SCELTA DALL'HOST! context.read().startNewGame(radius, isOnline: true, roomCode: code, isHost: true, shape: widget.selectedShape); Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const GameScreen())); }); } } return AlertDialog( backgroundColor: theme.background, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), title: Text("In attesa...", textAlign: TextAlign.center, style: TextStyle(color: theme.text, fontWeight: FontWeight.bold)), content: Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator(), const SizedBox(height: 25), Text("CODICE STANZA", style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: theme.text.withOpacity(0.6), letterSpacing: 2)), Text(code, style: TextStyle(fontSize: 40, fontWeight: FontWeight.w900, color: theme.playerRed, letterSpacing: 8)), const SizedBox(height: 20), Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: theme.text.withOpacity(0.05), borderRadius: BorderRadius.circular(15), border: Border.all(color: theme.playerBlue.withOpacity(0.3), width: 1.5)), child: Column( children: [ Icon(Icons.auto_awesome, color: theme.playerBlue, size: 28), const SizedBox(height: 10), Text("Condividi l'invito con un amico.", textAlign: TextAlign.center, style: TextStyle(color: theme.text, fontWeight: FontWeight.bold, fontSize: 14)), const SizedBox(height: 5), Text("Gli basterà copiare il messaggio e aprire l'app.", textAlign: TextAlign.center, style: TextStyle(color: theme.text.withOpacity(0.8), fontSize: 13, height: 1.4)), ], ), ), ], ), actionsAlignment: MainAxisAlignment.center, actions: [ TextButton( onPressed: () { FirebaseFirestore.instance.collection('games').doc(code).delete(); Navigator.pop(context); }, child: const Text("ANNULLA", style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold, fontSize: 16, letterSpacing: 1.5)), ), ], ); }, ); } ); } @override Widget build(BuildContext context) { final theme = context.watch().currentColors; return Scaffold( backgroundColor: theme.background, appBar: AppBar(title: Text("Multiplayer Online", style: TextStyle(color: theme.text, fontWeight: FontWeight.bold)), backgroundColor: Colors.transparent, elevation: 0, iconTheme: IconThemeData(color: theme.text)), body: _isLoading ? Center(child: CircularProgressIndicator(color: theme.playerRed)) : Center( child: SingleChildScrollView( padding: const EdgeInsets.all(24.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Icon(Icons.wifi, size: 80, color: theme.playerBlue), const SizedBox(height: 10), Text("Giocatore: $_playerName", textAlign: TextAlign.center, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: theme.playerRed)), const SizedBox(height: 40), SizedBox(height: 60, child: ElevatedButton(style: ElevatedButton.styleFrom(backgroundColor: theme.playerRed, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15))), onPressed: _createRoom, child: const Text("CREA PARTITA", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 2)))), const SizedBox(height: 40), TextField( controller: _codeController, textCapitalization: TextCapitalization.characters, textAlign: TextAlign.center, maxLength: 5, style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: theme.text, letterSpacing: 8), decoration: InputDecoration(hintText: "CODICE", hintStyle: TextStyle(color: theme.text.withOpacity(0.3)), enabledBorder: OutlineInputBorder(borderSide: BorderSide(color: theme.text.withOpacity(0.3), width: 2), borderRadius: BorderRadius.circular(15)), focusedBorder: OutlineInputBorder(borderSide: BorderSide(color: theme.playerBlue, width: 3), borderRadius: BorderRadius.circular(15))), ), const SizedBox(height: 16), SizedBox(height: 60, child: ElevatedButton(style: ElevatedButton.styleFrom(backgroundColor: theme.playerBlue, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15))), onPressed: _joinRoom, child: const Text("UNISCITI", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 2)))), ], ), ), ), ); } } // =========================================================================== // FILE: lib/ui/settings/settings_screen.dart // =========================================================================== import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/theme_manager.dart'; import '../../core/app_colors.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @override Widget build(BuildContext context) { final themeManager = context.watch(); final theme = themeManager.currentColors; return Scaffold( backgroundColor: theme.background, appBar: AppBar( title: Text("SELEZIONA TEMA", style: TextStyle(fontWeight: FontWeight.bold, color: theme.text)), backgroundColor: Colors.transparent, elevation: 0, iconTheme: IconThemeData(color: theme.text), ), body: ListView( padding: const EdgeInsets.all(20), children: [ _ThemeCard( title: "Minimal", subtitle: "Linee pulite, sfondo chiaro", type: AppThemeType.minimal, previewColors: AppColors.minimal, ), const SizedBox(height: 15), _ThemeCard( title: "Quaderno (Doodle)", subtitle: "Sfondo a quadretti, tratto a penna", type: AppThemeType.doodle, previewColors: AppColors.doodle, ), const SizedBox(height: 15), _ThemeCard( title: "Cyberpunk", subtitle: "Nero profondo, luci al neon", type: AppThemeType.cyberpunk, previewColors: AppColors.cyberpunk, ), const SizedBox(height: 15), // --- NUOVA CARD TEMA LEGNO --- _ThemeCard( title: "Legno & Fiammiferi", subtitle: "Tavolo di legno, linee come fiammiferi", type: AppThemeType.wood, previewColors: AppColors.wood, ), // ----------------------------- ], ), ); } } class _ThemeCard extends StatelessWidget { final String title; final String subtitle; final AppThemeType type; final ThemeColors previewColors; const _ThemeCard({required this.title, required this.subtitle, required this.type, required this.previewColors}); @override Widget build(BuildContext context) { final themeManager = context.watch(); bool isSelected = themeManager.currentThemeType == type; return GestureDetector( onTap: () => themeManager.setTheme(type), child: AnimatedContainer( duration: const Duration(milliseconds: 300), padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: previewColors.background, borderRadius: BorderRadius.circular(20), border: Border.all( color: isSelected ? previewColors.playerBlue : previewColors.gridLine.withOpacity(0.5), width: isSelected ? 4 : 2, ), boxShadow: isSelected ? [BoxShadow(color: previewColors.playerBlue.withOpacity(0.4), blurRadius: 10, spreadRadius: 2)] : [], ), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: previewColors.text)), Text(subtitle, style: TextStyle(fontSize: 14, color: previewColors.text.withOpacity(0.7))), ], ), ), Container(width: 20, height: 20, decoration: BoxDecoration(color: previewColors.playerRed, shape: BoxShape.circle)), const SizedBox(width: 10), Container(width: 20, height: 20, decoration: BoxDecoration(color: previewColors.playerBlue, shape: BoxShape.circle)), ], ), ), ); } } // =========================================================================== // FILE: lib/widgets/custom_button.dart // =========================================================================== // =========================================================================== // FILE: lib/widgets/custom_settings_button.dart // =========================================================================== import 'package:flutter/material.dart'; import '../theme/app_colors.dart'; // Widget per i pulsanti di selezione della forma dell'arena class NeonShapeButton extends StatelessWidget { final IconData icon; final String label; final bool isSelected; final VoidCallback onTap; final ShapeBorder shape; // La forma geometrica del pulsante const NeonShapeButton({ super.key, required this.icon, required this.label, required this.isSelected, required this.onTap, this.shape = const RoundedRectangleBorder( // Forma di default borderRadius: BorderRadius.all(Radius.circular(12.0))), }); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: ShapeDecoration( shape: shape, color: isSelected ? AppColors.neonGreen.withOpacity(0.2) // Sfondo luminoso se selezionato : AppColors.surface.withOpacity(0.5), // Sfondo più scuro se non selezionato shadows: isSelected ? [ // Bagliore intenso se selezionato BoxShadow( color: AppColors.neonGreen.withOpacity(0.6), blurRadius: 12.0, spreadRadius: 2.0, ), ] : [], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( icon, color: isSelected ? AppColors.neonGreen : AppColors.textSecondary, size: 28, ), const SizedBox(height: 4), Text( label, style: TextStyle( color: isSelected ? AppColors.textPrimary : AppColors.textSecondary, fontSize: 12, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, ), ), ], ), ), ); } } // Widget per i pulsanti di selezione della taglia dell'arena class NeonSizeButton extends StatelessWidget { final String label; final bool isSelected; final VoidCallback onTap; const NeonSizeButton({ super.key, required this.label, required this.isSelected, required this.onTap, }); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, width: 50, height: 50, decoration: BoxDecoration( shape: BoxShape.circle, // Forma circolare color: isSelected ? AppColors.neonBlue.withOpacity(0.2) : AppColors.surface.withOpacity(0.5), border: Border.all( color: isSelected ? AppColors.neonBlue : AppColors.surfaceLight, width: 2.0, ), shadows: isSelected ? [ BoxShadow( color: AppColors.neonBlue.withOpacity(0.6), blurRadius: 10.0, spreadRadius: 1.5, ), ] : [], ), child: Center( child: Text( label, style: TextStyle( color: isSelected ? AppColors.textPrimary : AppColors.textSecondary, fontSize: 16, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, ), ), ), ), ); } } // Widget per l'interruttore della modalità tempo (Clessidra) class NeonTimeSwitch extends StatelessWidget { final bool isTimeMode; final VoidCallback onTap; const NeonTimeSwitch({ super.key, required this.isTimeMode, required this.onTap, }); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), decoration: BoxDecoration( borderRadius: BorderRadius.circular(30.0), // Forma arrotondata per lo switch color: isTimeMode ? AppColors.neonGreen.withOpacity(0.2) : AppColors.surface.withOpacity(0.5), border: Border.all( color: isTimeMode ? AppColors.neonGreen : AppColors.surfaceLight, width: 2.0, ), shadows: isTimeMode ? [ BoxShadow( color: AppColors.neonGreen.withOpacity(0.6), blurRadius: 12.0, spreadRadius: 2.0, ), ] : [], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.hourglass_empty, // Icona clessidra color: isTimeMode ? AppColors.neonGreen : AppColors.textSecondary, ), const SizedBox(width: 8), Text( isTimeMode ? 'A TEMPO' : 'SENZA TEMPO', style: TextStyle( color: isTimeMode ? AppColors.textPrimary : AppColors.textSecondary, fontWeight: isTimeMode ? FontWeight.bold : FontWeight.normal, ), ), ], ), ), ); } } // =========================================================================== // FILE: lib/widgets/game_over_dialog.dart // =========================================================================== import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../logic/game_controller.dart'; import '../core/theme_manager.dart'; import '../core/app_colors.dart'; class GameOverDialog extends StatelessWidget { const GameOverDialog({super.key}); @override Widget build(BuildContext context) { final game = context.read(); final themeManager = context.read(); final theme = themeManager.currentColors; final themeType = themeManager.currentThemeType; int red = game.board.scoreRed; int blue = game.board.scoreBlue; bool playerBeatCPU = game.isVsCPU && red > blue; // --- LOGICA NOMI --- String nameRed = "ROSSO"; String nameBlue = themeType == AppThemeType.cyberpunk ? "VERDE" : "BLU"; if (game.isOnline) { nameRed = game.onlineHostName.toUpperCase(); nameBlue = game.onlineGuestName.toUpperCase(); } else if (game.isVsCPU) { nameRed = "TU"; nameBlue = "CPU"; } // --- DETERMINA IL VINCITORE --- String winnerText = ""; Color winnerColor = theme.text; if (red > blue) { winnerText = "VINCE $nameRed!"; winnerColor = theme.playerRed; } else if (blue > red) { winnerText = "VINCE $nameBlue!"; winnerColor = theme.playerBlue; } else { winnerText = "PAREGGIO!"; winnerColor = theme.text; } return AlertDialog( backgroundColor: theme.background, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), side: BorderSide(color: winnerColor.withOpacity(0.5), width: 2), ), title: Text("FINE PARTITA", textAlign: TextAlign.center, style: TextStyle(color: theme.text, fontWeight: FontWeight.bold, fontSize: 22)), content: Column( mainAxisSize: MainAxisSize.min, children: [ Text(winnerText, textAlign: TextAlign.center, style: TextStyle(fontSize: 26, fontWeight: FontWeight.w900, color: winnerColor)), const SizedBox(height: 20), Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), decoration: BoxDecoration( color: theme.text.withOpacity(0.05), borderRadius: BorderRadius.circular(15), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text("$nameRed: $red", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: theme.playerRed)), Text(" - ", style: TextStyle(fontSize: 18, color: theme.text)), Text("$nameBlue: $blue", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: theme.playerBlue)), ], ), ), if (game.isVsCPU) ...[ const SizedBox(height: 15), Text("Difficoltà CPU: Livello ${game.cpuLevel}", style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: theme.text.withOpacity(0.7))), ] ], ), actionsPadding: const EdgeInsets.only(left: 20, right: 20, bottom: 20, top: 10), actionsAlignment: MainAxisAlignment.center, actions: [ Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (playerBeatCPU) ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: winnerColor, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 15), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), elevation: 5, ), onPressed: () { Navigator.pop(context); game.increaseLevelAndRestart(); }, child: const Text("PROSSIMO LIVELLO ➔", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), ) else if (game.isOnline) ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: winnerColor == theme.text ? theme.playerBlue : winnerColor, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 15), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), elevation: 5, ), onPressed: () { Navigator.pop(context); if (game.board.isGameOver) { game.requestRematch(); } }, child: const Text("RIGIOCA ONLINE", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, letterSpacing: 1.5)), ) else ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: winnerColor == theme.text ? theme.playerBlue : winnerColor, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 15), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), elevation: 5, ), onPressed: () { Navigator.pop(context); game.startNewGame(game.board.radius, vsCPU: game.isVsCPU); }, child: const Text("RIGIOCA", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, letterSpacing: 2)), ), const SizedBox(height: 12), OutlinedButton( style: OutlinedButton.styleFrom( foregroundColor: theme.text, side: BorderSide(color: theme.text.withOpacity(0.3), width: 2), padding: const EdgeInsets.symmetric(vertical: 15), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), ), onPressed: () { if (game.isOnline) { game.disconnectOnlineGame(); } Navigator.pop(context); Navigator.pop(context); }, child: Text("TORNA AL MENU", style: TextStyle(fontWeight: FontWeight.bold, color: theme.text, fontSize: 14, letterSpacing: 1.5)), ), ], ) ], ); } }