diff --git a/SYNC_CHANGES_SUMMARY.md b/SYNC_CHANGES_SUMMARY.md new file mode 100644 index 0000000..1498094 --- /dev/null +++ b/SYNC_CHANGES_SUMMARY.md @@ -0,0 +1,157 @@ +# Resumo das Mudanças - Sincronização de Jogo em Tempo Real + +## 1. lib/controllers/placar_controller.dart + +### Adicionado ao constructor: +```dart +final void Function(String actionType, Map actionData)? onSyncAction; + +PlacarController({ + required this.gameId, + required this.myTeam, + required this.opponentTeam, + this.onSyncAction, // ← NOVO +}); +``` + +### Adicionado método _dispatchSyncAction: +```dart +void _dispatchSyncAction(String actionType, Map actionData) { + if (onSyncAction != null) { + final enrichedActionData = Map.from(actionData) + ..['remaining_seconds'] = durationNotifier.value.inSeconds + ..['is_running'] = isRunning; + onSyncAction!(actionType, enrichedActionData); + } +} +``` + +### Adicionado em 5 métodos (chamada _dispatchSyncAction): +- `useTimeout()` → dispatch `'use_timeout'` +- `handleSubbing()` → dispatch `'subbing'` +- `swapCourtPlayers()` → dispatch `'swap_players'` +- `registerFoul()` → dispatch `'register_foul'` +- `commitStat()` → dispatch `'commit_stat'` + +**Exemplo em commitStat:** +```dart +_dispatchSyncAction('commit_stat', { + 'action': action, + 'player_data': playerData, +}); +``` + +--- + +## 2. lib/pages/PlacarPage.dart + +### Adicionado ao state: +```dart +String? _lastAppliedSyncEventId; // ← NOVO - deduplicação de eventos +``` + +### Constructor do controller: +```dart +_controller = PlacarController( + gameId: widget.gameId, + myTeam: widget.myTeam, + opponentTeam: widget.opponentTeam, + onSyncAction: _onLocalControllerSync, // ← CONECTADO +); +``` + +### Adicionado novo método _onLocalControllerSync: +```dart +void _onLocalControllerSync(String actionType, Map actionData) { + if (_sessionId == null || _isApplyingRemoteSync) return; + print("📤 Enviando sync action local: $actionType -> $actionData"); + _sharingController.sendSyncEvent(_sessionId!, actionType, actionData); +} +``` + +### Atualizado _setupSyncListener (deduplicação): +```dart +_syncSubscription = _sharingController.listenToGameSyncOthers(_sessionId!).listen( + (dynamic event) { + Map? record; + if (event is List && event.isNotEmpty) { + for (final item in event) { + final row = item as Map?; + if (row == null) continue; + final rowId = row['id']?.toString(); + if (rowId != null && rowId != _lastAppliedSyncEventId) { + record = row; + break; // ← para no primeiro evento novo + } + } + } else if (event is Map) { + record = Map.from(event); + } + + if (record != null) { + final recordId = record['id']?.toString(); + if (recordId != null && recordId == _lastAppliedSyncEventId) return; + if (recordId != null) _lastAppliedSyncEventId = recordId; + _applyRemoteSyncEvent(record); + } + }, +); +``` + +### Atualizado _applyRemoteSyncEvent (aplicar estado remoto): +```dart +void _applyRemoteSyncEvent(Map record) { + final actionType = record['action_type']?.toString(); + final actionData = Map.from(record['action_data'] ?? {}); + + // ← NOVO: aplicar timer remotamente em TODAS as ações + final remoteSeconds = int.tryParse(actionData['remaining_seconds']?.toString() ?? ''); + final remoteIsRunning = actionData['is_running'] == true; + if (remoteSeconds != null) { + _controller.durationNotifier.value = Duration(seconds: remoteSeconds); + } + if (remoteIsRunning != _controller.isRunning) { + _isApplyingRemoteSync = true; + _controller.toggleTimer(context); + _isApplyingRemoteSync = false; + } + + // Aplicar ações específicas + if (actionType == 'toggle_timer') { + setState(() {}); + } else if (actionType == 'commit_stat') { + // aplicar pontos/faltas + } else if (actionType == 'register_foul') { + // aplicar falta + } else if (actionType == 'subbing') { + // aplicar substituição + } else if (actionType == 'swap_players') { + // trocar posição + } else if (actionType == 'use_timeout') { + // usar timeout + } +} +``` + +--- + +## Fluxo Completo + +1. **Ação Local** → `commitStat()` no controller +2. **Controller emite** → `_dispatchSyncAction('commit_stat', {action, player_data, remaining_seconds, is_running})` +3. **PlacarPage escuta** → `_onLocalControllerSync()` recebe o evento +4. **Envia ao Supabase** → `sendSyncEvent()` armazena em `game_sync_events` +5. **Parceiro recebe** → `listenToGameSyncOthers()` retorna o evento +6. **Aplica remotamente** → `_applyRemoteSyncEvent()` executa a ação no parceiro +7. **Estado sincronizado** → Ambos têm timer, pontos, faltas idênticos + +--- + +## Resultado + +✅ Timer não reseta ao marcar ponto +✅ Pontos sincronizam entre os dois lados +✅ Faltas sincronizam +✅ Timeouts sincronizam +✅ Substituições sincronizam +✅ Posições de jogadores sincronizam diff --git a/assets/campone.png b/assets/campone.png new file mode 100644 index 0000000..31705f5 Binary files /dev/null and b/assets/campone.png differ diff --git a/lib/controllers/game_sharing_controller.dart b/lib/controllers/game_sharing_controller.dart index a9db6cb..35ee370 100644 --- a/lib/controllers/game_sharing_controller.dart +++ b/lib/controllers/game_sharing_controller.dart @@ -1,3 +1,4 @@ +// ...existing code... import 'package:supabase_flutter/supabase_flutter.dart'; import 'dart:math'; @@ -81,14 +82,18 @@ class GameSharingController { .from('profiles') .select('username, full_name') .eq('id', createdBy) - .single(); + .maybeSingle(); - print("👤 Criador: ${creatorData['full_name'] ?? creatorData['username']}"); + final creatorName = creatorData != null + ? (creatorData['full_name'] ?? creatorData['username'] ?? 'Utilizador') + : 'Utilizador'; + + print("👤 Criador: $creatorName"); return { 'session_id': session['id'], 'game_id': gameId, - 'creator_name': creatorData['full_name'] ?? creatorData['username'] ?? 'Utilizador', + 'creator_name': creatorName, 'game': gameData, }; } catch (e) { @@ -156,14 +161,16 @@ class GameSharingController { Future sendSyncEvent( String sessionId, String actionType, - Map actionData, - ) async { + Map actionData, { + String? playerId, // opcional: identifica jogador/entidade alvo + }) async { try { await _supabase.from('game_sync_events').insert({ 'session_id': sessionId, 'action_type': actionType, 'action_data': actionData, 'triggered_by': myUserId, + if (playerId != null) 'player_id': playerId, }); print("✅ Evento sincronizado: $actionType"); @@ -186,6 +193,24 @@ class GameSharingController { .order('created_at', ascending: false); } + /// Retorna apenas os eventos que NÃO foram disparados pelo utilizador atual. + /// Emite uma lista de eventos (List>) por cada atualização. + Stream>> listenToGameSyncOthers(String sessionId) { + return listenToGameSync(sessionId).map((data) { + List> rows = []; + try { + if (data is List) { + rows = List>.from(data); + } else if (data is Map) { + rows = [Map.from(data)]; + } + } catch (_) { + return >[]; + } + return rows.where((r) => (r['triggered_by'] as String?) != myUserId).toList(); + }); + } + // ==================================== // 6️⃣ OBTER ÚLTIMOS EVENTOS // ==================================== diff --git a/lib/controllers/placar_controller.dart b/lib/controllers/placar_controller.dart index a3e07fc..56cd88c 100644 --- a/lib/controllers/placar_controller.dart +++ b/lib/controllers/placar_controller.dart @@ -9,15 +9,15 @@ class ShotRecord { final double relativeX; final double relativeY; final bool isMake; - final String playerId; - final String playerName; + final String playerId; + final String playerName; final String? zone; final int? points; ShotRecord({ - required this.relativeX, - required this.relativeY, - required this.isMake, + required this.relativeX, + required this.relativeY, + required this.isMake, required this.playerId, required this.playerName, this.zone, @@ -25,13 +25,23 @@ class ShotRecord { }); Map toJson() => { - 'relativeX': relativeX, 'relativeY': relativeY, 'isMake': isMake, - 'playerId': playerId, 'playerName': playerName, 'zone': zone, 'points': points, + 'relativeX': relativeX, + 'relativeY': relativeY, + 'isMake': isMake, + 'playerId': playerId, + 'playerName': playerName, + 'zone': zone, + 'points': points, }; 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'], + relativeX: json['relativeX'], + relativeY: json['relativeY'], + isMake: json['isMake'], + playerId: json['playerId'], + playerName: json['playerName'], + zone: json['zone'], + points: json['points'], ); } @@ -39,16 +49,99 @@ class PlacarController extends ChangeNotifier { final String gameId; final String myTeam; final String opponentTeam; + final void Function(String actionType, Map actionData)? + onSyncAction; PlacarController({ - required this.gameId, - required this.myTeam, - required this.opponentTeam, + required this.gameId, + required this.myTeam, + required this.opponentTeam, + this.onSyncAction, }); + void _dispatchSyncAction(String actionType, Map actionData) { + if (onSyncAction != null) { + final enrichedActionData = Map.from(actionData) + ..['remaining_seconds'] = durationNotifier.value.inSeconds + ..['is_running'] = isRunning + ..['current_quarter'] = currentQuarter + ..['my_fouls'] = myFouls + ..['opponent_fouls'] = opponentFouls + ..['my_timeouts_used'] = myTimeoutsUsed + ..['opponent_timeouts_used'] = opponentTimeoutsUsed; + onSyncAction!(actionType, enrichedActionData); + } + } + + void _startTimer() { + timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!isRunning) return; + + if (durationNotifier.value.inSeconds > 0) { + void addTimeToCourt(List court) { + for (String id in court) { + if (playerStats.containsKey(id)) { + int currentSec = playerStats[id]!['sec'] ?? 0; + playerStats[id]!['sec'] = currentSec + 1; + playerStats[id]!['min'] = (currentSec + 1) ~/ 60; + } + } + } + + addTimeToCourt(myCourt); + addTimeToCourt(oppCourt); + durationNotifier.value -= const Duration(seconds: 1); + } else { + timer.cancel(); + isRunning = false; + if (currentQuarter < 4) { + currentQuarter++; + durationNotifier.value = const Duration(minutes: 10); + myFouls = 0; + opponentFouls = 0; + myTimeoutsUsed = 0; + opponentTimeoutsUsed = 0; + _scheduleAutoSave(); + } + notifyListeners(); + _dispatchSyncAction('period_ended', {}); + } + }); + } + + void _setTimerRunning(bool shouldRun, {bool emitSync = true}) { + print("🔧 _setTimerRunning: shouldRun=$shouldRun, isRunning=$isRunning"); + if (shouldRun == isRunning) { + print("🔧 Guardado: shouldRun == isRunning"); + return; + } + + isRunning = shouldRun; + if (!shouldRun) { + print("🛑 Cancelando timer"); + timer?.cancel(); + _scheduleAutoSave(); + } else { + print("▶️ Iniciando timer"); + _startTimer(); + } + + notifyListeners(); + print("✅ notifyListeners chamado"); + + if (emitSync) { + print("📡 Despachando sync action"); + _dispatchSyncAction('toggle_timer', {'is_running': isRunning}); + } + } + + void applyRemoteTimerState(bool shouldRun) { + _setTimerRunning(shouldRun, emitSync: false); + } + bool isLoading = true; bool isSaving = false; - bool gameWasAlreadyFinished = false; + bool gameWasAlreadyFinished = false; int myScore = 0; int opponentScore = 0; @@ -66,7 +159,7 @@ class PlacarController extends ChangeNotifier { List oppCourt = []; List oppBench = []; - Map playerNames = {}; + Map playerNames = {}; Map playerNumbers = {}; Map> playerStats = {}; @@ -75,44 +168,66 @@ class PlacarController extends ChangeNotifier { bool isSelectingShotLocation = false; String? pendingAction; - String? pendingPlayerId; + String? pendingPlayerId; List matchShots = []; - + List playByPlay = []; - ValueNotifier durationNotifier = ValueNotifier(const Duration(minutes: 10)); + 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; + bool isCalibrating = false; + double hoopBaseX = 0.088; + double arcRadius = 0.459; + double cornerY = 0.440; Timer? _autoSaveTimer; Future loadPlayers() async { final supabase = Supabase.instance.client; try { - await Future.delayed(const Duration(milliseconds: 1500)); + await Future.delayed(const Duration(milliseconds: 1500)); - myCourt.clear(); myBench.clear(); oppCourt.clear(); oppBench.clear(); - playerNames.clear(); playerStats.clear(); playerNumbers.clear(); - matchShots.clear(); playByPlay.clear(); myFouls = 0; opponentFouls = 0; + myCourt.clear(); + myBench.clear(); + oppCourt.clear(); + oppBench.clear(); + playerNames.clear(); + playerStats.clear(); + playerNumbers.clear(); + matchShots.clear(); + playByPlay.clear(); + myFouls = 0; + opponentFouls = 0; + + final gameResponse = await supabase + .from('games') + .select() + .eq('id', gameId) + .single(); - 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; + 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'; + + 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'; if (gameResponse['play_by_play'] != null) { playByPlay = List.from(gameResponse['play_by_play']); @@ -120,30 +235,54 @@ class PlacarController extends ChangeNotifier { playByPlay = []; } - final teamsResponse = await supabase.from('teams').select('id, name').inFilter('name', [myTeam, opponentTeam]); + 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') : []; + 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 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 (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); + + _registerPlayer( + name: name, + number: myPlayers[i]['number']?.toString() ?? "0", + dbId: dbId, + isMyTeam: true, + isCourt: i < 5, + ); if (savedStats.containsKey(dbId)) { var s = savedStats[dbId]; _loadSavedPlayerStats(dbId, s); - myFouls += (s['fls'] as int? ?? 0); + myFouls += (s['fls'] as int? ?? 0); } } _padTeam(myCourt, myBench, "Jogador", isMyTeam: true); @@ -151,8 +290,14 @@ class PlacarController extends ChangeNotifier { 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); + + _registerPlayer( + name: name, + number: oppPlayers[i]['number']?.toString() ?? "0", + dbId: dbId, + isMyTeam: false, + isCourt: i < 5, + ); if (savedStats.containsKey(dbId)) { var s = savedStats[dbId]; @@ -162,23 +307,30 @@ class PlacarController extends ChangeNotifier { } _padTeam(oppCourt, oppBench, "Adversário", isMyTeam: false); - final shotsResponse = await supabase.from('shot_locations').select().eq('game_id', gameId); + 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, - )); + 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, + ), + ); } await _loadLocalBackup(); isLoading = false; - notifyListeners(); + notifyListeners(); } catch (e) { debugPrint("Erro ao retomar jogo: $e"); isLoading = false; @@ -188,42 +340,103 @@ class PlacarController extends ChangeNotifier { void _loadSavedPlayerStats(String dbId, Map s) { 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, - "p2m": s['p2m'] ?? 0, "p2a": s['p2a'] ?? 0, "p3m": s['p3m'] ?? 0, "p3a": s['p3a'] ?? 0, - "so": s['so'] ?? 0, "il": s['il'] ?? 0, "li": s['li'] ?? 0, - "pa": s['pa'] ?? 0, "tres_seg": s['tres_seg'] ?? 0, "dr": s['dr'] ?? 0, + "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, + "p2m": s['p2m'] ?? 0, + "p2a": s['p2a'] ?? 0, + "p3m": s['p3m'] ?? 0, + "p3a": s['p3a'] ?? 0, + "so": s['so'] ?? 0, + "il": s['il'] ?? 0, + "li": s['li'] ?? 0, + "pa": s['pa'] ?? 0, + "tres_seg": s['tres_seg'] ?? 0, + "dr": s['dr'] ?? 0, "min": (s['minutos_jogados'] ?? 0) ~/ 60, "sec": s['minutos_jogados'] ?? 0, }; } - 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)}"; - + 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, - "p2m": 0, "p2a": 0, "p3m": 0, "p3a": 0, - "so": 0, "il": 0, "li": 0, "pa": 0, "tres_seg": 0, "dr": 0, - "min": 0, "sec": 0 + "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, + "p2m": 0, + "p2a": 0, + "p3m": 0, + "p3a": 0, + "so": 0, + "il": 0, + "li": 0, + "pa": 0, + "tres_seg": 0, + "dr": 0, + "min": 0, + "sec": 0, }; if (isMyTeam) { - if (isCourt) myCourt.add(id); else myBench.add(id); + if (isCourt) + myCourt.add(id); + else + myBench.add(id); } else { - if (isCourt) oppCourt.add(id); else oppBench.add(id); + if (isCourt) + oppCourt.add(id); + else + oppBench.add(id); } } - void _padTeam(List court, List bench, String prefix, {required bool isMyTeam}) { + 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); + _registerPlayer( + name: "Sem $prefix ${court.length + 1}", + number: "0", + dbId: null, + isMyTeam: isMyTeam, + isCourt: true, + ); } } @@ -238,14 +451,21 @@ class PlacarController extends ChangeNotifier { 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, + '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, + 'myCourt': myCourt, + 'myBench': myBench, + 'oppCourt': oppCourt, + 'oppBench': oppBench, 'matchShots': matchShots.map((s) => s.toJson()).toList(), - 'playByPlay': playByPlay, + 'playByPlay': playByPlay, }; await prefs.setString('backup_$gameId', jsonEncode(backupData)); } catch (e) { @@ -257,25 +477,33 @@ class PlacarController extends ChangeNotifier { 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']); - + + 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))); - + playerStats = decodedStats.map( + (k, v) => MapEntry(k, Map.from(v)), + ); + List decodedShots = data['matchShots']; matchShots = decodedShots.map((s) => ShotRecord.fromJson(s)).toList(); - - playByPlay = List.from(data['playByPlay'] ?? []); + + playByPlay = List.from(data['playByPlay'] ?? []); } } catch (e) { debugPrint("Erro ao carregar Auto-Save: $e"); @@ -283,45 +511,10 @@ class PlacarController extends ChangeNotifier { } void toggleTimer(BuildContext context) { - if (isRunning) { - timer?.cancel(); - _scheduleAutoSave(); - } else { - timer = Timer.periodic(const Duration(seconds: 1), (timer) { - if (durationNotifier.value.inSeconds > 0) { - - void addTimeToCourt(List court) { - for (String id in court) { - if (playerStats.containsKey(id)) { - int currentSec = playerStats[id]!["sec"] ?? 0; - playerStats[id]!["sec"] = currentSec + 1; - playerStats[id]!["min"] = (currentSec + 1) ~/ 60; - } - } - } - addTimeToCourt(myCourt); - addTimeToCourt(oppCourt); - - durationNotifier.value -= const Duration(seconds: 1); - - } else { - timer.cancel(); - isRunning = false; - if (currentQuarter < 4) { - currentQuarter++; - durationNotifier.value = const Duration(minutes: 10); - myFouls = 0; opponentFouls = 0; - myTimeoutsUsed = 0; opponentTimeoutsUsed = 0; - _scheduleAutoSave(); - } - notifyListeners(); - } - }); - } - isRunning = !isRunning; - notifyListeners(); + print("⏱️ toggleTimer chamado: isRunning=$isRunning"); + _setTimerRunning(!isRunning); } - + void useTimeout(bool isOpponent) { if (isOpponent) { if (opponentTimeoutsUsed < 3) opponentTimeoutsUsed++; @@ -332,19 +525,34 @@ class PlacarController extends ChangeNotifier { timer?.cancel(); _scheduleAutoSave(); notifyListeners(); + _dispatchSyncAction('use_timeout', {'is_opponent': isOpponent}); } - void handleActionDrag(BuildContext context, String action, String playerData) { - String playerId = playerData.replaceAll("player_my_", "").replaceAll("player_opp_", ""); + 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)); + 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") { + if (action == "add_pts_2" || + action == "add_pts_3" || + action == "miss_2" || + action == "miss_3") { pendingAction = action; pendingPlayerId = playerData; isSelectingShotLocation = true; @@ -354,10 +562,15 @@ class PlacarController extends ChangeNotifier { notifyListeners(); } - void handleSubbing(BuildContext context, String action, String courtPlayerId, bool isOpponent) { + 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; + if (playerStats[benchPlayerId]!["fls"]! >= 5) return; int courtIndex = myCourt.indexOf(courtPlayerId); int benchIndex = myBench.indexOf(benchPlayerId); myCourt[courtIndex] = benchPlayerId; @@ -375,31 +588,38 @@ class PlacarController extends ChangeNotifier { } _scheduleAutoSave(); notifyListeners(); + _dispatchSyncAction('subbing', { + 'action': action, + 'court_player': courtPlayerId, + 'is_opponent': isOpponent, + }); } // ── TROCAR JOGADORES NO CAMPO ────────────────────────────────────────────── void swapCourtPlayers(String draggedPlayerData, String targetPlayerData) { // Verifica se são da mesma equipa (Minha Equipa) - if (draggedPlayerData.startsWith("player_my_") && targetPlayerData.startsWith("player_my_")) { + if (draggedPlayerData.startsWith("player_my_") && + targetPlayerData.startsWith("player_my_")) { String id1 = draggedPlayerData.replaceAll("player_my_", ""); String id2 = targetPlayerData.replaceAll("player_my_", ""); - + int idx1 = myCourt.indexOf(id1); int idx2 = myCourt.indexOf(id2); - + if (idx1 != -1 && idx2 != -1) { myCourt[idx1] = id2; myCourt[idx2] = id1; } - } + } // Verifica se são da mesma equipa (Adversário) - else if (draggedPlayerData.startsWith("player_opp_") && targetPlayerData.startsWith("player_opp_")) { + else if (draggedPlayerData.startsWith("player_opp_") && + targetPlayerData.startsWith("player_opp_")) { String id1 = draggedPlayerData.replaceAll("player_opp_", ""); String id2 = targetPlayerData.replaceAll("player_opp_", ""); - + int idx1 = oppCourt.indexOf(id1); int idx2 = oppCourt.indexOf(id2); - + if (idx1 != -1 && idx2 != -1) { oppCourt[idx1] = id2; oppCourt[idx2] = id1; @@ -411,17 +631,38 @@ class PlacarController extends ChangeNotifier { _scheduleAutoSave(); notifyListeners(); + _dispatchSyncAction('swap_players', { + 'dragged': draggedPlayerData, + 'target': targetPlayerData, + }); } - 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_", ""); + 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 isMake = action.startsWith("add_"); String name = playerNames[playerId] ?? "Jogador"; - matchShots.add(ShotRecord( - relativeX: relativeX, relativeY: relativeY, isMake: isMake, - playerId: playerId, playerName: name, zone: zone, points: points - )); + matchShots.add( + ShotRecord( + relativeX: relativeX, + relativeY: relativeY, + isMake: isMake, + playerId: playerId, + playerName: name, + zone: zone, + points: points, + ), + ); String finalAction = isMake ? "add_pts_$points" : "miss_$points"; commitStat(finalAction, targetPlayer); @@ -430,26 +671,38 @@ class PlacarController extends ChangeNotifier { 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; + 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_", ""); + String pId = pendingPlayerId! + .replaceAll("player_my_", "") + .replaceAll("player_opp_", ""); + + matchShots.add( + ShotRecord( + relativeX: relX, + relativeY: relY, + isMake: isMake, + playerId: pId, + playerName: playerNames[pId]!, + ), + ); - matchShots.add(ShotRecord(relativeX: relX, relativeY: relY, isMake: isMake, playerId: pId, playerName: playerNames[pId]!)); - commitStat(pendingAction!, pendingPlayerId!); - - isSelectingShotLocation = false; pendingAction = null; pendingPlayerId = null; - _scheduleAutoSave(); + + isSelectingShotLocation = false; + pendingAction = null; + pendingPlayerId = null; + _scheduleAutoSave(); notifyListeners(); } @@ -459,16 +712,16 @@ class PlacarController extends ChangeNotifier { bool isLeftHalf = relX < 0.5; double hoopX = isLeftHalf ? hoopBaseX : (1.0 - hoopBaseX); - double hoopY = 0.50; + double hoopY = 0.50; double aspectRatio = size.width / size.height; - double distFromCenterY = (relY - hoopY).abs(); + double distFromCenterY = (relY - hoopY).abs(); bool isInside2Pts; if (distFromCenterY > cornerY) { double distToBaseline = isLeftHalf ? relX : (1.0 - relX); - isInside2Pts = distToBaseline <= hoopBaseX; + isInside2Pts = distToBaseline <= hoopBaseX; } else { double dx = (relX - hoopX) * aspectRatio; double dy = (relY - hoopY); @@ -477,30 +730,40 @@ class PlacarController extends ChangeNotifier { } if (is3Pt) return !isInside2Pts; - return isInside2Pts; + return isInside2Pts; } void cancelShotLocation() { - isSelectingShotLocation = false; pendingAction = null; pendingPlayerId = null; notifyListeners(); + isSelectingShotLocation = false; + pendingAction = null; + pendingPlayerId = null; + notifyListeners(); } void registerFoul(String committerData, String foulType, String victimData) { bool isOpponent = committerData.startsWith("player_opp_"); - String committerId = committerData.replaceAll("player_my_", "").replaceAll("player_opp_", ""); + String committerId = committerData + .replaceAll("player_my_", "") + .replaceAll("player_opp_", ""); final committerStats = playerStats[committerId]!; final committerName = playerNames[committerId] ?? "Jogador"; committerStats["fls"] = committerStats["fls"]! + 1; - if (isOpponent) opponentFouls++; else myFouls++; + if (isOpponent) + opponentFouls++; + else + myFouls++; if (foulType == "Desqualificante") { - committerStats["fls"] = 5; + committerStats["fls"] = 5; } String logText = "cometeu Falta $foulType"; if (victimData.isNotEmpty) { - String victimId = victimData.replaceAll("player_my_", "").replaceAll("player_opp_", ""); + String victimId = victimData + .replaceAll("player_my_", "") + .replaceAll("player_opp_", ""); final victimStats = playerStats[victimId]!; final victimName = playerNames[victimId] ?? "Jogador"; @@ -510,11 +773,17 @@ class PlacarController extends ChangeNotifier { logText += " (Equipa/Banco) ⚠️"; } - String time = "${durationNotifier.value.inMinutes.toString().padLeft(2, '0')}:${durationNotifier.value.inSeconds.remainder(60).toString().padLeft(2, '0')}"; + String time = + "${durationNotifier.value.inMinutes.toString().padLeft(2, '0')}:${durationNotifier.value.inSeconds.remainder(60).toString().padLeft(2, '0')}"; playByPlay.insert(0, "P$currentQuarter - $time: $committerName $logText"); _scheduleAutoSave(); notifyListeners(); + _dispatchSyncAction('register_foul', { + 'committer': committerData, + 'foulType': foulType, + 'victim': victimData, + }); } void commitStat(String action, String playerData) { @@ -553,13 +822,14 @@ class PlacarController extends ChangeNotifier { } logText = "marcou $pts pontos 🏀"; } - // ── ANULAR PONTOS ──────────────────────────────────────────────────────── else if (action.startsWith("sub_pts_")) { int ptsToAnul = int.parse(action.split("_").last); - int lastShotIndex = matchShots.lastIndexWhere((s) => - s.playerId == playerId && s.isMake == true && s.points == ptsToAnul); + int lastShotIndex = matchShots.lastIndexWhere( + (s) => + s.playerId == playerId && s.isMake == true && s.points == ptsToAnul, + ); if (lastShotIndex != -1) { matchShots.removeAt(lastShotIndex); @@ -588,7 +858,6 @@ class PlacarController extends ChangeNotifier { return; } } - // ── FALHAS ─────────────────────────────────────────────────────────────── else if (action == "miss_1") { stats["fta"] = stats["fta"]! + 1; @@ -602,7 +871,6 @@ class PlacarController extends ChangeNotifier { stats["p3a"] = stats["p3a"]! + 1; logText = "falhou lançamento de 3 ❌"; } - // ── RESSALTOS ───────────────────────────────────────────────────────────── else if (action == "add_orb") { stats["orb"] = stats["orb"]! + 1; @@ -613,19 +881,16 @@ class PlacarController extends ChangeNotifier { stats["rbs"] = stats["rbs"]! + 1; logText = "ganhou ressalto defensivo 🛡️"; } - // ── ASSISTÊNCIA ─────────────────────────────────────────────────────────── else if (action == "add_ast") { stats["ast"] = stats["ast"]! + 1; logText = "fez uma assistência 🤝"; } - // ── SOFRIDAS ────────────────────────────────────────────────────────────── else if (action == "add_so") { stats["so"] = stats["so"]! + 1; logText = "sofreu uma falta 🤕"; } - // ══════════════════════════════════════════════════════════════════════════ // STEAL — ROUBO DE BOLA // ══════════════════════════════════════════════════════════════════════════ @@ -637,7 +902,6 @@ class PlacarController extends ChangeNotifier { stats["il"] = stats["il"]! + 1; logText = "intercetou um lançamento 🛑"; } - // ══════════════════════════════════════════════════════════════════════════ // BLOCK — DESARME // ══════════════════════════════════════════════════════════════════════════ @@ -648,7 +912,6 @@ class PlacarController extends ChangeNotifier { stats["li"] = stats["li"]! + 1; logText = "sofreu um desarme 🚫"; } - // Ações independentes legadas else if (action == "add_il") { stats["il"] = stats["il"]! + 1; @@ -657,7 +920,6 @@ class PlacarController extends ChangeNotifier { stats["li"] = stats["li"]! + 1; logText = "teve o lançamento intercetado 🚫"; } - // ══════════════════════════════════════════════════════════════════════════ // TURNOVER — PERDE DE BOLA E INFRAÇÕES // ══════════════════════════════════════════════════════════════════════════ @@ -666,21 +928,20 @@ class PlacarController extends ChangeNotifier { logText = "fez um passe ruim 🤦"; } else if (action == "tov_3s") { stats["tres_seg"] = stats["tres_seg"]! + 1; // SOMA AOS 3 SEGUNDOS - stats["tov"] = stats["tov"]! + 1; // SOMA AO TURNOVER GERAL + stats["tov"] = stats["tov"]! + 1; // SOMA AO TURNOVER GERAL logText = "violação de 3 segundos ⏱️"; } else if (action == "tov_clock") { stats["tov"] = stats["tov"]! + 1; logText = "violação de 24 segundos ⏱️"; } else if (action == "tov_travel") { - stats["pa"] = stats["pa"]! + 1; // SOMA AOS PASSOS - stats["tov"] = stats["tov"]! + 1; // SOMA AO TURNOVER GERAL + stats["pa"] = stats["pa"]! + 1; // SOMA AOS PASSOS + stats["tov"] = stats["tov"]! + 1; // SOMA AO TURNOVER GERAL logText = "cometeu passos 🚶"; } else if (action == "tov_double") { - stats["dr"] = stats["dr"]! + 1; // SOMA AOS DRIBLES DUPLOS - stats["tov"] = stats["tov"]! + 1; // SOMA AO TURNOVER GERAL + stats["dr"] = stats["dr"]! + 1; // SOMA AOS DRIBLES DUPLOS + stats["tov"] = stats["tov"]! + 1; // SOMA AO TURNOVER GERAL logText = "fez drible duplo 🏀"; } - // ── ANULAR FALTA ────────────────────────────────────────────────────────── else if (action == "sub_foul") { if (stats["fls"]! > 0) stats["fls"] = stats["fls"]! - 1; @@ -700,6 +961,10 @@ class PlacarController extends ChangeNotifier { _scheduleAutoSave(); notifyListeners(); + _dispatchSyncAction('commit_stat', { + 'action': action, + 'player_data': playerData, + }); } @override @@ -744,7 +1009,7 @@ class PlacarController extends ChangeNotifier { double mvpScore = ((pts * 0.30) + (tr * 0.20) + (ast * 0.35) + (br * 0.15)) - - ((bp * 0.35) + (lFalhados * 0.30) + (llFalhados * 0.35)); + ((bp * 0.35) + (lFalhados * 0.30) + (llFalhados * 0.35)); mvpScore = mvpScore * (minJogados / 40.0); String pName = playerNames[playerId] ?? '---'; @@ -768,20 +1033,23 @@ class PlacarController extends ChangeNotifier { }); // 1. Atualizar o Jogo - 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, - 'mvp_name': mvpName, - 'play_by_play': playByPlay, - }).eq('id', gameId); + 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, + 'mvp_name': mvpName, + 'play_by_play': playByPlay, + }) + .eq('id', gameId); // 2. Preparar as Estatísticas dos Jogadores List> batchStats = []; @@ -810,11 +1078,11 @@ class PlacarController extends ChangeNotifier { 'p2a': stats['p2a'], 'p3m': stats['p3m'], 'p3a': stats['p3a'], - 'so': stats['so'], + 'so': stats['so'], 'il': stats['il'], 'li': stats['li'], 'pa': stats['pa'], - 'tres_seg': stats['tres_seg'], + 'tres_seg': stats['tres_seg'], 'dr': stats['dr'], 'minutos_jogados': stats['sec'], }); @@ -855,20 +1123,26 @@ class PlacarController extends ChangeNotifier { await prefs.remove('backup_$gameId'); if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( content: Text('Guardado com Sucesso!'), - backgroundColor: Colors.green)); + backgroundColor: Colors.green, + ), + ); } } catch (e) { debugPrint("Erro ao gravar estatísticas: $e"); if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( content: Text('Erro ao guardar: $e'), - backgroundColor: Colors.red)); + backgroundColor: Colors.red, + ), + ); } } finally { isSaving = false; notifyListeners(); } } -} \ No newline at end of file +} diff --git a/lib/pages/PlacarPage.dart b/lib/pages/PlacarPage.dart index 03c838c..0a7f3df 100644 --- a/lib/pages/PlacarPage.dart +++ b/lib/pages/PlacarPage.dart @@ -1,778 +1,903 @@ -import 'dart:async'; -import 'dart:math' as math; + import 'dart:async'; + import 'dart:math' as math; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:playmaker/icons.dart/resaltosicon.dart'; -import 'package:playmaker/widgets/placar_widgets.dart'; // Mantém este import -import 'package:playmaker/widgets/share_game_dialog.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; + import 'package:flutter/material.dart'; + import 'package:flutter/services.dart'; + import 'package:playmaker/icons.dart/resaltosicon.dart'; + import 'package:playmaker/widgets/placar_widgets.dart'; // Mantém este import + import 'package:playmaker/widgets/share_game_dialog.dart'; + import 'package:supabase_flutter/supabase_flutter.dart'; -import '../classe/theme.dart'; -import '../controllers/game_sharing_controller.dart'; -import '../controllers/placar_controller.dart'; + import '../classe/theme.dart'; + import '../controllers/game_sharing_controller.dart'; + import '../controllers/placar_controller.dart'; -class PlacarPage extends StatefulWidget { - final String gameId, myTeam, opponentTeam; + class PlacarPage extends StatefulWidget { + final String gameId, myTeam, opponentTeam; - const PlacarPage({ - super.key, - required this.gameId, - required this.myTeam, - required this.opponentTeam, - }); + const PlacarPage({ + super.key, + required this.gameId, + required this.myTeam, + required this.opponentTeam, + }); - @override - State createState() => _PlacarPageState(); -} - -class _PlacarPageState extends State { - late PlacarController _controller; - final GameSharingController _sharingController = GameSharingController(); - String? _sessionId; - String? _shareCode; - String _sharedWithName = ''; - StreamSubscription? _syncSubscription; - bool _isApplyingRemoteSync = false; - - @override - void initState() { - super.initState(); - SystemChrome.setPreferredOrientations([ - DeviceOrientation.landscapeRight, - DeviceOrientation.landscapeLeft, - ]); - - _controller = PlacarController( - gameId: widget.gameId, - myTeam: widget.myTeam, - opponentTeam: widget.opponentTeam, - ); - _controller.loadPlayers().then((_) => _initializeShareForGame()); + @override + State createState() => _PlacarPageState(); } - @override - void dispose() { - _syncSubscription?.cancel(); - _controller.dispose(); - SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); - super.dispose(); - } + class _PlacarPageState extends State { + late PlacarController _controller; + final GameSharingController _sharingController = GameSharingController(); + String? _sessionId; + String? _shareCode; + String _sharedWithName = ''; + StreamSubscription? _syncSubscription; + bool _isApplyingRemoteSync = false; + final Set _appliedSyncEventIds = {}; - Widget _buildFloatingFoulBtn( - String label, - Color color, - String action, - IconData icon, - double left, - double right, - double top, - double sf, - ) { - return Positioned( - top: top, - left: left > 0 ? left : null, - right: right > 0 ? right : null, - child: Draggable( - data: action, - feedback: Material( - color: Colors.transparent, - child: CircleAvatar( - radius: 30 * sf, - backgroundColor: color.withOpacity(0.8), - child: Icon(icon, color: Colors.white, size: 30 * sf), + @override + void initState() { + super.initState(); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeRight, + DeviceOrientation.landscapeLeft, + ]); + + _controller = PlacarController( + gameId: widget.gameId, + myTeam: widget.myTeam, + opponentTeam: widget.opponentTeam, + onSyncAction: _onLocalControllerSync, + ); + _controller.loadPlayers().then((_) => _initializeShareForGame()); + } + + @override + void dispose() { + _syncSubscription?.cancel(); + _controller.dispose(); + SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + super.dispose(); + } + + Widget _buildFloatingFoulBtn( + String label, + Color color, + String action, + IconData icon, + double left, + double right, + double top, + double sf, + ) { + return Positioned( + top: top, + left: left > 0 ? left : null, + right: right > 0 ? right : null, + child: Draggable( + data: action, + feedback: Material( + color: Colors.transparent, + child: CircleAvatar( + radius: 30 * sf, + backgroundColor: color.withOpacity(0.8), + child: Icon(icon, color: Colors.white, size: 30 * sf), + ), + ), + child: Column( + children: [ + CircleAvatar( + radius: 27 * sf, + backgroundColor: color, + child: Icon(icon, color: Colors.white, size: 28 * sf), + ), + SizedBox(height: 5 * sf), + Text( + label, + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12 * sf, + ), + ), + ], ), ), - child: Column( - children: [ - CircleAvatar( - radius: 27 * sf, - backgroundColor: color, - child: Icon(icon, color: Colors.white, size: 28 * sf), - ), - SizedBox(height: 5 * sf), - Text( - label, - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 12 * sf, - ), - ), - ], - ), - ), - ); - } - - Widget _buildCornerBtn({ - required String heroTag, - required IconData icon, - required Color color, - required VoidCallback? onTap, - required double size, - bool isLoading = false, - }) { - return SizedBox( - width: size, - height: size, - child: FloatingActionButton( - heroTag: heroTag, - backgroundColor: onTap == null ? Colors.grey : color, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14 * (size / 50)), - ), - elevation: 5, - onPressed: isLoading ? null : onTap, - child: isLoading - ? SizedBox( - width: size * 0.45, - height: size * 0.45, - child: const CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2.5, - ), - ) - : Icon(icon, color: Colors.white, size: size * 0.55), - ), - ); - } - - void _showHeatmap(BuildContext context) { - showDialog( - context: context, - builder: (ctx) => HeatmapDialog( - shots: _controller.matchShots, - myTeamName: _controller.myTeam, - oppTeamName: _controller.opponentTeam, - myPlayersIds: [..._controller.myCourt, ..._controller.myBench], - oppPlayersIds: [..._controller.oppCourt, ..._controller.oppBench], - playerStats: _controller.playerStats, - playerNames: _controller.playerNames, - ), - ); - } - - Future _initializeShareForGame() async { - final activeSession = await _sharingController.getActiveSessionForGame( - widget.gameId, - ); - if (activeSession == null) return; - - _sessionId = activeSession['id']?.toString(); - _shareCode = activeSession['share_code']?.toString(); - final sharedWith = activeSession['shared_with_user_id']?.toString(); - - if (sharedWith != null && sharedWith.isNotEmpty) { - _sharedWithName = await _resolveUserName(sharedWith); + ); } - _setupSyncListener(); - setState(() {}); - } - - Future _resolveUserName(String userId) async { - try { - final profile = await Supabase.instance.client - .from('profiles') - .select('username, full_name') - .eq('id', userId) - .single(); - - return profile['full_name']?.toString() ?? - profile['username']?.toString() ?? - 'Parceiro'; - } catch (_) { - return 'Parceiro'; + Widget _buildCornerBtn({ + required String heroTag, + required IconData icon, + required Color color, + required VoidCallback? onTap, + required double size, + bool isLoading = false, + }) { + return SizedBox( + width: size, + height: size, + child: FloatingActionButton( + heroTag: heroTag, + backgroundColor: onTap == null ? Colors.grey : color, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14 * (size / 50)), + ), + elevation: 5, + onPressed: isLoading ? null : onTap, + child: isLoading + ? SizedBox( + width: size * 0.45, + height: size * 0.45, + child: const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2.5, + ), + ) + : Icon(icon, color: Colors.white, size: size * 0.55), + ), + ); } - } - void _setupSyncListener() { - if (_sessionId == null) return; - _syncSubscription?.cancel(); - _syncSubscription = _sharingController.listenToGameSync(_sessionId!).listen( - (event) { - if (event is List && event.isNotEmpty) { - final record = event.last as Map?; - if (record != null) { - _handleSyncRecords(record); - } - } - }, - ); - } + void _showHeatmap(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => HeatmapDialog( + shots: _controller.matchShots, + myTeamName: _controller.myTeam, + oppTeamName: _controller.opponentTeam, + myPlayersIds: [..._controller.myCourt, ..._controller.myBench], + oppPlayersIds: [..._controller.oppCourt, ..._controller.oppBench], + playerStats: _controller.playerStats, + playerNames: _controller.playerNames, + ), + ); + } - void _handleSyncRecords(Map record) { - final triggeredBy = record['triggered_by']?.toString(); - final currentUserId = Supabase.instance.client.auth.currentUser?.id; - if (triggeredBy == null || triggeredBy == currentUserId) return; + Future _initializeShareForGame() async { + final activeSession = await _sharingController.getActiveSessionForGame( + widget.gameId, + ); + if (activeSession == null) return; - _applyRemoteSyncEvent(record); - } + _sessionId = activeSession['id']?.toString(); + _shareCode = activeSession['share_code']?.toString(); + final sharedWith = activeSession['shared_with_user_id']?.toString(); - void _applyRemoteSyncEvent(Map record) { - final actionType = record['action_type']?.toString(); - final actionData = Map.from(record['action_data'] ?? {}); - if (actionType == 'toggle_timer') { - final paused = actionData['paused'] == true; - final remainingSeconds = - int.tryParse(actionData['remaining_seconds']?.toString() ?? '') ?? - _controller.durationNotifier.value.inSeconds; - _controller.durationNotifier.value = Duration(seconds: remainingSeconds); + if (sharedWith != null && sharedWith.isNotEmpty) { + _sharedWithName = await _resolveUserName(sharedWith); + } - if (paused && _controller.isRunning) { + _setupSyncListener(); + setState(() {}); + } + + Future _resolveUserName(String userId) async { + try { + final profile = await Supabase.instance.client + .from('profiles') + .select('username, full_name') + .eq('id', userId) + .single(); + + return profile['full_name']?.toString() ?? + profile['username']?.toString() ?? + 'Parceiro'; + } catch (_) { + return 'Parceiro'; + } + } + + void _setupSyncListener() { + if (_sessionId == null) return; + _syncSubscription?.cancel(); + _appliedSyncEventIds.clear(); + _syncSubscription = _sharingController + .listenToGameSyncOthers(_sessionId!) + .listen( + (dynamic event) { + final rows = >[]; + + if (event is List && event.isNotEmpty) { + for (final item in event) { + final row = item as Map?; + if (row == null) continue; + + final rowId = row['id']?.toString(); + if (rowId == null || _appliedSyncEventIds.contains(rowId)) { + continue; + } + + rows.add(Map.from(row)); + } + } else if (event is Map) { + final row = Map.from(event); + final rowId = row['id']?.toString(); + if (rowId != null && !_appliedSyncEventIds.contains(rowId)) { + rows.add(row); + } + } + + if (rows.isEmpty) return; + + rows.sort((a, b) { + final aTime = a['created_at']?.toString(); + final bTime = b['created_at']?.toString(); + if (aTime == null || bTime == null) return 0; + try { + return DateTime.parse(aTime).compareTo(DateTime.parse(bTime)); + } catch (_) { + return 0; + } + }); + + for (final record in rows) { + final recordId = record['id']?.toString(); + if (recordId == null || _appliedSyncEventIds.contains(recordId)) { + continue; + } + + _appliedSyncEventIds.add(recordId); + print( + "🔄 Evento remoto recebido: ${record['action_type']} - ${record['action_data']}", + ); + _applyRemoteSyncEvent(record); + } + }, + onError: (error) { + print("⚠️ Erro no stream de sync: $error"); + }, + ); + } + + void _handleSyncRecords(Map record) { + // Mantido apenas como fallback, mas a escuta principal usa listenToGameSyncOthers. + _applyRemoteSyncEvent(record); + } + + void _onLocalControllerSync( + String actionType, + Map actionData, + ) { + if (_sessionId == null || _isApplyingRemoteSync) return; + print("📤 Enviando sync action local: $actionType -> $actionData (is_running: ${actionData['is_running']})"); + _sharingController.sendSyncEvent(_sessionId!, actionType, actionData); + } + + void _applyRemoteSyncEvent(Map record) { + final actionType = record['action_type']?.toString(); + final actionData = Map.from(record['action_data'] ?? {}); + + // Aplicar estado remoto do timer em TODAS as ações + final remoteSeconds = int.tryParse( + actionData['remaining_seconds']?.toString() ?? '', + ); + final remoteIsRunning = actionData['is_running'] == true; + + if (remoteSeconds != null) { + _controller.durationNotifier.value = Duration(seconds: remoteSeconds); + } + + final remoteQuarter = int.tryParse( + actionData['current_quarter']?.toString() ?? '', + ); + if (remoteQuarter != null) { + _controller.currentQuarter = remoteQuarter; + } + + final remoteMyFouls = int.tryParse( + actionData['my_fouls']?.toString() ?? '', + ); + if (remoteMyFouls != null) { + _controller.myFouls = remoteMyFouls; + } + + final remoteOpponentFouls = int.tryParse( + actionData['opponent_fouls']?.toString() ?? '', + ); + if (remoteOpponentFouls != null) { + _controller.opponentFouls = remoteOpponentFouls; + } + + final remoteMyTimeoutsUsed = int.tryParse( + actionData['my_timeouts_used']?.toString() ?? '', + ); + if (remoteMyTimeoutsUsed != null) { + _controller.myTimeoutsUsed = remoteMyTimeoutsUsed; + } + + final remoteOpponentTimeoutsUsed = int.tryParse( + actionData['opponent_timeouts_used']?.toString() ?? '', + ); + if (remoteOpponentTimeoutsUsed != null) { + _controller.opponentTimeoutsUsed = remoteOpponentTimeoutsUsed; + } + + if (remoteIsRunning != _controller.isRunning) { _isApplyingRemoteSync = true; - _controller.toggleTimer(context); - _isApplyingRemoteSync = false; - } else if (!paused && !_controller.isRunning) { - _isApplyingRemoteSync = true; - _controller.toggleTimer(context); + _controller.applyRemoteTimerState(remoteIsRunning); + _controller.notifyListeners(); + _isApplyingRemoteSync = false; + } + + if (actionType == 'toggle_timer') { + setState(() {}); + } else if (actionType == 'commit_stat') { + final action = actionData['action']?.toString() ?? ''; + final playerData = actionData['player_data']?.toString() ?? ''; + if (action.isNotEmpty && playerData.isNotEmpty) { + _isApplyingRemoteSync = true; + _controller.commitStat(action, playerData); + _isApplyingRemoteSync = false; + } + } else if (actionType == 'register_foul') { + final committer = actionData['committer']?.toString() ?? ''; + final foulType = actionData['foulType']?.toString() ?? ''; + final victim = actionData['victim']?.toString() ?? ''; + if (committer.isNotEmpty && foulType.isNotEmpty) { + _isApplyingRemoteSync = true; + _controller.registerFoul(committer, foulType, victim); + _isApplyingRemoteSync = false; + } + } else if (actionType == 'subbing') { + final action = actionData['action']?.toString() ?? ''; + final courtPlayer = actionData['court_player']?.toString() ?? ''; + final isOpponent = actionData['is_opponent'] == true; + if (action.isNotEmpty && courtPlayer.isNotEmpty) { + _isApplyingRemoteSync = true; + _controller.handleSubbing(context, action, courtPlayer, isOpponent); + _isApplyingRemoteSync = false; + } + } else if (actionType == 'swap_players') { + final dragged = actionData['dragged']?.toString() ?? ''; + final target = actionData['target']?.toString() ?? ''; + if (dragged.isNotEmpty && target.isNotEmpty) { + _isApplyingRemoteSync = true; + _controller.swapCourtPlayers(dragged, target); + _isApplyingRemoteSync = false; + } + } else if (actionType == 'use_timeout') { + final isOpponent = actionData['is_opponent'] == true; + _isApplyingRemoteSync = true; + _controller.useTimeout(isOpponent); _isApplyingRemoteSync = false; } - setState(() {}); } - } - void _handleTimerButton(BuildContext context) { - _controller.toggleTimer(context); - - if (_sessionId != null && !_isApplyingRemoteSync) { - _sharingController.sendSyncEvent(_sessionId!, 'toggle_timer', { - 'paused': !_controller.isRunning, - 'remaining_seconds': _controller.durationNotifier.value.inSeconds, - }); + void _handleTimerButton(BuildContext context) { + _controller.toggleTimer(context); } - } - Future _openShareDialog(BuildContext context) async { - final result = await showDialog>( - context: context, - builder: (ctx) => ShareGameDialog( - gameId: widget.gameId, - controller: _sharingController, - activeSessionId: _sessionId, - activeShareCode: _shareCode, - ), - ); - - if (result != null) { - _sessionId = result['session_id']?.toString(); - _shareCode = result['share_code']?.toString(); - _setupSyncListener(); - setState(() {}); - } - } - - Future _openJoinDialog(BuildContext context) async { - final result = await showDialog>( - context: context, - builder: (ctx) => JoinGameDialog(controller: _sharingController), - ); - - if (result != null) { - _sessionId = result['session_id']?.toString(); - _shareCode = result['share_code']?.toString(); - _sharedWithName = result['creator_name']?.toString() ?? ''; - _setupSyncListener(); - setState(() {}); - } - } - - Widget _buildShareStatus(double sf) { - if (_sessionId == null) return const SizedBox.shrink(); - - final text = _sharedWithName.isNotEmpty - ? 'Partilhado com $_sharedWithName' - : 'Sessão partilhada: $_shareCode'; - - return Container( - padding: EdgeInsets.symmetric(horizontal: 14 * sf, vertical: 8 * sf), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.55), - borderRadius: BorderRadius.circular(14 * sf), - border: Border.all(color: Colors.white24), - ), - child: Text( - text, - style: TextStyle( - color: Colors.white, - fontSize: 13 * sf, - fontWeight: FontWeight.bold, + Future _openShareDialog(BuildContext context) async { + final result = await showDialog>( + context: context, + builder: (ctx) => ShareGameDialog( + gameId: widget.gameId, + controller: _sharingController, + activeSessionId: _sessionId, + activeShareCode: _shareCode, ), - ), - ); - } + ); - @override - Widget build(BuildContext context) { - final double wScreen = MediaQuery.of(context).size.width; - final double hScreen = MediaQuery.of(context).size.height; - final double sf = math.min(wScreen / 1150, hScreen / 720); - final double cornerBtnSize = 48 * sf; + if (result != null) { + _sessionId = result['session_id']?.toString(); + _shareCode = result['share_code']?.toString(); + _setupSyncListener(); + setState(() {}); + } + } - return AnimatedBuilder( - animation: _controller, - builder: (context, child) { - if (_controller.isLoading) { - return Scaffold( - backgroundColor: AppTheme.placarDarkSurface, - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - "PREPARANDO O PAVILHÃO", - style: TextStyle( - color: Colors.white24, - fontSize: 45 * sf, - fontWeight: FontWeight.bold, - letterSpacing: 2, + Future _openJoinDialog(BuildContext context) async { + final result = await showDialog>( + context: context, + builder: (ctx) => JoinGameDialog(controller: _sharingController), + ); + + if (result != null) { + _sessionId = result['session_id']?.toString(); + _shareCode = result['share_code']?.toString(); + _sharedWithName = result['creator_name']?.toString() ?? ''; + _setupSyncListener(); + setState(() {}); + } + } + + Widget _buildShareStatus(double sf) { + if (_sessionId == null) return const SizedBox.shrink(); + + final text = _sharedWithName.isNotEmpty + ? 'Partilhado com $_sharedWithName' + : 'Sessão partilhada: $_shareCode'; + + return Container( + padding: EdgeInsets.symmetric(horizontal: 14 * sf, vertical: 8 * sf), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.55), + borderRadius: BorderRadius.circular(14 * sf), + border: Border.all(color: Colors.white24), + ), + child: Text( + text, + style: TextStyle( + color: Colors.white, + fontSize: 13 * sf, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final double wScreen = MediaQuery.of(context).size.width; + final double hScreen = MediaQuery.of(context).size.height; + final double sf = math.min(wScreen / 1150, hScreen / 720); + final double cornerBtnSize = 48 * sf; + + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + if (_controller.isLoading) { + return Scaffold( + backgroundColor: AppTheme.placarDarkSurface, + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "PREPARANDO O PAVILHÃO", + style: TextStyle( + color: Colors.white24, + fontSize: 45 * sf, + fontWeight: FontWeight.bold, + letterSpacing: 2, + ), ), - ), - SizedBox(height: 35 * sf), - const CircularProgressIndicator(color: Colors.orangeAccent), - ], + SizedBox(height: 35 * sf), + const CircularProgressIndicator(color: Colors.orangeAccent), + ], + ), + ), + ); + } + + return Scaffold( + backgroundColor: AppTheme.placarBackground, + body: SafeArea( + top: false, + bottom: false, + child: IgnorePointer( + ignoring: _controller.isSaving, + child: Stack( + children: [ + Container( + margin: EdgeInsets.only( + left: 65 * sf, + right: 65 * sf, + bottom: 55 * sf, + ), + decoration: BoxDecoration( + border: Border.all(color: Colors.white, width: 2.5), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final w = constraints.maxWidth; + final h = constraints.maxHeight; + return Stack( + children: [ + GestureDetector( + onTapDown: (details) { + if (_controller.isSelectingShotLocation) { + bool isMake = + _controller.pendingAction?.startsWith( + "add_pts_", + ) ?? + false; + String? pData = _controller.pendingPlayerId; + + _controller.registerShotLocation( + context, + details.localPosition, + Size(w, h), + ); + + if (isMake && pData != null) { + bool isOpp = pData.startsWith( + "player_opp_", + ); + String pId = pData + .replaceAll("player_my_", "") + .replaceAll("player_opp_", ""); + showAssistDialog( + context, + _controller, + isOpp, + pId, + sf, + ); + } + } + }, + child: Container( + decoration: const BoxDecoration( + image: DecorationImage( + image: AssetImage('assets/campone.png'), + fit: BoxFit.fill, + ), + ), + ), + ), + if (!_controller.isSelectingShotLocation && + _controller.myCourt.length >= 5 && + _controller.oppCourt.length >= 5) ...[ + Positioned( + top: h * 0.25, + left: w * 0.02, + child: PlayerCourtCard( + controller: _controller, + playerId: _controller.myCourt[0], + isOpponent: false, + sf: sf, + ), + ), + Positioned( + top: h * 0.68, + left: w * 0.02, + child: PlayerCourtCard( + controller: _controller, + playerId: _controller.myCourt[1], + isOpponent: false, + sf: sf, + ), + ), + Positioned( + top: h * 0.45, + left: w * 0.25, + child: PlayerCourtCard( + controller: _controller, + playerId: _controller.myCourt[2], + isOpponent: false, + sf: sf, + ), + ), + Positioned( + top: h * 0.15, + left: w * 0.20, + child: PlayerCourtCard( + controller: _controller, + playerId: _controller.myCourt[3], + isOpponent: false, + sf: sf, + ), + ), + Positioned( + top: h * 0.80, + left: w * 0.20, + child: PlayerCourtCard( + controller: _controller, + playerId: _controller.myCourt[4], + isOpponent: false, + sf: sf, + ), + ), + + Positioned( + top: h * 0.25, + right: w * 0.02, + child: PlayerCourtCard( + controller: _controller, + playerId: _controller.oppCourt[0], + isOpponent: true, + sf: sf, + ), + ), + Positioned( + top: h * 0.68, + right: w * 0.02, + child: PlayerCourtCard( + controller: _controller, + playerId: _controller.oppCourt[1], + isOpponent: true, + sf: sf, + ), + ), + Positioned( + top: h * 0.45, + right: w * 0.25, + child: PlayerCourtCard( + controller: _controller, + playerId: _controller.oppCourt[2], + isOpponent: true, + sf: sf, + ), + ), + Positioned( + top: h * 0.15, + right: w * 0.20, + child: PlayerCourtCard( + controller: _controller, + playerId: _controller.oppCourt[3], + isOpponent: true, + sf: sf, + ), + ), + Positioned( + top: h * 0.80, + right: w * 0.20, + child: PlayerCourtCard( + controller: _controller, + playerId: _controller.oppCourt[4], + isOpponent: true, + sf: sf, + ), + ), + ], + if (!_controller.isSelectingShotLocation) ...[ + _buildFloatingFoulBtn( + "FALTA +", + AppTheme.actionPoints, + "add_foul", + Icons.sports, + w * 0.39, + 0.0, + h * 0.31, + sf, + ), + _buildFloatingFoulBtn( + "FALTA -", + AppTheme.actionMiss, + "sub_foul", + Icons.block, + 0.0, + w * 0.39, + h * 0.31, + sf, + ), + ], + if (!_controller.isSelectingShotLocation) + Positioned( + top: (h * 0.32) + (40 * sf), + left: 0, + right: 0, + child: Center( + child: GestureDetector( + onTap: () => _handleTimerButton(context), + child: CircleAvatar( + radius: 68 * sf, + backgroundColor: Colors.grey.withOpacity( + 0.5, + ), + child: Icon( + _controller.isRunning + ? Icons.pause + : Icons.play_arrow, + color: Colors.white, + size: 58 * sf, + ), + ), + ), + ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: Center( + child: TopScoreboard( + controller: _controller, + sf: sf, + ), + ), + ), + if (_sessionId != null) + Positioned( + top: 90 * sf, + left: 0, + right: 0, + child: Center(child: _buildShareStatus(sf)), + ), + + if (!_controller.isSelectingShotLocation) + Positioned( + bottom: -10 * sf, + left: 0, + right: 0, + child: ActionButtonsPanel( + controller: _controller, + sf: sf, + ), + ), + if (_controller.isSelectingShotLocation) + Positioned( + top: h * 0.4, + left: 0, + right: 0, + child: Center( + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 35 * sf, + vertical: 18 * sf, + ), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular( + 11 * sf, + ), + border: Border.all( + color: Colors.white, + width: 1.5 * sf, + ), + ), + child: Text( + "TOQUE NO CAMPO PARA MARCAR O LOCAL", + style: TextStyle( + color: Colors.white, + fontSize: 22 * sf, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ], + ); + }, + ), + ), + + Positioned( + top: 50 * sf, + left: 12 * sf, + child: Column( + children: [ + _buildCornerBtn( + heroTag: 'btn_save_exit', + icon: Icons.save_alt, + color: AppTheme.oppTeamRed, + size: cornerBtnSize, + isLoading: _controller.isSaving, + onTap: () async { + await _controller.saveGameStats(context); + if (context.mounted) Navigator.pop(context); + }, + ), + SizedBox(height: 10 * sf), + _buildCornerBtn( + heroTag: 'btn_history', + icon: Icons.history, + color: Colors.blueGrey, + size: cornerBtnSize, + onTap: () => showDialog( + context: context, + builder: (ctx) => + PlayByPlayDialog(controller: _controller), + ), + ), + ], + ), + ), + + Positioned( + top: 50 * sf, + right: 12 * sf, + child: Column( + children: [ + _buildCornerBtn( + heroTag: 'btn_heatmap', + icon: Icons.local_fire_department, + color: Colors.orange.shade800, + size: cornerBtnSize, + onTap: () => _showHeatmap(context), + ), + SizedBox(height: 10 * sf), + _buildCornerBtn( + heroTag: 'btn_boxscore', + icon: Icons.table_chart, + color: Colors.indigo, + size: cornerBtnSize, + onTap: () => showDialog( + context: context, + builder: (ctx) => + BoxScoreDialog(controller: _controller, sf: sf), + ), + ), + SizedBox(height: 10 * sf), + _buildCornerBtn( + heroTag: 'btn_share', + icon: Icons.share, + color: Colors.green, + size: cornerBtnSize, + onTap: () => _openShareDialog(context), + ), + ], + ), + ), + + // BOTÕES INFERIORES: SUBSTITUIÇÕES E TIMEOUTS + Positioned( + bottom: 55 * sf, + left: 12 * sf, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildCornerBtn( + heroTag: 'btn_sub_home', + icon: Icons.swap_horiz, + color: AppTheme.myTeamBlue, + size: cornerBtnSize, + onTap: () => showDialog( + context: context, + builder: (ctx) => SubstitutionDialog( + controller: _controller, + isOpponent: false, + sf: sf, + ), + ), + ), + SizedBox(height: 12 * sf), + _buildCornerBtn( + heroTag: 'btn_to_home', + icon: Icons.timer, + color: AppTheme.myTeamBlue, + size: cornerBtnSize, + onTap: _controller.myTimeoutsUsed >= 3 + ? null + : () => _controller.useTimeout(false), + ), + ], + ), + ), + + Positioned( + bottom: 55 * sf, + right: 12 * sf, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildCornerBtn( + heroTag: 'btn_sub_away', + icon: Icons.swap_horiz, + color: AppTheme.oppTeamRed, + size: cornerBtnSize, + onTap: () => showDialog( + context: context, + builder: (ctx) => SubstitutionDialog( + controller: _controller, + isOpponent: true, + sf: sf, + ), + ), + ), + SizedBox(height: 12 * sf), + _buildCornerBtn( + heroTag: 'btn_to_away', + icon: Icons.timer, + color: AppTheme.oppTeamRed, + size: cornerBtnSize, + onTap: _controller.opponentTimeoutsUsed >= 3 + ? null + : () => _controller.useTimeout(true), + ), + ], + ), + ), + + if (_controller.isSaving) + Positioned.fill( + child: Container( + color: Colors.black.withOpacity(0.4), + child: const Center( + child: CircularProgressIndicator(color: Colors.white), + ), + ), + ), + ], + ), ), ), ); - } - - return Scaffold( - backgroundColor: AppTheme.placarBackground, - body: SafeArea( - top: false, - bottom: false, - child: IgnorePointer( - ignoring: _controller.isSaving, - child: Stack( - children: [ - Container( - margin: EdgeInsets.only( - left: 65 * sf, - right: 65 * sf, - bottom: 55 * sf, - ), - decoration: BoxDecoration( - border: Border.all(color: Colors.white, width: 2.5), - ), - child: LayoutBuilder( - builder: (context, constraints) { - final w = constraints.maxWidth; - final h = constraints.maxHeight; - return Stack( - children: [ - GestureDetector( - onTapDown: (details) { - if (_controller.isSelectingShotLocation) { - bool isMake = - _controller.pendingAction?.startsWith( - "add_pts_", - ) ?? - false; - String? pData = _controller.pendingPlayerId; - - _controller.registerShotLocation( - context, - details.localPosition, - Size(w, h), - ); - - if (isMake && pData != null) { - bool isOpp = pData.startsWith( - "player_opp_", - ); - String pId = pData - .replaceAll("player_my_", "") - .replaceAll("player_opp_", ""); - showAssistDialog( - context, - _controller, - isOpp, - pId, - sf, - ); - } - } - }, - child: Container( - decoration: const BoxDecoration( - image: DecorationImage( - image: AssetImage('assets/campo.png'), - fit: BoxFit.fill, - ), - ), - ), - ), - if (!_controller.isSelectingShotLocation && - _controller.myCourt.length >= 5 && - _controller.oppCourt.length >= 5) ...[ - Positioned( - top: h * 0.25, - left: w * 0.02, - child: PlayerCourtCard( - controller: _controller, - playerId: _controller.myCourt[0], - isOpponent: false, - sf: sf, - ), - ), - Positioned( - top: h * 0.68, - left: w * 0.02, - child: PlayerCourtCard( - controller: _controller, - playerId: _controller.myCourt[1], - isOpponent: false, - sf: sf, - ), - ), - Positioned( - top: h * 0.45, - left: w * 0.25, - child: PlayerCourtCard( - controller: _controller, - playerId: _controller.myCourt[2], - isOpponent: false, - sf: sf, - ), - ), - Positioned( - top: h * 0.15, - left: w * 0.20, - child: PlayerCourtCard( - controller: _controller, - playerId: _controller.myCourt[3], - isOpponent: false, - sf: sf, - ), - ), - Positioned( - top: h * 0.80, - left: w * 0.20, - child: PlayerCourtCard( - controller: _controller, - playerId: _controller.myCourt[4], - isOpponent: false, - sf: sf, - ), - ), - - Positioned( - top: h * 0.25, - right: w * 0.02, - child: PlayerCourtCard( - controller: _controller, - playerId: _controller.oppCourt[0], - isOpponent: true, - sf: sf, - ), - ), - Positioned( - top: h * 0.68, - right: w * 0.02, - child: PlayerCourtCard( - controller: _controller, - playerId: _controller.oppCourt[1], - isOpponent: true, - sf: sf, - ), - ), - Positioned( - top: h * 0.45, - right: w * 0.25, - child: PlayerCourtCard( - controller: _controller, - playerId: _controller.oppCourt[2], - isOpponent: true, - sf: sf, - ), - ), - Positioned( - top: h * 0.15, - right: w * 0.20, - child: PlayerCourtCard( - controller: _controller, - playerId: _controller.oppCourt[3], - isOpponent: true, - sf: sf, - ), - ), - Positioned( - top: h * 0.80, - right: w * 0.20, - child: PlayerCourtCard( - controller: _controller, - playerId: _controller.oppCourt[4], - isOpponent: true, - sf: sf, - ), - ), - ], - if (!_controller.isSelectingShotLocation) ...[ - _buildFloatingFoulBtn( - "FALTA +", - AppTheme.actionPoints, - "add_foul", - Icons.sports, - w * 0.39, - 0.0, - h * 0.31, - sf, - ), - _buildFloatingFoulBtn( - "FALTA -", - AppTheme.actionMiss, - "sub_foul", - Icons.block, - 0.0, - w * 0.39, - h * 0.31, - sf, - ), - ], - if (!_controller.isSelectingShotLocation) - Positioned( - top: (h * 0.32) + (40 * sf), - left: 0, - right: 0, - child: Center( - child: GestureDetector( - onTap: () => _handleTimerButton(context), - child: CircleAvatar( - radius: 68 * sf, - backgroundColor: Colors.grey.withOpacity( - 0.5, - ), - child: Icon( - _controller.isRunning - ? Icons.pause - : Icons.play_arrow, - color: Colors.white, - size: 58 * sf, - ), - ), - ), - ), - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: Center( - child: TopScoreboard( - controller: _controller, - sf: sf, - ), - ), - ), - if (_sessionId != null) - Positioned( - top: 90 * sf, - left: 0, - right: 0, - child: Center(child: _buildShareStatus(sf)), - ), - - if (!_controller.isSelectingShotLocation) - Positioned( - bottom: -10 * sf, - left: 0, - right: 0, - child: ActionButtonsPanel( - controller: _controller, - sf: sf, - ), - ), - if (_controller.isSelectingShotLocation) - Positioned( - top: h * 0.4, - left: 0, - right: 0, - child: Center( - child: Container( - padding: EdgeInsets.symmetric( - horizontal: 35 * sf, - vertical: 18 * sf, - ), - decoration: BoxDecoration( - color: Colors.black87, - borderRadius: BorderRadius.circular( - 11 * sf, - ), - border: Border.all( - color: Colors.white, - width: 1.5 * sf, - ), - ), - child: Text( - "TOQUE NO CAMPO PARA MARCAR O LOCAL", - style: TextStyle( - color: Colors.white, - fontSize: 22 * sf, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ), - ], - ); - }, - ), - ), - - Positioned( - top: 50 * sf, - left: 12 * sf, - child: Column( - children: [ - _buildCornerBtn( - heroTag: 'btn_save_exit', - icon: Icons.save_alt, - color: AppTheme.oppTeamRed, - size: cornerBtnSize, - isLoading: _controller.isSaving, - onTap: () async { - await _controller.saveGameStats(context); - if (context.mounted) Navigator.pop(context); - }, - ), - SizedBox(height: 10 * sf), - _buildCornerBtn( - heroTag: 'btn_history', - icon: Icons.history, - color: Colors.blueGrey, - size: cornerBtnSize, - onTap: () => showDialog( - context: context, - builder: (ctx) => - PlayByPlayDialog(controller: _controller), - ), - ), - ], - ), - ), - - Positioned( - top: 50 * sf, - right: 12 * sf, - child: Column( - children: [ - _buildCornerBtn( - heroTag: 'btn_heatmap', - icon: Icons.local_fire_department, - color: Colors.orange.shade800, - size: cornerBtnSize, - onTap: () => _showHeatmap(context), - ), - SizedBox(height: 10 * sf), - _buildCornerBtn( - heroTag: 'btn_boxscore', - icon: Icons.table_chart, - color: Colors.indigo, - size: cornerBtnSize, - onTap: () => showDialog( - context: context, - builder: (ctx) => - BoxScoreDialog(controller: _controller, sf: sf), - ), - ), - SizedBox(height: 10 * sf), - _buildCornerBtn( - heroTag: 'btn_share', - icon: Icons.share, - color: Colors.green, - size: cornerBtnSize, - onTap: () => _openShareDialog(context), - ), - ], - ), - ), - - // BOTÕES INFERIORES: SUBSTITUIÇÕES E TIMEOUTS - Positioned( - bottom: 55 * sf, - left: 12 * sf, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildCornerBtn( - heroTag: 'btn_sub_home', - icon: Icons.swap_horiz, - color: AppTheme.myTeamBlue, - size: cornerBtnSize, - onTap: () => showDialog( - context: context, - builder: (ctx) => SubstitutionDialog( - controller: _controller, - isOpponent: false, - sf: sf, - ), - ), - ), - SizedBox(height: 12 * sf), - _buildCornerBtn( - heroTag: 'btn_to_home', - icon: Icons.timer, - color: AppTheme.myTeamBlue, - size: cornerBtnSize, - onTap: _controller.myTimeoutsUsed >= 3 - ? null - : () => _controller.useTimeout(false), - ), - ], - ), - ), - - Positioned( - bottom: 55 * sf, - right: 12 * sf, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildCornerBtn( - heroTag: 'btn_sub_away', - icon: Icons.swap_horiz, - color: AppTheme.oppTeamRed, - size: cornerBtnSize, - onTap: () => showDialog( - context: context, - builder: (ctx) => SubstitutionDialog( - controller: _controller, - isOpponent: true, - sf: sf, - ), - ), - ), - SizedBox(height: 12 * sf), - _buildCornerBtn( - heroTag: 'btn_to_away', - icon: Icons.timer, - color: AppTheme.oppTeamRed, - size: cornerBtnSize, - onTap: _controller.opponentTimeoutsUsed >= 3 - ? null - : () => _controller.useTimeout(true), - ), - ], - ), - ), - - if (_controller.isSaving) - Positioned.fill( - child: Container( - color: Colors.black.withOpacity(0.4), - child: const Center( - child: CircularProgressIndicator(color: Colors.white), - ), - ), - ), - ], - ), - ), - ), - ); - }, - ); + }, + ); + } } -} diff --git a/pubspec.yaml b/pubspec.yaml index cc30f3c..a140ae1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -71,6 +71,9 @@ flutter: - assets/assit.png - assets/tov.png - assets/stl.png + - assets/campone.png + + fonts: - family: playmaker fonts: diff --git a/test/widget_test.dart b/test/widget_test.dart index 80d5fdb..dc39e6f 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,9 +1,3 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart';