1195 lines
38 KiB
Dart
1195 lines
38 KiB
Dart
import 'dart:async';
|
|
import 'dart:math' as math;
|
|
import 'dart:convert';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
class ShotRecord {
|
|
final double relativeX;
|
|
final double relativeY;
|
|
final bool isMake;
|
|
final String playerId;
|
|
final String playerName;
|
|
final String? zone;
|
|
final int? points;
|
|
|
|
ShotRecord({
|
|
required this.relativeX,
|
|
required this.relativeY,
|
|
required this.isMake,
|
|
required this.playerId,
|
|
required this.playerName,
|
|
this.zone,
|
|
this.points,
|
|
});
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'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'],
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
void applyRemoteAddShot(Map<String, dynamic> shotJson) {
|
|
try {
|
|
matchShots.add(ShotRecord.fromJson(shotJson));
|
|
notifyListeners();
|
|
} catch (e) {
|
|
debugPrint('Erro ao aplicar shot remoto: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _persistShotRemote(Map<String, dynamic> shotJson) async {
|
|
try {
|
|
final supabase = Supabase.instance.client;
|
|
final row = {
|
|
'game_id': gameId,
|
|
'member_id': shotJson['playerId'] ?? shotJson['player_id'],
|
|
'player_name': shotJson['playerName'] ?? shotJson['player_name'],
|
|
'relative_x': shotJson['relativeX'] ?? shotJson['relative_x'],
|
|
'relative_y': shotJson['relativeY'] ?? shotJson['relative_y'],
|
|
'is_make': shotJson['isMake'] ?? shotJson['is_make'],
|
|
'zone': shotJson['zone'],
|
|
'points': shotJson['points'],
|
|
};
|
|
|
|
await supabase.from('shot_locations').insert(row);
|
|
debugPrint('✅ Shot persisted remotely');
|
|
} catch (e) {
|
|
debugPrint('❌ Erro ao persistir shot remoto: $e');
|
|
}
|
|
}
|
|
|
|
bool isLoading = true;
|
|
bool isSaving = false;
|
|
bool gameWasAlreadyFinished = false;
|
|
|
|
int myScore = 0;
|
|
int opponentScore = 0;
|
|
int myFouls = 0;
|
|
int opponentFouls = 0;
|
|
int currentQuarter = 1;
|
|
int myTimeoutsUsed = 0;
|
|
int opponentTimeoutsUsed = 0;
|
|
|
|
String? myTeamDbId;
|
|
String? oppTeamDbId;
|
|
|
|
List<String> myCourt = [];
|
|
List<String> myBench = [];
|
|
List<String> oppCourt = [];
|
|
List<String> oppBench = [];
|
|
|
|
Map<String, String> playerNames = {};
|
|
Map<String, String> playerNumbers = {};
|
|
Map<String, Map<String, int>> playerStats = {};
|
|
|
|
bool showMyBench = false;
|
|
bool showOppBench = false;
|
|
|
|
bool isSelectingShotLocation = false;
|
|
String? pendingAction;
|
|
String? pendingPlayerId;
|
|
List<ShotRecord> matchShots = [];
|
|
|
|
List<String> playByPlay = [];
|
|
|
|
ValueNotifier<Duration> 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;
|
|
|
|
Timer? _autoSaveTimer;
|
|
|
|
Future<void> loadPlayers() async {
|
|
final supabase = Supabase.instance.client;
|
|
try {
|
|
await Future.delayed(const Duration(milliseconds: 1500));
|
|
|
|
myCourt.clear();
|
|
myBench.clear();
|
|
oppCourt.clear();
|
|
oppBench.clear();
|
|
playerNames.clear();
|
|
playerStats.clear();
|
|
playerNumbers.clear();
|
|
matchShots.clear();
|
|
playByPlay.clear();
|
|
myFouls = 0;
|
|
opponentFouls = 0;
|
|
|
|
final gameResponse = await supabase
|
|
.from('games')
|
|
.select()
|
|
.eq('id', gameId)
|
|
.single();
|
|
|
|
myScore = int.tryParse(gameResponse['my_score']?.toString() ?? '0') ?? 0;
|
|
opponentScore =
|
|
int.tryParse(gameResponse['opponent_score']?.toString() ?? '0') ?? 0;
|
|
|
|
int totalSeconds =
|
|
int.tryParse(
|
|
gameResponse['remaining_seconds']?.toString() ?? '600',
|
|
) ??
|
|
600;
|
|
durationNotifier.value = Duration(seconds: totalSeconds);
|
|
|
|
myTimeoutsUsed =
|
|
int.tryParse(gameResponse['my_timeouts']?.toString() ?? '0') ?? 0;
|
|
opponentTimeoutsUsed =
|
|
int.tryParse(gameResponse['opp_timeouts']?.toString() ?? '0') ?? 0;
|
|
currentQuarter =
|
|
int.tryParse(gameResponse['current_quarter']?.toString() ?? '1') ?? 1;
|
|
|
|
gameWasAlreadyFinished = gameResponse['status'] == 'Terminado';
|
|
|
|
if (gameResponse['play_by_play'] != null) {
|
|
playByPlay = List<String>.from(gameResponse['play_by_play']);
|
|
} else {
|
|
playByPlay = [];
|
|
}
|
|
|
|
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')
|
|
: [];
|
|
|
|
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 (int i = 0; i < myPlayers.length; i++) {
|
|
String dbId = myPlayers[i]['id'].toString();
|
|
String name = myPlayers[i]['name'].toString();
|
|
|
|
_registerPlayer(
|
|
name: name,
|
|
number: myPlayers[i]['number']?.toString() ?? "0",
|
|
dbId: dbId,
|
|
isMyTeam: true,
|
|
isCourt: i < 5,
|
|
);
|
|
|
|
if (savedStats.containsKey(dbId)) {
|
|
var s = savedStats[dbId];
|
|
_loadSavedPlayerStats(dbId, s);
|
|
myFouls += (s['fls'] as int? ?? 0);
|
|
}
|
|
}
|
|
_padTeam(myCourt, myBench, "Jogador", isMyTeam: true);
|
|
|
|
for (int i = 0; i < oppPlayers.length; i++) {
|
|
String dbId = oppPlayers[i]['id'].toString();
|
|
String name = oppPlayers[i]['name'].toString();
|
|
|
|
_registerPlayer(
|
|
name: name,
|
|
number: oppPlayers[i]['number']?.toString() ?? "0",
|
|
dbId: dbId,
|
|
isMyTeam: false,
|
|
isCourt: i < 5,
|
|
);
|
|
|
|
if (savedStats.containsKey(dbId)) {
|
|
var s = savedStats[dbId];
|
|
_loadSavedPlayerStats(dbId, s);
|
|
opponentFouls += (s['fls'] as int? ?? 0);
|
|
}
|
|
}
|
|
_padTeam(oppCourt, oppBench, "Adversário", isMyTeam: false);
|
|
|
|
final shotsResponse = await supabase
|
|
.from('shot_locations')
|
|
.select()
|
|
.eq('game_id', gameId);
|
|
for (var shotData in shotsResponse) {
|
|
matchShots.add(
|
|
ShotRecord(
|
|
relativeX: double.parse(shotData['relative_x'].toString()),
|
|
relativeY: double.parse(shotData['relative_y'].toString()),
|
|
isMake: shotData['is_make'] == true,
|
|
playerId: shotData['member_id'].toString(),
|
|
playerName: shotData['player_name'].toString(),
|
|
zone: shotData['zone']?.toString(),
|
|
points: shotData['points'] != null
|
|
? int.parse(shotData['points'].toString())
|
|
: null,
|
|
),
|
|
);
|
|
}
|
|
|
|
await _loadLocalBackup();
|
|
|
|
isLoading = false;
|
|
notifyListeners();
|
|
} catch (e) {
|
|
debugPrint("Erro ao retomar jogo: $e");
|
|
isLoading = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
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,
|
|
"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)}";
|
|
|
|
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,
|
|
};
|
|
|
|
if (isMyTeam) {
|
|
if (isCourt)
|
|
myCourt.add(id);
|
|
else
|
|
myBench.add(id);
|
|
} else {
|
|
if (isCourt)
|
|
oppCourt.add(id);
|
|
else
|
|
oppBench.add(id);
|
|
}
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
|
|
void _scheduleAutoSave() {
|
|
_autoSaveTimer?.cancel();
|
|
_autoSaveTimer = Timer(const Duration(milliseconds: 1500), () {
|
|
_saveLocalBackup();
|
|
});
|
|
}
|
|
|
|
Future<void> _saveLocalBackup() async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final backupData = {
|
|
'myScore': myScore,
|
|
'opponentScore': opponentScore,
|
|
'myFouls': myFouls,
|
|
'opponentFouls': opponentFouls,
|
|
'currentQuarter': currentQuarter,
|
|
'duration': durationNotifier.value.inSeconds,
|
|
'myTimeoutsUsed': myTimeoutsUsed,
|
|
'opponentTimeoutsUsed': opponentTimeoutsUsed,
|
|
'playerStats': playerStats,
|
|
'myCourt': myCourt,
|
|
'myBench': myBench,
|
|
'oppCourt': oppCourt,
|
|
'oppBench': oppBench,
|
|
'matchShots': matchShots.map((s) => s.toJson()).toList(),
|
|
'playByPlay': playByPlay,
|
|
};
|
|
await prefs.setString('backup_$gameId', jsonEncode(backupData));
|
|
} catch (e) {
|
|
debugPrint("Erro no Auto-Save: $e");
|
|
}
|
|
}
|
|
|
|
Future<void> _loadLocalBackup() async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final String? backupString = prefs.getString('backup_$gameId');
|
|
|
|
if (backupString != null) {
|
|
final data = jsonDecode(backupString);
|
|
|
|
myScore = data['myScore'];
|
|
opponentScore = data['opponentScore'];
|
|
myFouls = data['myFouls'];
|
|
opponentFouls = data['opponentFouls'];
|
|
currentQuarter = data['currentQuarter'];
|
|
durationNotifier.value = Duration(seconds: data['duration']);
|
|
myTimeoutsUsed = data['myTimeoutsUsed'];
|
|
opponentTimeoutsUsed = data['opponentTimeoutsUsed'];
|
|
|
|
myCourt = List<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)),
|
|
);
|
|
|
|
List<dynamic> decodedShots = data['matchShots'];
|
|
matchShots = decodedShots.map((s) => ShotRecord.fromJson(s)).toList();
|
|
|
|
playByPlay = List<String>.from(data['playByPlay'] ?? []);
|
|
}
|
|
} catch (e) {
|
|
debugPrint("Erro ao carregar Auto-Save: $e");
|
|
}
|
|
}
|
|
|
|
void toggleTimer(BuildContext context) {
|
|
print("⏱️ toggleTimer chamado: isRunning=$isRunning");
|
|
_setTimerRunning(!isRunning);
|
|
}
|
|
|
|
void useTimeout(bool isOpponent) {
|
|
if (isOpponent) {
|
|
if (opponentTimeoutsUsed < 3) opponentTimeoutsUsed++;
|
|
} else {
|
|
if (myTimeoutsUsed < 3) myTimeoutsUsed++;
|
|
}
|
|
isRunning = false;
|
|
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_", "");
|
|
final stats = playerStats[playerId]!;
|
|
final name = playerNames[playerId]!;
|
|
|
|
if (stats["fls"]! >= 5 && action != "sub_foul") {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('🛑 $name atingiu 5 faltas e está expulso!'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (action == "add_pts_2" ||
|
|
action == "add_pts_3" ||
|
|
action == "miss_2" ||
|
|
action == "miss_3") {
|
|
pendingAction = action;
|
|
pendingPlayerId = playerData;
|
|
isSelectingShotLocation = true;
|
|
} else {
|
|
commitStat(action, playerData);
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
void handleSubbing(
|
|
BuildContext context,
|
|
String action,
|
|
String courtPlayerId,
|
|
bool isOpponent,
|
|
) {
|
|
if (action.startsWith("bench_my_") && !isOpponent) {
|
|
String benchPlayerId = action.replaceAll("bench_my_", "");
|
|
if (playerStats[benchPlayerId]!["fls"]! >= 5) return;
|
|
int courtIndex = myCourt.indexOf(courtPlayerId);
|
|
int benchIndex = myBench.indexOf(benchPlayerId);
|
|
myCourt[courtIndex] = benchPlayerId;
|
|
myBench[benchIndex] = courtPlayerId;
|
|
showMyBench = false;
|
|
}
|
|
if (action.startsWith("bench_opp_") && isOpponent) {
|
|
String benchPlayerId = action.replaceAll("bench_opp_", "");
|
|
if (playerStats[benchPlayerId]!["fls"]! >= 5) return;
|
|
int courtIndex = oppCourt.indexOf(courtPlayerId);
|
|
int benchIndex = oppBench.indexOf(benchPlayerId);
|
|
oppCourt[courtIndex] = benchPlayerId;
|
|
oppBench[benchIndex] = courtPlayerId;
|
|
showOppBench = false;
|
|
}
|
|
_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_")) {
|
|
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_")) {
|
|
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;
|
|
}
|
|
} else {
|
|
// Se forem de equipas diferentes ou dados inválidos, ignora.
|
|
return;
|
|
}
|
|
|
|
_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_", "");
|
|
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,
|
|
),
|
|
);
|
|
|
|
String finalAction = isMake ? "add_pts_$points" : "miss_$points";
|
|
commitStat(finalAction, targetPlayer);
|
|
// Emitir evento de shot para parceiros remotos
|
|
try {
|
|
final shotJson = matchShots.last.toJson();
|
|
_dispatchSyncAction('add_shot', {'shot': shotJson});
|
|
// Persist shot immediately on server (fire-and-forget)
|
|
_persistShotRemote(shotJson);
|
|
} catch (_) {}
|
|
|
|
notifyListeners();
|
|
}
|
|
|
|
void registerShotLocation(BuildContext context, Offset position, Size size) {
|
|
if (pendingAction == null || pendingPlayerId == null) return;
|
|
|
|
bool is3Pt = pendingAction!.contains("_3");
|
|
bool is2Pt = pendingAction!.contains("_2");
|
|
|
|
if (is3Pt || is2Pt) {
|
|
bool isValid = _validateShotZone(position, size, is3Pt);
|
|
if (!isValid) return;
|
|
}
|
|
|
|
bool isMake = pendingAction!.startsWith("add_pts_");
|
|
double relX = position.dx / size.width;
|
|
double relY = position.dy / size.height;
|
|
String pId = pendingPlayerId!
|
|
.replaceAll("player_my_", "")
|
|
.replaceAll("player_opp_", "");
|
|
|
|
matchShots.add(
|
|
ShotRecord(
|
|
relativeX: relX,
|
|
relativeY: relY,
|
|
isMake: isMake,
|
|
playerId: pId,
|
|
playerName: playerNames[pId]!,
|
|
),
|
|
);
|
|
|
|
// Emitir evento de shot para parceiros remotos
|
|
try {
|
|
final shotJson = matchShots.last.toJson();
|
|
_dispatchSyncAction('add_shot', {'shot': shotJson});
|
|
// Persist shot immediately on server (fire-and-forget)
|
|
_persistShotRemote(shotJson);
|
|
} catch (_) {}
|
|
|
|
commitStat(pendingAction!, pendingPlayerId!);
|
|
|
|
isSelectingShotLocation = false;
|
|
pendingAction = null;
|
|
pendingPlayerId = null;
|
|
_scheduleAutoSave();
|
|
notifyListeners();
|
|
}
|
|
|
|
bool _validateShotZone(Offset position, Size size, bool is3Pt) {
|
|
double relX = position.dx / size.width;
|
|
double relY = position.dy / size.height;
|
|
|
|
bool isLeftHalf = relX < 0.5;
|
|
double hoopX = isLeftHalf ? hoopBaseX : (1.0 - hoopBaseX);
|
|
double hoopY = 0.50;
|
|
|
|
double aspectRatio = size.width / size.height;
|
|
double distFromCenterY = (relY - hoopY).abs();
|
|
|
|
bool isInside2Pts;
|
|
|
|
if (distFromCenterY > cornerY) {
|
|
double distToBaseline = isLeftHalf ? relX : (1.0 - relX);
|
|
isInside2Pts = distToBaseline <= hoopBaseX;
|
|
} else {
|
|
double dx = (relX - hoopX) * aspectRatio;
|
|
double dy = (relY - hoopY);
|
|
double distanceToHoop = math.sqrt((dx * dx) + (dy * dy));
|
|
isInside2Pts = distanceToHoop < arcRadius;
|
|
}
|
|
|
|
if (is3Pt) return !isInside2Pts;
|
|
return isInside2Pts;
|
|
}
|
|
|
|
void cancelShotLocation() {
|
|
isSelectingShotLocation = false;
|
|
pendingAction = null;
|
|
pendingPlayerId = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
void registerFoul(String committerData, String foulType, String victimData) {
|
|
bool isOpponent = committerData.startsWith("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 (foulType == "Desqualificante") {
|
|
committerStats["fls"] = 5;
|
|
}
|
|
|
|
String logText = "cometeu Falta $foulType";
|
|
|
|
if (victimData.isNotEmpty) {
|
|
String victimId = victimData
|
|
.replaceAll("player_my_", "")
|
|
.replaceAll("player_opp_", "");
|
|
final victimStats = playerStats[victimId]!;
|
|
final victimName = playerNames[victimId] ?? "Jogador";
|
|
|
|
victimStats["so"] = victimStats["so"]! + 1;
|
|
logText += " sobre $victimName ⚠️";
|
|
} else {
|
|
logText += " (Equipa/Banco) ⚠️";
|
|
}
|
|
|
|
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) {
|
|
bool isOpponent = playerData.startsWith("player_opp_");
|
|
String playerId = playerData
|
|
.replaceAll("player_my_", "")
|
|
.replaceAll("player_opp_", "");
|
|
final stats = playerStats[playerId]!;
|
|
final name = playerNames[playerId] ?? "Jogador";
|
|
|
|
String logText = "";
|
|
|
|
// ── PONTOS ──────────────────────────────────────────────────────────────
|
|
if (action.startsWith("add_pts_")) {
|
|
int pts = int.parse(action.split("_").last);
|
|
if (isOpponent)
|
|
opponentScore += pts;
|
|
else
|
|
myScore += pts;
|
|
stats["pts"] = stats["pts"]! + pts;
|
|
if (pts == 2) {
|
|
stats["fgm"] = stats["fgm"]! + 1;
|
|
stats["fga"] = stats["fga"]! + 1;
|
|
stats["p2m"] = stats["p2m"]! + 1;
|
|
stats["p2a"] = stats["p2a"]! + 1;
|
|
}
|
|
if (pts == 3) {
|
|
stats["fgm"] = stats["fgm"]! + 1;
|
|
stats["fga"] = stats["fga"]! + 1;
|
|
stats["p3m"] = stats["p3m"]! + 1;
|
|
stats["p3a"] = stats["p3a"]! + 1;
|
|
}
|
|
if (pts == 1) {
|
|
stats["ftm"] = stats["ftm"]! + 1;
|
|
stats["fta"] = stats["fta"]! + 1;
|
|
}
|
|
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,
|
|
);
|
|
|
|
if (lastShotIndex != -1) {
|
|
matchShots.removeAt(lastShotIndex);
|
|
if (isOpponent)
|
|
opponentScore -= ptsToAnul;
|
|
else
|
|
myScore -= ptsToAnul;
|
|
stats["pts"] = stats["pts"]! - ptsToAnul;
|
|
|
|
if (ptsToAnul == 2) {
|
|
if (stats["p2m"]! > 0) stats["p2m"] = stats["p2m"]! - 1;
|
|
if (stats["p2a"]! > 0) stats["p2a"] = stats["p2a"]! - 1;
|
|
if (stats["fgm"]! > 0) stats["fgm"] = stats["fgm"]! - 1;
|
|
if (stats["fga"]! > 0) stats["fga"] = stats["fga"]! - 1;
|
|
} else if (ptsToAnul == 3) {
|
|
if (stats["p3m"]! > 0) stats["p3m"] = stats["p3m"]! - 1;
|
|
if (stats["p3a"]! > 0) stats["p3a"] = stats["p3a"]! - 1;
|
|
if (stats["fgm"]! > 0) stats["fgm"] = stats["fgm"]! - 1;
|
|
if (stats["fga"]! > 0) stats["fga"] = stats["fga"]! - 1;
|
|
} else if (ptsToAnul == 1) {
|
|
if (stats["ftm"]! > 0) stats["ftm"] = stats["ftm"]! - 1;
|
|
if (stats["fta"]! > 0) stats["fta"] = stats["fta"]! - 1;
|
|
}
|
|
logText = "anulou cesto de $ptsToAnul pts ⏪";
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
// ── FALHAS ───────────────────────────────────────────────────────────────
|
|
else if (action == "miss_1") {
|
|
stats["fta"] = stats["fta"]! + 1;
|
|
logText = "falhou lance livre ❌";
|
|
} else if (action == "miss_2") {
|
|
stats["fga"] = stats["fga"]! + 1;
|
|
stats["p2a"] = stats["p2a"]! + 1;
|
|
logText = "falhou lançamento de 2 ❌";
|
|
} else if (action == "miss_3") {
|
|
stats["fga"] = stats["fga"]! + 1;
|
|
stats["p3a"] = stats["p3a"]! + 1;
|
|
logText = "falhou lançamento de 3 ❌";
|
|
}
|
|
// ── RESSALTOS ─────────────────────────────────────────────────────────────
|
|
else if (action == "add_orb") {
|
|
stats["orb"] = stats["orb"]! + 1;
|
|
stats["rbs"] = stats["rbs"]! + 1;
|
|
logText = "ganhou ressalto ofensivo 🔄";
|
|
} else if (action == "add_drb") {
|
|
stats["drb"] = stats["drb"]! + 1;
|
|
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
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
else if (action == "add_stl" || action == "stl_steal") {
|
|
stats["stl"] = stats["stl"]! + 1;
|
|
logText = "roubou a bola 🥷";
|
|
} else if (action == "stl_intercept") {
|
|
stats["stl"] = stats["stl"]! + 1;
|
|
stats["il"] = stats["il"]! + 1;
|
|
logText = "intercetou um lançamento 🛑";
|
|
}
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// BLOCK — DESARME
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
else if (action == "add_blk" || action == "blk_made") {
|
|
stats["blk"] = stats["blk"]! + 1;
|
|
logText = "fez um desarme (bloco) ✋";
|
|
} else if (action == "blk_suffered") {
|
|
stats["li"] = stats["li"]! + 1;
|
|
logText = "sofreu um desarme 🚫";
|
|
}
|
|
// Ações independentes legadas
|
|
else if (action == "add_il") {
|
|
stats["il"] = stats["il"]! + 1;
|
|
logText = "intercetou um lançamento 🛑";
|
|
} else if (action == "add_li") {
|
|
stats["li"] = stats["li"]! + 1;
|
|
logText = "teve o lançamento intercetado 🚫";
|
|
}
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// TURNOVER — PERDE DE BOLA E INFRAÇÕES
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
else if (action == "add_tov" || action == "tov_badpass") {
|
|
stats["tov"] = stats["tov"]! + 1;
|
|
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
|
|
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
|
|
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
|
|
logText = "fez drible duplo 🏀";
|
|
}
|
|
// ── ANULAR FALTA ──────────────────────────────────────────────────────────
|
|
else if (action == "sub_foul") {
|
|
if (stats["fls"]! > 0) stats["fls"] = stats["fls"]! - 1;
|
|
if (isOpponent) {
|
|
if (opponentFouls > 0) opponentFouls--;
|
|
} else {
|
|
if (myFouls > 0) myFouls--;
|
|
}
|
|
logText = "teve falta anulada 🔄";
|
|
}
|
|
|
|
if (logText.isNotEmpty) {
|
|
String time =
|
|
"${durationNotifier.value.inMinutes.toString().padLeft(2, '0')}:${durationNotifier.value.inSeconds.remainder(60).toString().padLeft(2, '0')}";
|
|
playByPlay.insert(0, "P$currentQuarter - $time: $name $logText");
|
|
}
|
|
|
|
_scheduleAutoSave();
|
|
notifyListeners();
|
|
_dispatchSyncAction('commit_stat', {
|
|
'action': action,
|
|
'player_data': playerData,
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
timer?.cancel();
|
|
_autoSaveTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> saveGameStats(BuildContext context) async {
|
|
final supabase = Supabase.instance.client;
|
|
isSaving = true;
|
|
notifyListeners();
|
|
|
|
try {
|
|
bool isGameFinishedNow =
|
|
currentQuarter >= 4 && durationNotifier.value.inSeconds == 0;
|
|
String newStatus = isGameFinishedNow ? 'Terminado' : 'Pausado';
|
|
|
|
String topPtsName = '---';
|
|
int maxPts = -1;
|
|
String topAstName = '---';
|
|
int maxAst = -1;
|
|
String topRbsName = '---';
|
|
int maxRbs = -1;
|
|
String mvpName = '---';
|
|
double maxMvpScore = -999.0;
|
|
|
|
playerStats.forEach((playerId, stats) {
|
|
int pts = stats['pts'] ?? 0;
|
|
int ast = stats['ast'] ?? 0;
|
|
int rbs = stats['rbs'] ?? 0;
|
|
|
|
double minJogados = (stats['sec'] ?? 0) / 60.0;
|
|
if (minJogados <= 0) minJogados = 40.0;
|
|
|
|
int tr = rbs;
|
|
int br = stats['stl'] ?? 0;
|
|
int bp = stats['tov'] ?? 0;
|
|
int lFalhados = (stats['fga'] ?? 0) - (stats['fgm'] ?? 0);
|
|
int llFalhados = (stats['fta'] ?? 0) - (stats['ftm'] ?? 0);
|
|
|
|
double mvpScore =
|
|
((pts * 0.30) + (tr * 0.20) + (ast * 0.35) + (br * 0.15)) -
|
|
((bp * 0.35) + (lFalhados * 0.30) + (llFalhados * 0.35));
|
|
mvpScore = mvpScore * (minJogados / 40.0);
|
|
|
|
String pName = playerNames[playerId] ?? '---';
|
|
|
|
if (pts > maxPts && pts > 0) {
|
|
maxPts = pts;
|
|
topPtsName = '$pName ($pts)';
|
|
}
|
|
if (ast > maxAst && ast > 0) {
|
|
maxAst = ast;
|
|
topAstName = '$pName ($ast)';
|
|
}
|
|
if (rbs > maxRbs && rbs > 0) {
|
|
maxRbs = rbs;
|
|
topRbsName = '$pName ($rbs)';
|
|
}
|
|
if (mvpScore > maxMvpScore) {
|
|
maxMvpScore = mvpScore;
|
|
mvpName = '$pName (${mvpScore.toStringAsFixed(1)})';
|
|
}
|
|
});
|
|
|
|
// 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);
|
|
|
|
// 2. Preparar as Estatísticas dos Jogadores
|
|
List<Map<String, dynamic>> batchStats = [];
|
|
playerStats.forEach((playerId, stats) {
|
|
if (!playerId.startsWith("fake_")) {
|
|
bool isMyTeamPlayer =
|
|
myCourt.contains(playerId) || myBench.contains(playerId);
|
|
batchStats.add({
|
|
'game_id': gameId,
|
|
'member_id': playerId,
|
|
'team_id': isMyTeamPlayer ? myTeamDbId! : oppTeamDbId!,
|
|
'pts': stats['pts'],
|
|
'rbs': stats['rbs'],
|
|
'ast': stats['ast'],
|
|
'stl': stats['stl'],
|
|
'blk': stats['blk'],
|
|
'tov': stats['tov'],
|
|
'fls': stats['fls'],
|
|
'fgm': stats['fgm'],
|
|
'fga': stats['fga'],
|
|
'ftm': stats['ftm'],
|
|
'fta': stats['fta'],
|
|
'orb': stats['orb'],
|
|
'drb': stats['drb'],
|
|
'p2m': stats['p2m'],
|
|
'p2a': stats['p2a'],
|
|
'p3m': stats['p3m'],
|
|
'p3a': stats['p3a'],
|
|
'so': stats['so'],
|
|
'il': stats['il'],
|
|
'li': stats['li'],
|
|
'pa': stats['pa'],
|
|
'tres_seg': stats['tres_seg'],
|
|
'dr': stats['dr'],
|
|
'minutos_jogados': stats['sec'],
|
|
});
|
|
}
|
|
});
|
|
|
|
// 3. Preparar os Locais dos Lançamentos (MAPA DE CALOR)
|
|
List<Map<String, dynamic>> batchShots = [];
|
|
for (var shot in matchShots) {
|
|
if (!shot.playerId.startsWith("fake_")) {
|
|
batchShots.add({
|
|
'game_id': gameId,
|
|
'member_id': shot.playerId,
|
|
'player_name': shot.playerName,
|
|
'relative_x': shot.relativeX,
|
|
'relative_y': shot.relativeY,
|
|
'is_make': shot.isMake,
|
|
'zone': shot.zone,
|
|
'points': shot.points,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Guardar na BD: Apaga as antigas e insere as atualizadas
|
|
await supabase.from('player_stats').delete().eq('game_id', gameId);
|
|
if (batchStats.isNotEmpty) {
|
|
await supabase.from('player_stats').insert(batchStats);
|
|
}
|
|
|
|
// Guardar mapa de calor na BD
|
|
await supabase.from('shot_locations').delete().eq('game_id', gameId);
|
|
if (batchShots.isNotEmpty) {
|
|
await supabase.from('shot_locations').insert(batchShots);
|
|
}
|
|
|
|
// Limpar backup local
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.remove('backup_$gameId');
|
|
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Guardado com Sucesso!'),
|
|
backgroundColor: Colors.green,
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
debugPrint("Erro ao gravar estatísticas: $e");
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Erro ao guardar: $e'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
} finally {
|
|
isSaving = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
}
|