import 'dart:async'; import 'dart:math' as math; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:shared_preferences/shared_preferences.dart'; class ShotRecord { final double relativeX; final double relativeY; final bool isMake; final String playerId; final String playerName; final String? zone; final int? points; ShotRecord({ required this.relativeX, required this.relativeY, required this.isMake, required this.playerId, required this.playerName, this.zone, this.points, }); // 👇 Para o Auto-Save converter em Texto Map toJson() => { 'relativeX': relativeX, 'relativeY': relativeY, 'isMake': isMake, 'playerId': playerId, 'playerName': playerName, 'zone': zone, 'points': points, }; // 👇 Para o Auto-Save ler do Texto factory ShotRecord.fromJson(Map json) => ShotRecord( relativeX: json['relativeX'], relativeY: json['relativeY'], isMake: json['isMake'], playerId: json['playerId'], playerName: json['playerName'], zone: json['zone'], points: json['points'], ); } // 👇 AGORA É UM CHANGENOTIFIER (Gestor de Estado Profissional) 👇 class PlacarController extends ChangeNotifier { final String gameId; final String myTeam; final String opponentTeam; PlacarController({ required this.gameId, required this.myTeam, required this.opponentTeam, }); bool isLoading = true; bool isSaving = false; bool gameWasAlreadyFinished = false; int myScore = 0; int opponentScore = 0; int myFouls = 0; int opponentFouls = 0; int currentQuarter = 1; int myTimeoutsUsed = 0; int opponentTimeoutsUsed = 0; String? myTeamDbId; String? oppTeamDbId; List myCourt = []; List myBench = []; List oppCourt = []; List oppBench = []; Map playerNames = {}; Map playerNumbers = {}; Map> playerStats = {}; bool showMyBench = false; bool showOppBench = false; bool isSelectingShotLocation = false; String? pendingAction; String? pendingPlayerId; List matchShots = []; // 👇 O CRONÓMETRO AGORA TEM VIDA PRÓPRIA (ValueNotifier) PARA NÃO ENCRAVAR A APP 👇 ValueNotifier durationNotifier = ValueNotifier(const Duration(minutes: 10)); Timer? timer; bool isRunning = false; bool isCalibrating = false; double hoopBaseX = 0.088; double arcRadius = 0.459; double cornerY = 0.440; Future loadPlayers() async { final supabase = Supabase.instance.client; try { await Future.delayed(const Duration(milliseconds: 1500)); myCourt.clear(); myBench.clear(); oppCourt.clear(); oppBench.clear(); playerNames.clear(); playerStats.clear(); playerNumbers.clear(); matchShots.clear(); myFouls = 0; opponentFouls = 0; final gameResponse = await supabase.from('games').select().eq('id', gameId).single(); myScore = int.tryParse(gameResponse['my_score']?.toString() ?? '0') ?? 0; opponentScore = int.tryParse(gameResponse['opponent_score']?.toString() ?? '0') ?? 0; int totalSeconds = int.tryParse(gameResponse['remaining_seconds']?.toString() ?? '600') ?? 600; durationNotifier.value = Duration(seconds: totalSeconds); myTimeoutsUsed = int.tryParse(gameResponse['my_timeouts']?.toString() ?? '0') ?? 0; opponentTimeoutsUsed = int.tryParse(gameResponse['opp_timeouts']?.toString() ?? '0') ?? 0; currentQuarter = int.tryParse(gameResponse['current_quarter']?.toString() ?? '1') ?? 1; gameWasAlreadyFinished = gameResponse['status'] == 'Terminado'; final teamsResponse = await supabase.from('teams').select('id, name').inFilter('name', [myTeam, opponentTeam]); for (var t in teamsResponse) { if (t['name'] == myTeam) myTeamDbId = t['id']; if (t['name'] == opponentTeam) oppTeamDbId = t['id']; } List myPlayers = myTeamDbId != null ? await supabase.from('members').select().eq('team_id', myTeamDbId!).eq('type', 'Jogador') : []; List oppPlayers = oppTeamDbId != null ? await supabase.from('members').select().eq('team_id', oppTeamDbId!).eq('type', 'Jogador') : []; final statsResponse = await supabase.from('player_stats').select().eq('game_id', gameId); final Map savedStats = { for (var item in statsResponse) item['member_id'].toString(): item }; for (int i = 0; i < myPlayers.length; i++) { String dbId = myPlayers[i]['id'].toString(); String name = myPlayers[i]['name'].toString(); _registerPlayer(name: name, number: myPlayers[i]['number']?.toString() ?? "0", dbId: dbId, isMyTeam: true, isCourt: i < 5); if (savedStats.containsKey(dbId)) { var s = savedStats[dbId]; playerStats[dbId] = { "pts": s['pts'] ?? 0, "rbs": s['rbs'] ?? 0, "ast": s['ast'] ?? 0, "stl": s['stl'] ?? 0, "tov": s['tov'] ?? 0, "blk": s['blk'] ?? 0, "fls": s['fls'] ?? 0, "fgm": s['fgm'] ?? 0, "fga": s['fga'] ?? 0, "ftm": s['ftm'] ?? 0, "fta": s['fta'] ?? 0, "orb": s['orb'] ?? 0, "drb": s['drb'] ?? 0, }; myFouls += (s['fls'] as int? ?? 0); } } _padTeam(myCourt, myBench, "Jogador", isMyTeam: true); for (int i = 0; i < oppPlayers.length; i++) { String dbId = oppPlayers[i]['id'].toString(); String name = oppPlayers[i]['name'].toString(); _registerPlayer(name: name, number: oppPlayers[i]['number']?.toString() ?? "0", dbId: dbId, isMyTeam: false, isCourt: i < 5); if (savedStats.containsKey(dbId)) { var s = savedStats[dbId]; playerStats[dbId] = { "pts": s['pts'] ?? 0, "rbs": s['rbs'] ?? 0, "ast": s['ast'] ?? 0, "stl": s['stl'] ?? 0, "tov": s['tov'] ?? 0, "blk": s['blk'] ?? 0, "fls": s['fls'] ?? 0, "fgm": s['fgm'] ?? 0, "fga": s['fga'] ?? 0, "ftm": s['ftm'] ?? 0, "fta": s['fta'] ?? 0, "orb": s['orb'] ?? 0, "drb": s['drb'] ?? 0, }; opponentFouls += (s['fls'] as int? ?? 0); } } _padTeam(oppCourt, oppBench, "Adversário", isMyTeam: false); final shotsResponse = await supabase.from('shot_locations').select().eq('game_id', gameId); for (var shotData in shotsResponse) { matchShots.add(ShotRecord( relativeX: double.parse(shotData['relative_x'].toString()), relativeY: double.parse(shotData['relative_y'].toString()), isMake: shotData['is_make'] == true, playerId: shotData['member_id'].toString(), playerName: shotData['player_name'].toString(), zone: shotData['zone']?.toString(), points: shotData['points'] != null ? int.parse(shotData['points'].toString()) : null, )); } // 👇 AUTO-SAVE: SE O JOGO FOI ABAIXO A MEIO, RECUPERA TUDO AQUI! 👇 await _loadLocalBackup(); isLoading = false; notifyListeners(); // Substitui o antigo onUpdate! } catch (e) { debugPrint("Erro ao retomar jogo: $e"); isLoading = false; notifyListeners(); } } void _registerPlayer({required String name, required String number, String? dbId, required bool isMyTeam, required bool isCourt}) { String id = dbId ?? "fake_${DateTime.now().millisecondsSinceEpoch}_${math.Random().nextInt(9999)}"; playerNames[id] = name; playerNumbers[id] = number; playerStats[id] = { "pts": 0, "rbs": 0, "ast": 0, "stl": 0, "tov": 0, "blk": 0, "fls": 0, "fgm": 0, "fga": 0, "ftm": 0, "fta": 0, "orb": 0, "drb": 0 }; if (isMyTeam) { if (isCourt) myCourt.add(id); else myBench.add(id); } else { if (isCourt) oppCourt.add(id); else oppBench.add(id); } } void _padTeam(List court, List bench, String prefix, {required bool isMyTeam}) { while (court.length < 5) { _registerPlayer(name: "Sem $prefix ${court.length + 1}", number: "0", dbId: null, isMyTeam: isMyTeam, isCourt: true); } } // ========================================================================= // 👇 AS DUAS FUNÇÕES MÁGICAS DO AUTO-SAVE 👇 // ========================================================================= Future _saveLocalBackup() async { try { final prefs = await SharedPreferences.getInstance(); final backupData = { 'myScore': myScore, 'opponentScore': opponentScore, 'myFouls': myFouls, 'opponentFouls': opponentFouls, 'currentQuarter': currentQuarter, 'duration': durationNotifier.value.inSeconds, 'myTimeoutsUsed': myTimeoutsUsed, 'opponentTimeoutsUsed': opponentTimeoutsUsed, 'playerStats': playerStats, 'myCourt': myCourt, 'myBench': myBench, 'oppCourt': oppCourt, 'oppBench': oppBench, 'matchShots': matchShots.map((s) => s.toJson()).toList(), }; await prefs.setString('backup_$gameId', jsonEncode(backupData)); } catch (e) { debugPrint("Erro no Auto-Save: $e"); } } Future _loadLocalBackup() async { try { final prefs = await SharedPreferences.getInstance(); final String? backupString = prefs.getString('backup_$gameId'); if (backupString != null) { final data = jsonDecode(backupString); myScore = data['myScore']; opponentScore = data['opponentScore']; myFouls = data['myFouls']; opponentFouls = data['opponentFouls']; currentQuarter = data['currentQuarter']; durationNotifier.value = Duration(seconds: data['duration']); myTimeoutsUsed = data['myTimeoutsUsed']; opponentTimeoutsUsed = data['opponentTimeoutsUsed']; myCourt = List.from(data['myCourt']); myBench = List.from(data['myBench']); oppCourt = List.from(data['oppCourt']); oppBench = List.from(data['oppBench']); Map decodedStats = data['playerStats']; playerStats = decodedStats.map((k, v) => MapEntry(k, Map.from(v))); List decodedShots = data['matchShots']; matchShots = decodedShots.map((s) => ShotRecord.fromJson(s)).toList(); debugPrint("🔄 AUTO-SAVE RECUPERADO COM SUCESSO!"); } } catch (e) { debugPrint("Erro ao carregar Auto-Save: $e"); } } void toggleTimer(BuildContext context) { if (isRunning) { timer?.cancel(); _saveLocalBackup(); // Grava no telemóvel quando pausa! } else { timer = Timer.periodic(const Duration(seconds: 1), (timer) { if (durationNotifier.value.inSeconds > 0) { durationNotifier.value -= const Duration(seconds: 1); // 👈 Só o relógio atualiza, a app não pisca! } else { timer.cancel(); isRunning = false; if (currentQuarter < 4) { currentQuarter++; durationNotifier.value = const Duration(minutes: 10); myFouls = 0; opponentFouls = 0; myTimeoutsUsed = 0; opponentTimeoutsUsed = 0; _saveLocalBackup(); // Grava mudança de período } notifyListeners(); // Aqui sim, redesenhamos o ecrã para mudar o Quarto } }); } isRunning = !isRunning; notifyListeners(); } void useTimeout(bool isOpponent) { if (isOpponent) { if (opponentTimeoutsUsed < 3) opponentTimeoutsUsed++; } else { if (myTimeoutsUsed < 3) myTimeoutsUsed++; } isRunning = false; timer?.cancel(); _saveLocalBackup(); notifyListeners(); } void handleActionDrag(BuildContext context, String action, String playerData) { String playerId = playerData.replaceAll("player_my_", "").replaceAll("player_opp_", ""); final stats = playerStats[playerId]!; final name = playerNames[playerId]!; if (stats["fls"]! >= 5 && action != "sub_foul") { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('🛑 $name atingiu 5 faltas e está expulso!'), backgroundColor: Colors.red)); return; } if (action == "add_pts_2" || action == "add_pts_3" || action == "miss_2" || action == "miss_3") { pendingAction = action; pendingPlayerId = playerData; isSelectingShotLocation = true; } else { commitStat(action, playerData); } notifyListeners(); } void handleSubbing(BuildContext context, String action, String courtPlayerId, bool isOpponent) { if (action.startsWith("bench_my_") && !isOpponent) { String benchPlayerId = action.replaceAll("bench_my_", ""); if (playerStats[benchPlayerId]!["fls"]! >= 5) return; int courtIndex = myCourt.indexOf(courtPlayerId); int benchIndex = myBench.indexOf(benchPlayerId); myCourt[courtIndex] = benchPlayerId; myBench[benchIndex] = courtPlayerId; showMyBench = false; } if (action.startsWith("bench_opp_") && isOpponent) { String benchPlayerId = action.replaceAll("bench_opp_", ""); if (playerStats[benchPlayerId]!["fls"]! >= 5) return; int courtIndex = oppCourt.indexOf(courtPlayerId); int benchIndex = oppBench.indexOf(benchPlayerId); oppCourt[courtIndex] = benchPlayerId; oppBench[benchIndex] = courtPlayerId; showOppBench = false; } _saveLocalBackup(); notifyListeners(); } void registerShotFromPopup(BuildContext context, String action, String targetPlayer, String zone, int points, double relativeX, double relativeY) { String playerId = targetPlayer.replaceAll("player_my_", "").replaceAll("player_opp_", ""); bool isMyTeam = targetPlayer.startsWith("player_my_"); bool isMake = action.startsWith("add_"); String name = playerNames[playerId]!; if (playerStats.containsKey(playerId)) { playerStats[playerId]!['fga'] = playerStats[playerId]!['fga']! + 1; if (isMake) { playerStats[playerId]!['fgm'] = playerStats[playerId]!['fgm']! + 1; playerStats[playerId]!['pts'] = playerStats[playerId]!['pts']! + points; if (isMyTeam) myScore += points; else opponentScore += points; } } matchShots.add(ShotRecord(relativeX: relativeX, relativeY: relativeY, isMake: isMake, playerId: playerId, playerName: name, zone: zone, points: points)); _saveLocalBackup(); // 👈 Grava logo para não perder o cesto! notifyListeners(); } void registerShotLocation(BuildContext context, Offset position, Size size) { if (pendingAction == null || pendingPlayerId == null) return; bool is3Pt = pendingAction!.contains("_3"); bool is2Pt = pendingAction!.contains("_2"); if (is3Pt || is2Pt) { bool isValid = _validateShotZone(position, size, is3Pt); if (!isValid) return; } bool isMake = pendingAction!.startsWith("add_pts_"); double relX = position.dx / size.width; double relY = position.dy / size.height; String pId = pendingPlayerId!.replaceAll("player_my_", "").replaceAll("player_opp_", ""); matchShots.add(ShotRecord(relativeX: relX, relativeY: relY, isMake: isMake, playerId: pId, playerName: playerNames[pId]!)); commitStat(pendingAction!, pendingPlayerId!); isSelectingShotLocation = false; pendingAction = null; pendingPlayerId = null; _saveLocalBackup(); // 👈 Grava logo notifyListeners(); } bool _validateShotZone(Offset position, Size size, bool is3Pt) { double relX = position.dx / size.width; double relY = position.dy / size.height; bool isLeftHalf = relX < 0.5; double hoopX = isLeftHalf ? hoopBaseX : (1.0 - hoopBaseX); double hoopY = 0.50; double aspectRatio = size.width / size.height; double distFromCenterY = (relY - hoopY).abs(); bool isInside2Pts; if (distFromCenterY > cornerY) { double distToBaseline = isLeftHalf ? relX : (1.0 - relX); isInside2Pts = distToBaseline <= hoopBaseX; } else { double dx = (relX - hoopX) * aspectRatio; double dy = (relY - hoopY); double distanceToHoop = math.sqrt((dx * dx) + (dy * dy)); isInside2Pts = distanceToHoop < arcRadius; } if (is3Pt) return !isInside2Pts; return isInside2Pts; } void cancelShotLocation() { isSelectingShotLocation = false; pendingAction = null; pendingPlayerId = null; notifyListeners(); } void commitStat(String action, String playerData) { bool isOpponent = playerData.startsWith("player_opp_"); String playerId = playerData.replaceAll("player_my_", "").replaceAll("player_opp_", ""); final stats = playerStats[playerId]!; if (action.startsWith("add_pts_")) { int pts = int.parse(action.split("_").last); if (isOpponent) opponentScore += pts; else myScore += pts; stats["pts"] = stats["pts"]! + pts; if (pts == 2 || pts == 3) { stats["fgm"] = stats["fgm"]! + 1; stats["fga"] = stats["fga"]! + 1; } if (pts == 1) { stats["ftm"] = stats["ftm"]! + 1; stats["fta"] = stats["fta"]! + 1; } } else if (action.startsWith("sub_pts_")) { int pts = int.parse(action.split("_").last); if (isOpponent) { opponentScore = (opponentScore - pts < 0) ? 0 : opponentScore - pts; } else { myScore = (myScore - pts < 0) ? 0 : myScore - pts; } stats["pts"] = (stats["pts"]! - pts < 0) ? 0 : stats["pts"]! - pts; if (pts == 2 || pts == 3) { if (stats["fgm"]! > 0) stats["fgm"] = stats["fgm"]! - 1; if (stats["fga"]! > 0) stats["fga"] = stats["fga"]! - 1; } if (pts == 1) { if (stats["ftm"]! > 0) stats["ftm"] = stats["ftm"]! - 1; if (stats["fta"]! > 0) stats["fta"] = stats["fta"]! - 1; } } else if (action == "miss_1") { stats["fta"] = stats["fta"]! + 1; } else if (action == "miss_2" || action == "miss_3") { stats["fga"] = stats["fga"]! + 1; } else if (action == "add_orb") { stats["orb"] = stats["orb"]! + 1; stats["rbs"] = stats["rbs"]! + 1; } else if (action == "add_drb") { stats["drb"] = stats["drb"]! + 1; stats["rbs"] = stats["rbs"]! + 1; } else if (action == "add_ast") { stats["ast"] = stats["ast"]! + 1; } else if (action == "add_stl") { stats["stl"] = stats["stl"]! + 1; } else if (action == "add_tov") { stats["tov"] = stats["tov"]! + 1; } else if (action == "add_blk") { stats["blk"] = stats["blk"]! + 1; } else if (action == "add_foul") { stats["fls"] = stats["fls"]! + 1; if (isOpponent) { opponentFouls++; } else { myFouls++; } } else if (action == "sub_foul") { if (stats["fls"]! > 0) stats["fls"] = stats["fls"]! - 1; if (isOpponent) { if (opponentFouls > 0) opponentFouls--; } else { if (myFouls > 0) myFouls--; } } _saveLocalBackup(); // 👈 Grava na memória! } Future saveGameStats(BuildContext context) async { final supabase = Supabase.instance.client; isSaving = true; notifyListeners(); try { bool isGameFinishedNow = currentQuarter >= 4 && durationNotifier.value.inSeconds == 0; String newStatus = isGameFinishedNow ? 'Terminado' : 'Pausado'; String topPtsName = '---'; int maxPts = -1; String topAstName = '---'; int maxAst = -1; String topRbsName = '---'; int maxRbs = -1; String topDefName = '---'; int maxDef = -1; String mvpName = '---'; int maxMvpScore = -1; playerStats.forEach((playerId, stats) { int pts = stats['pts'] ?? 0; int ast = stats['ast'] ?? 0; int rbs = stats['rbs'] ?? 0; int stl = stats['stl'] ?? 0; int blk = stats['blk'] ?? 0; int defScore = stl + blk; int mvpScore = pts + ast + rbs + defScore; String pName = playerNames[playerId] ?? '---'; if (pts > maxPts && pts > 0) { maxPts = pts; topPtsName = '$pName ($pts)'; } if (ast > maxAst && ast > 0) { maxAst = ast; topAstName = '$pName ($ast)'; } if (rbs > maxRbs && rbs > 0) { maxRbs = rbs; topRbsName = '$pName ($rbs)'; } if (defScore > maxDef && defScore > 0) { maxDef = defScore; topDefName = '$pName ($defScore)'; } if (mvpScore > maxMvpScore && mvpScore > 0) { maxMvpScore = mvpScore; mvpName = pName; } }); await supabase.from('games').update({ 'my_score': myScore, 'opponent_score': opponentScore, 'remaining_seconds': durationNotifier.value.inSeconds, 'my_timeouts': myTimeoutsUsed, 'opp_timeouts': opponentTimeoutsUsed, 'current_quarter': currentQuarter, 'status': newStatus, 'top_pts_name': topPtsName, 'top_ast_name': topAstName, 'top_rbs_name': topRbsName, 'top_def_name': topDefName, 'mvp_name': mvpName, }).eq('id', gameId); if (isGameFinishedNow && !gameWasAlreadyFinished && myTeamDbId != null && oppTeamDbId != null) { final teamsData = await supabase.from('teams').select('id, wins, losses, draws').inFilter('id', [myTeamDbId, oppTeamDbId]); Map myTeamUpdate = {}; Map oppTeamUpdate = {}; for(var t in teamsData) { if(t['id'].toString() == myTeamDbId) myTeamUpdate = Map.from(t); if(t['id'].toString() == oppTeamDbId) oppTeamUpdate = Map.from(t); } if (myScore > opponentScore) { myTeamUpdate['wins'] = (myTeamUpdate['wins'] ?? 0) + 1; oppTeamUpdate['losses'] = (oppTeamUpdate['losses'] ?? 0) + 1; } else if (myScore < opponentScore) { myTeamUpdate['losses'] = (myTeamUpdate['losses'] ?? 0) + 1; oppTeamUpdate['wins'] = (oppTeamUpdate['wins'] ?? 0) + 1; } else { myTeamUpdate['draws'] = (myTeamUpdate['draws'] ?? 0) + 1; oppTeamUpdate['draws'] = (oppTeamUpdate['draws'] ?? 0) + 1; } await supabase.from('teams').update({'wins': myTeamUpdate['wins'], 'losses': myTeamUpdate['losses'], 'draws': myTeamUpdate['draws']}).eq('id', myTeamDbId!); await supabase.from('teams').update({'wins': oppTeamUpdate['wins'], 'losses': oppTeamUpdate['losses'], 'draws': oppTeamUpdate['draws']}).eq('id', oppTeamDbId!); gameWasAlreadyFinished = true; } List> batchStats = []; playerStats.forEach((playerId, stats) { if (!playerId.startsWith("fake_") && stats.values.any((val) => val > 0)) { bool isMyTeamPlayer = myCourt.contains(playerId) || myBench.contains(playerId); batchStats.add({ 'game_id': gameId, 'member_id': playerId, 'team_id': isMyTeamPlayer ? myTeamDbId! : oppTeamDbId!, 'pts': stats['pts'], 'rbs': stats['rbs'], 'ast': stats['ast'], 'stl': stats['stl'], 'blk': stats['blk'], 'tov': stats['tov'], 'fls': stats['fls'], 'fgm': stats['fgm'], 'fga': stats['fga'], 'ftm': stats['ftm'], 'fta': stats['fta'], 'orb': stats['orb'], 'drb': stats['drb'], }); } }); await supabase.from('player_stats').delete().eq('game_id', gameId); if (batchStats.isNotEmpty) await supabase.from('player_stats').insert(batchStats); List> batchShots = []; for (var shot in matchShots) { if (!shot.playerId.startsWith("fake_")) { batchShots.add({ 'game_id': gameId, 'member_id': shot.playerId, 'player_name': shot.playerName, 'relative_x': shot.relativeX, 'relative_y': shot.relativeY, 'is_make': shot.isMake, 'zone': shot.zone ?? 'Desconhecida', 'points': shot.points ?? (shot.isMake ? 2 : 0), }); } } await supabase.from('shot_locations').delete().eq('game_id', gameId); if (batchShots.isNotEmpty) await supabase.from('shot_locations').insert(batchShots); // 👇 SE O SUPABASE GUARDOU COM SUCESSO, LIMPA A MEMÓRIA DO TELEMÓVEL! 👇 final prefs = await SharedPreferences.getInstance(); await prefs.remove('backup_$gameId'); if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Estatísticas, Mapa de Calor e Resultados guardados com Sucesso!'), backgroundColor: Colors.green)); } catch (e) { debugPrint("Erro ao gravar estatísticas: $e"); if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Erro ao guardar: $e'), backgroundColor: Colors.red)); } finally { isSaving = false; notifyListeners(); } } @override void dispose() { timer?.cancel(); super.dispose(); } }