This commit is contained in:
2026-05-15 12:43:30 +01:00
parent 1e38c4ad57
commit 332361c296
7 changed files with 1545 additions and 967 deletions

157
SYNC_CHANGES_SUMMARY.md Normal file
View File

@@ -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<String, dynamic> 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<String, dynamic> actionData) {
if (onSyncAction != null) {
final enrichedActionData = Map<String, dynamic>.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<String, dynamic> 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<String, dynamic>? record;
if (event is List && event.isNotEmpty) {
for (final item in event) {
final row = item as Map<String, dynamic>?;
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<String, dynamic>) {
record = Map<String, dynamic>.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<String, dynamic> record) {
final actionType = record['action_type']?.toString();
final actionData = Map<String, dynamic>.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

BIN
assets/campone.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 MiB

View File

@@ -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<bool> sendSyncEvent(
String sessionId,
String actionType,
Map<String, dynamic> actionData,
) async {
Map<String, dynamic> 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<Map<String, dynamic>>) por cada atualização.
Stream<List<Map<String, dynamic>>> listenToGameSyncOthers(String sessionId) {
return listenToGameSync(sessionId).map((data) {
List<Map<String, dynamic>> rows = [];
try {
if (data is List) {
rows = List<Map<String, dynamic>>.from(data);
} else if (data is Map) {
rows = [Map<String, dynamic>.from(data)];
}
} catch (_) {
return <Map<String, dynamic>>[];
}
return rows.where((r) => (r['triggered_by'] as String?) != myUserId).toList();
});
}
// ====================================
// 6⃣ OBTER ÚLTIMOS EVENTOS
// ====================================

View File

@@ -25,13 +25,23 @@ class ShotRecord {
});
Map<String, dynamic> 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<String, dynamic> 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,13 +49,96 @@ class PlacarController extends ChangeNotifier {
final String gameId;
final String myTeam;
final String opponentTeam;
final void Function(String actionType, Map<String, dynamic> actionData)?
onSyncAction;
PlacarController({
required this.gameId,
required this.myTeam,
required this.opponentTeam,
this.onSyncAction,
});
void _dispatchSyncAction(String actionType, Map<String, dynamic> actionData) {
if (onSyncAction != null) {
final enrichedActionData = Map<String, dynamic>.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<String> 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;
@@ -80,7 +173,9 @@ class PlacarController extends ChangeNotifier {
List<String> playByPlay = [];
ValueNotifier<Duration> durationNotifier = ValueNotifier(const Duration(minutes: 10));
ValueNotifier<Duration> durationNotifier = ValueNotifier(
const Duration(minutes: 10),
);
Timer? timer;
bool isRunning = false;
@@ -96,21 +191,41 @@ class PlacarController extends ChangeNotifier {
try {
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;
opponentScore =
int.tryParse(gameResponse['opponent_score']?.toString() ?? '0') ?? 0;
int totalSeconds = int.tryParse(gameResponse['remaining_seconds']?.toString() ?? '600') ?? 600;
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;
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';
@@ -120,25 +235,49 @@ 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<dynamic> myPlayers = myTeamDbId != null ? await supabase.from('members').select().eq('team_id', myTeamDbId!).eq('type', 'Jogador') : [];
List<dynamic> oppPlayers = oppTeamDbId != null ? await supabase.from('members').select().eq('team_id', oppTeamDbId!).eq('type', 'Jogador') : [];
List<dynamic> myPlayers = myTeamDbId != null
? await supabase
.from('members')
.select()
.eq('team_id', myTeamDbId!)
.eq('type', 'Jogador')
: [];
List<dynamic> 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<String, dynamic> 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];
@@ -152,7 +291,13 @@ class PlacarController extends ChangeNotifier {
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,17 +307,24 @@ 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(
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,
));
points: shotData['points'] != null
? int.parse(shotData['points'].toString())
: null,
),
);
}
await _loadLocalBackup();
@@ -188,42 +340,103 @@ class PlacarController extends ChangeNotifier {
void _loadSavedPlayerStats(String dbId, Map<String, dynamic> 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<String> court, List<String> bench, String prefix, {required bool isMyTeam}) {
void _padTeam(
List<String> court,
List<String> 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,12 +451,19 @@ 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,
};
@@ -261,16 +481,24 @@ class PlacarController extends ChangeNotifier {
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'];
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<String>.from(data['myCourt']); myBench = List<String>.from(data['myBench']);
oppCourt = List<String>.from(data['oppCourt']); oppBench = List<String>.from(data['oppBench']);
myCourt = List<String>.from(data['myCourt']);
myBench = List<String>.from(data['myBench']);
oppCourt = List<String>.from(data['oppCourt']);
oppBench = List<String>.from(data['oppBench']);
Map<String, dynamic> decodedStats = data['playerStats'];
playerStats = decodedStats.map((k, v) => MapEntry(k, Map<String, int>.from(v)));
playerStats = decodedStats.map(
(k, v) => MapEntry(k, Map<String, int>.from(v)),
);
List<dynamic> decodedShots = data['matchShots'];
matchShots = decodedShots.map((s) => ShotRecord.fromJson(s)).toList();
@@ -283,43 +511,8 @@ 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<String> 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) {
@@ -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,7 +562,12 @@ 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;
@@ -375,12 +588,18 @@ 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_", "");
@@ -393,7 +612,8 @@ class PlacarController extends ChangeNotifier {
}
}
// 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_", "");
@@ -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);
@@ -442,13 +683,25 @@ class PlacarController extends ChangeNotifier {
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;
isSelectingShotLocation = false;
pendingAction = null;
pendingPlayerId = null;
_scheduleAutoSave();
notifyListeners();
}
@@ -481,17 +734,25 @@ class PlacarController extends ChangeNotifier {
}
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;
@@ -500,7 +761,9 @@ class PlacarController extends ChangeNotifier {
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
// ══════════════════════════════════════════════════════════════════════════
@@ -680,7 +942,6 @@ class PlacarController extends ChangeNotifier {
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
@@ -768,7 +1033,9 @@ class PlacarController extends ChangeNotifier {
});
// 1. Atualizar o Jogo
await supabase.from('games').update({
await supabase
.from('games')
.update({
'my_score': myScore,
'opponent_score': opponentScore,
'remaining_seconds': durationNotifier.value.inSeconds,
@@ -781,7 +1048,8 @@ class PlacarController extends ChangeNotifier {
'top_rbs_name': topRbsName,
'mvp_name': mvpName,
'play_by_play': playByPlay,
}).eq('id', gameId);
})
.eq('id', gameId);
// 2. Preparar as Estatísticas dos Jogadores
List<Map<String, dynamic>> batchStats = [];
@@ -855,16 +1123,22 @@ 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;

View File

@@ -34,6 +34,7 @@ class _PlacarPageState extends State<PlacarPage> {
String _sharedWithName = '';
StreamSubscription? _syncSubscription;
bool _isApplyingRemoteSync = false;
final Set<String> _appliedSyncEventIds = {};
@override
void initState() {
@@ -47,6 +48,7 @@ class _PlacarPageState extends State<PlacarPage> {
gameId: widget.gameId,
myTeam: widget.myTeam,
opponentTeam: widget.opponentTeam,
onSyncAction: _onLocalControllerSync,
);
_controller.loadPlayers().then((_) => _initializeShareForGame());
}
@@ -190,58 +192,181 @@ class _PlacarPageState extends State<PlacarPage> {
void _setupSyncListener() {
if (_sessionId == null) return;
_syncSubscription?.cancel();
_syncSubscription = _sharingController.listenToGameSync(_sessionId!).listen(
(event) {
_appliedSyncEventIds.clear();
_syncSubscription = _sharingController
.listenToGameSyncOthers(_sessionId!)
.listen(
(dynamic event) {
final rows = <Map<String, dynamic>>[];
if (event is List && event.isNotEmpty) {
final record = event.last as Map<String, dynamic>?;
if (record != null) {
_handleSyncRecords(record);
for (final item in event) {
final row = item as Map<String, dynamic>?;
if (row == null) continue;
final rowId = row['id']?.toString();
if (rowId == null || _appliedSyncEventIds.contains(rowId)) {
continue;
}
rows.add(Map<String, dynamic>.from(row));
}
} else if (event is Map<String, dynamic>) {
final row = Map<String, dynamic>.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<String, dynamic> record) {
final triggeredBy = record['triggered_by']?.toString();
final currentUserId = Supabase.instance.client.auth.currentUser?.id;
if (triggeredBy == null || triggeredBy == currentUserId) return;
// Mantido apenas como fallback, mas a escuta principal usa listenToGameSyncOthers.
_applyRemoteSyncEvent(record);
}
void _onLocalControllerSync(
String actionType,
Map<String, dynamic> 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<String, dynamic> record) {
final actionType = record['action_type']?.toString();
final actionData = Map<String, dynamic>.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 (paused && _controller.isRunning) {
// 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;
}
}
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,
});
}
}
Future<void> _openShareDialog(BuildContext context) async {
@@ -397,7 +522,7 @@ class _PlacarPageState extends State<PlacarPage> {
child: Container(
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage('assets/campo.png'),
image: AssetImage('assets/campone.png'),
fit: BoxFit.fill,
),
),

View File

@@ -71,6 +71,9 @@ flutter:
- assets/assit.png
- assets/tov.png
- assets/stl.png
- assets/campone.png
fonts:
- family: playmaker
fonts:

View File

@@ -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';