This commit is contained in:
2026-03-22 01:40:29 +00:00
parent 6c89b7ab8c
commit 00fee30792
23 changed files with 1717 additions and 2081 deletions

View File

@@ -4,51 +4,44 @@ import '../models/game_model.dart';
class GameController {
final _supabase = Supabase.instance.client;
// 👇 Atalho para apanhar o ID do utilizador logado
String get myUserId => _supabase.auth.currentUser?.id ?? '';
// 1. LER JOGOS (Stream em Tempo Real da tabela original)
// LER JOGOS
Stream<List<Game>> get gamesStream {
return _supabase
.from('games')
.stream(primaryKey: ['id'])
.eq('user_id', myUserId) // 🔒 SEGURANÇA: Ouve apenas os jogos deste utilizador
.eq('user_id', myUserId)
.asyncMap((event) async {
// Lê diretamente da tabela "games" e já não da "games_with_logos"
final data = await _supabase
.from('games')
.select()
.eq('user_id', myUserId) // 🔒 SEGURANÇA
.eq('user_id', myUserId)
.order('game_date', ascending: false);
// O Game.fromMap agora faz o trabalho sujo todo!
return data.map((json) => Game.fromMap(json)).toList();
});
}
// =========================================================================
// 👇 LER JOGOS COM FILTROS DE EQUIPA E TEMPORADA
// =========================================================================
// LER JOGOS COM FILTROS
Stream<List<Game>> getFilteredGames({required String teamFilter, required String seasonFilter}) {
return _supabase
.from('games')
.stream(primaryKey: ['id'])
.eq('user_id', myUserId) // 🔒 SEGURANÇA
.eq('user_id', myUserId)
.asyncMap((event) async {
// 1. Começamos a query na tabela principal "games"
var query = _supabase.from('games').select().eq('user_id', myUserId); // 🔒 SEGURANÇA
var query = _supabase.from('games').select().eq('user_id', myUserId);
// 2. Se a temporada não for "Todas", aplicamos o filtro AQUI
if (seasonFilter != 'Todas') {
query = query.eq('season', seasonFilter);
}
// 3. Executamos a query e ordenamos pela data
final data = await query.order('game_date', ascending: false);
List<Game> games = data.map((json) => Game.fromMap(json)).toList();
// 4. Filtramos a equipa em memória
if (teamFilter != 'Todas') {
games = games.where((g) => g.myTeam == teamFilter || g.opponentTeam == teamFilter).toList();
}
@@ -57,11 +50,11 @@ class GameController {
});
}
// 2. CRIAR JOGO
// CRIAR JOGO
Future<String?> createGame(String myTeam, String opponent, String season) async {
try {
final response = await _supabase.from('games').insert({
'user_id': myUserId, // 🔒 CARIMBA O JOGO COM O ID DO TREINADOR
'user_id': myUserId,
'my_team': myTeam,
'opponent_team': opponent,
'season': season,
@@ -69,16 +62,24 @@ class GameController {
'opponent_score': 0,
'status': 'Decorrer',
'game_date': DateTime.now().toIso8601String(),
// 👇 Preenchemos logo com os valores iniciais da tua Base de Dados
'remaining_seconds': 600, // Assume 10 minutos (600s)
'my_timeouts': 0,
'opp_timeouts': 0,
'current_quarter': 1,
'top_pts_name': '---',
'top_ast_name': '---',
'top_rbs_name': '---',
'top_def_name': '---',
'mvp_name': '---',
}).select().single();
return response['id'];
return response['id']?.toString();
} catch (e) {
print("Erro ao criar jogo: $e");
return null;
}
}
void dispose() {
// Não é necessário fechar streams do Supabase manualmente aqui
}
void dispose() {}
}

View File

@@ -1,12 +1,15 @@
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;
@@ -15,28 +18,39 @@ class ShotRecord {
required this.relativeX,
required this.relativeY,
required this.isMake,
required this.playerId,
required this.playerName,
this.zone,
this.points,
});
// 👇 Para o Auto-Save converter em Texto
Map<String, dynamic> toJson() => {
'relativeX': relativeX, 'relativeY': relativeY, 'isMake': isMake,
'playerId': playerId, 'playerName': playerName, 'zone': zone, 'points': points,
};
// 👇 Para o Auto-Save ler do Texto
factory ShotRecord.fromJson(Map<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 {
// 👇 AGORA É UM CHANGENOTIFIER (Gestor de Estado Profissional) 👇
class PlacarController extends ChangeNotifier {
final String gameId;
final String myTeam;
final String opponentTeam;
final VoidCallback onUpdate;
PlacarController({
required this.gameId,
required this.myTeam,
required this.opponentTeam,
required this.onUpdate
});
bool isLoading = true;
bool isSaving = false;
bool gameWasAlreadyFinished = false;
int myScore = 0;
@@ -55,19 +69,20 @@ class PlacarController {
List<String> oppCourt = [];
List<String> oppBench = [];
Map<String, String> playerNames = {};
Map<String, String> playerNumbers = {};
Map<String, Map<String, int>> playerStats = {};
Map<String, String> playerDbIds = {};
bool showMyBench = false;
bool showOppBench = false;
bool isSelectingShotLocation = false;
String? pendingAction;
String? pendingPlayer;
String? pendingPlayerId;
List<ShotRecord> matchShots = [];
Duration duration = const Duration(minutes: 10);
// 👇 O CRONÓMETRO AGORA TEM VIDA PRÓPRIA (ValueNotifier) PARA NÃO ENCRAVAR A APP 👇
ValueNotifier<Duration> durationNotifier = ValueNotifier(const Duration(minutes: 10));
Timer? timer;
bool isRunning = false;
@@ -81,16 +96,9 @@ class PlacarController {
try {
await Future.delayed(const Duration(milliseconds: 1500));
myCourt.clear();
myBench.clear();
oppCourt.clear();
oppBench.clear();
playerStats.clear();
playerNumbers.clear();
playerDbIds.clear();
matchShots.clear(); // Limpa as bolas do último jogo
myFouls = 0;
opponentFouls = 0;
myCourt.clear(); myBench.clear(); oppCourt.clear(); oppBench.clear();
playerNames.clear(); playerStats.clear(); playerNumbers.clear();
matchShots.clear(); myFouls = 0; opponentFouls = 0;
final gameResponse = await supabase.from('games').select().eq('id', gameId).single();
@@ -98,7 +106,7 @@ class PlacarController {
opponentScore = int.tryParse(gameResponse['opponent_score']?.toString() ?? '0') ?? 0;
int totalSeconds = int.tryParse(gameResponse['remaining_seconds']?.toString() ?? '600') ?? 600;
duration = Duration(seconds: totalSeconds);
durationNotifier.value = Duration(seconds: totalSeconds);
myTimeoutsUsed = int.tryParse(gameResponse['my_timeouts']?.toString() ?? '0') ?? 0;
opponentTimeoutsUsed = int.tryParse(gameResponse['opp_timeouts']?.toString() ?? '0') ?? 0;
@@ -128,7 +136,7 @@ class PlacarController {
if (savedStats.containsKey(dbId)) {
var s = savedStats[dbId];
playerStats[name] = {
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,
@@ -147,7 +155,7 @@ class PlacarController {
if (savedStats.containsKey(dbId)) {
var s = savedStats[dbId];
playerStats[name] = {
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,
@@ -158,44 +166,46 @@ class PlacarController {
}
_padTeam(oppCourt, oppBench, "Adversário", isMyTeam: false);
// 👇 CARREGA AS BOLINHAS ANTIGAS (MAPA DE CALOR DO JOGO ATUAL) 👇
final shotsResponse = await supabase.from('shot_locations').select().eq('game_id', gameId);
for (var shotData in shotsResponse) {
matchShots.add(ShotRecord(
relativeX: double.parse(shotData['relative_x'].toString()),
relativeY: double.parse(shotData['relative_y'].toString()),
isMake: shotData['is_make'] == true,
playerId: shotData['member_id'].toString(),
playerName: shotData['player_name'].toString(),
zone: shotData['zone']?.toString(),
points: shotData['points'] != null ? int.parse(shotData['points'].toString()) : null,
));
}
// 👇 AUTO-SAVE: SE O JOGO FOI ABAIXO A MEIO, RECUPERA TUDO AQUI! 👇
await _loadLocalBackup();
isLoading = false;
onUpdate();
notifyListeners(); // Substitui o antigo onUpdate!
} catch (e) {
debugPrint("Erro ao retomar jogo: $e");
_padTeam(myCourt, myBench, "Falha", isMyTeam: true);
_padTeam(oppCourt, oppBench, "Falha Opp", isMyTeam: false);
isLoading = false;
onUpdate();
notifyListeners();
}
}
void _registerPlayer({required String name, required String number, String? dbId, required bool isMyTeam, required bool isCourt}) {
if (playerNumbers.containsKey(name)) name = "$name (Opp)";
playerNumbers[name] = number;
if (dbId != null) playerDbIds[name] = dbId;
String id = dbId ?? "fake_${DateTime.now().millisecondsSinceEpoch}_${math.Random().nextInt(9999)}";
playerStats[name] = {
playerNames[id] = name;
playerNumbers[id] = number;
playerStats[id] = {
"pts": 0, "rbs": 0, "ast": 0, "stl": 0, "tov": 0, "blk": 0,
"fls": 0, "fgm": 0, "fga": 0, "ftm": 0, "fta": 0, "orb": 0, "drb": 0
};
if (isMyTeam) {
if (isCourt) myCourt.add(name); else myBench.add(name);
if (isCourt) myCourt.add(id); else myBench.add(id);
} else {
if (isCourt) oppCourt.add(name); else oppBench.add(name);
if (isCourt) oppCourt.add(id); else oppBench.add(id);
}
}
@@ -205,33 +215,80 @@ class PlacarController {
}
}
// =========================================================================
// 👇 AS DUAS FUNÇÕES MÁGICAS DO AUTO-SAVE 👇
// =========================================================================
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(),
};
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();
debugPrint("🔄 AUTO-SAVE RECUPERADO COM SUCESSO!");
}
} catch (e) {
debugPrint("Erro ao carregar Auto-Save: $e");
}
}
void toggleTimer(BuildContext context) {
if (isRunning) {
timer?.cancel();
_saveLocalBackup(); // Grava no telemóvel quando pausa!
} else {
timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (duration.inSeconds > 0) {
duration -= const Duration(seconds: 1);
if (durationNotifier.value.inSeconds > 0) {
durationNotifier.value -= const Duration(seconds: 1); // 👈 Só o relógio atualiza, a app não pisca!
} else {
timer.cancel();
isRunning = false;
if (currentQuarter < 4) {
currentQuarter++;
duration = const Duration(minutes: 10);
myFouls = 0;
opponentFouls = 0;
myTimeoutsUsed = 0;
opponentTimeoutsUsed = 0;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Período $currentQuarter iniciado. Faltas e Timeouts resetados!'), backgroundColor: Colors.blue));
} else {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('FIM DO JOGO! Clica em Guardar para fechar a partida.'), backgroundColor: Colors.red));
}
durationNotifier.value = const Duration(minutes: 10);
myFouls = 0; opponentFouls = 0;
myTimeoutsUsed = 0; opponentTimeoutsUsed = 0;
_saveLocalBackup(); // Grava mudança de período
}
notifyListeners(); // Aqui sim, redesenhamos o ecrã para mudar o Quarto
}
onUpdate();
});
}
isRunning = !isRunning;
onUpdate();
notifyListeners();
}
void useTimeout(bool isOpponent) {
@@ -242,14 +299,14 @@ class PlacarController {
}
isRunning = false;
timer?.cancel();
onUpdate();
_saveLocalBackup();
notifyListeners();
}
String formatTime() => "${duration.inMinutes.toString().padLeft(2, '0')}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}";
void handleActionDrag(BuildContext context, String action, String playerData) {
String name = playerData.replaceAll("player_my_", "").replaceAll("player_opp_", "");
final stats = playerStats[name]!;
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));
@@ -258,91 +315,61 @@ class PlacarController {
if (action == "add_pts_2" || action == "add_pts_3" || action == "miss_2" || action == "miss_3") {
pendingAction = action;
pendingPlayer = playerData;
pendingPlayerId = playerData;
isSelectingShotLocation = true;
} else {
commitStat(action, playerData);
}
onUpdate();
notifyListeners();
}
void handleSubbing(BuildContext context, String action, String courtPlayerName, bool isOpponent) {
void handleSubbing(BuildContext context, String action, String courtPlayerId, bool isOpponent) {
if (action.startsWith("bench_my_") && !isOpponent) {
String benchPlayer = action.replaceAll("bench_my_", "");
if (playerStats[benchPlayer]!["fls"]! >= 5) return;
int courtIndex = myCourt.indexOf(courtPlayerName);
int benchIndex = myBench.indexOf(benchPlayer);
myCourt[courtIndex] = benchPlayer;
myBench[benchIndex] = courtPlayerName;
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;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Sai $courtPlayerName, Entra $benchPlayer')));
}
if (action.startsWith("bench_opp_") && isOpponent) {
String benchPlayer = action.replaceAll("bench_opp_", "");
if (playerStats[benchPlayer]!["fls"]! >= 5) return;
int courtIndex = oppCourt.indexOf(courtPlayerName);
int benchIndex = oppBench.indexOf(benchPlayer);
oppCourt[courtIndex] = benchPlayer;
oppBench[benchIndex] = courtPlayerName;
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;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Sai $courtPlayerName, Entra $benchPlayer')));
}
onUpdate();
_saveLocalBackup();
notifyListeners();
}
// =========================================================================
// 👇 REGISTA PONTOS VINDO DO POP-UP AMARELO (E MARCA A BOLINHA)
// =========================================================================
void registerShotFromPopup(BuildContext context, String action, String targetPlayer, String zone, int points, double relativeX, double relativeY) {
// 💡 AVISO AMIGÁVEL REMOVIDO. Agora podes marcar pontos mesmo com o tempo parado!
String name = targetPlayer.replaceAll("player_my_", "").replaceAll("player_opp_", "");
String playerId = targetPlayer.replaceAll("player_my_", "").replaceAll("player_opp_", "");
bool isMyTeam = targetPlayer.startsWith("player_my_");
bool isMake = action.startsWith("add_");
String name = playerNames[playerId]!;
// 1. ATUALIZA A ESTATÍSTICA DO JOGADOR
if (playerStats.containsKey(name)) {
playerStats[name]!['fga'] = playerStats[name]!['fga']! + 1;
if (playerStats.containsKey(playerId)) {
playerStats[playerId]!['fga'] = playerStats[playerId]!['fga']! + 1;
if (isMake) {
playerStats[name]!['fgm'] = playerStats[name]!['fgm']! + 1;
playerStats[name]!['pts'] = playerStats[name]!['pts']! + points;
// 2. ATUALIZA O PLACAR DA EQUIPA
if (isMyTeam) {
myScore += points;
} else {
opponentScore += points;
}
playerStats[playerId]!['fgm'] = playerStats[playerId]!['fgm']! + 1;
playerStats[playerId]!['pts'] = playerStats[playerId]!['pts']! + points;
if (isMyTeam) myScore += points; else opponentScore += points;
}
}
// 3. CRIA A BOLINHA PARA APARECER NO CAMPO
matchShots.add(ShotRecord(
relativeX: relativeX,
relativeY: relativeY,
isMake: isMake,
playerName: name,
zone: zone,
points: points,
));
matchShots.add(ShotRecord(relativeX: relativeX, relativeY: relativeY, isMake: isMake, playerId: playerId, playerName: name, zone: zone, points: points));
// 4. MANDA UMA MENSAGEM NO ECRÃ
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isMake ? '🔥 $name MARCOU de $zone!' : '$name FALHOU de $zone!'),
backgroundColor: isMake ? Colors.green : Colors.red,
duration: const Duration(seconds: 2),
)
);
// 5. ATUALIZA O ECRÃ
onUpdate();
_saveLocalBackup(); // 👈 Grava logo para não perder o cesto!
notifyListeners();
}
// MANTIDO PARA CASO USES A MARCAÇÃO CLÁSSICA DIRETAMENTE NO CAMPO ESCURO
void registerShotLocation(BuildContext context, Offset position, Size size) {
if (pendingAction == null || pendingPlayer == null) return;
if (pendingAction == null || pendingPlayerId == null) return;
bool is3Pt = pendingAction!.contains("_3");
bool is2Pt = pendingAction!.contains("_2");
@@ -355,21 +382,15 @@ class PlacarController {
bool isMake = pendingAction!.startsWith("add_pts_");
double relX = position.dx / size.width;
double relY = position.dy / size.height;
String name = pendingPlayer!.replaceAll("player_my_", "").replaceAll("player_opp_", "");
String pId = pendingPlayerId!.replaceAll("player_my_", "").replaceAll("player_opp_", "");
matchShots.add(ShotRecord(
relativeX: relX,
relativeY: relY,
isMake: isMake,
playerName: name
));
matchShots.add(ShotRecord(relativeX: relX, relativeY: relY, isMake: isMake, playerId: pId, playerName: playerNames[pId]!));
commitStat(pendingAction!, pendingPlayer!);
commitStat(pendingAction!, pendingPlayerId!);
isSelectingShotLocation = false;
pendingAction = null;
pendingPlayer = null;
onUpdate();
isSelectingShotLocation = false; pendingAction = null; pendingPlayerId = null;
_saveLocalBackup(); // 👈 Grava logo
notifyListeners();
}
bool _validateShotZone(Offset position, Size size, bool is3Pt) {
@@ -400,13 +421,13 @@ class PlacarController {
}
void cancelShotLocation() {
isSelectingShotLocation = false; pendingAction = null; pendingPlayer = null; onUpdate();
isSelectingShotLocation = false; pendingAction = null; pendingPlayerId = null; notifyListeners();
}
void commitStat(String action, String playerData) {
bool isOpponent = playerData.startsWith("player_opp_");
String name = playerData.replaceAll("player_my_", "").replaceAll("player_opp_", "");
final stats = playerStats[name]!;
String playerId = playerData.replaceAll("player_my_", "").replaceAll("player_opp_", "");
final stats = playerStats[playerId]!;
if (action.startsWith("add_pts_")) {
int pts = int.parse(action.split("_").last);
@@ -445,15 +466,16 @@ class PlacarController {
if (stats["fls"]! > 0) stats["fls"] = stats["fls"]! - 1;
if (isOpponent) { if (opponentFouls > 0) opponentFouls--; } else { if (myFouls > 0) myFouls--; }
}
_saveLocalBackup(); // 👈 Grava na memória!
}
Future<void> saveGameStats(BuildContext context) async {
final supabase = Supabase.instance.client;
isSaving = true;
onUpdate();
notifyListeners();
try {
bool isGameFinishedNow = currentQuarter >= 4 && duration.inSeconds == 0;
bool isGameFinishedNow = currentQuarter >= 4 && durationNotifier.value.inSeconds == 0;
String newStatus = isGameFinishedNow ? 'Terminado' : 'Pausado';
String topPtsName = '---'; int maxPts = -1;
@@ -462,7 +484,7 @@ class PlacarController {
String topDefName = '---'; int maxDef = -1;
String mvpName = '---'; int maxMvpScore = -1;
playerStats.forEach((playerName, stats) {
playerStats.forEach((playerId, stats) {
int pts = stats['pts'] ?? 0;
int ast = stats['ast'] ?? 0;
int rbs = stats['rbs'] ?? 0;
@@ -471,18 +493,20 @@ class PlacarController {
int defScore = stl + blk;
int mvpScore = pts + ast + rbs + defScore;
String pName = playerNames[playerId] ?? '---';
if (pts > maxPts && pts > 0) { maxPts = pts; topPtsName = '$playerName ($pts)'; }
if (ast > maxAst && ast > 0) { maxAst = ast; topAstName = '$playerName ($ast)'; }
if (rbs > maxRbs && rbs > 0) { maxRbs = rbs; topRbsName = '$playerName ($rbs)'; }
if (defScore > maxDef && defScore > 0) { maxDef = defScore; topDefName = '$playerName ($defScore)'; }
if (mvpScore > maxMvpScore && mvpScore > 0) { maxMvpScore = mvpScore; mvpName = playerName; }
if (pts > maxPts && pts > 0) { maxPts = pts; topPtsName = '$pName ($pts)'; }
if (ast > maxAst && ast > 0) { maxAst = ast; topAstName = '$pName ($ast)'; }
if (rbs > maxRbs && rbs > 0) { maxRbs = rbs; topRbsName = '$pName ($rbs)'; }
if (defScore > maxDef && defScore > 0) { maxDef = defScore; topDefName = '$pName ($defScore)'; }
if (mvpScore > maxMvpScore && mvpScore > 0) { maxMvpScore = mvpScore; mvpName = pName; }
});
await supabase.from('games').update({
'my_score': myScore,
'opponent_score': opponentScore,
'remaining_seconds': duration.inSeconds,
'remaining_seconds': durationNotifier.value.inSeconds,
'my_timeouts': myTimeoutsUsed,
'opp_timeouts': opponentTimeoutsUsed,
'current_quarter': currentQuarter,
@@ -495,9 +519,7 @@ class PlacarController {
}).eq('id', gameId);
if (isGameFinishedNow && !gameWasAlreadyFinished && myTeamDbId != null && oppTeamDbId != null) {
final teamsData = await supabase.from('teams').select('id, wins, losses, draws').inFilter('id', [myTeamDbId, oppTeamDbId]);
Map<String, dynamic> myTeamUpdate = {};
Map<String, dynamic> oppTeamUpdate = {};
@@ -507,84 +529,65 @@ class PlacarController {
}
if (myScore > opponentScore) {
myTeamUpdate['wins'] = (myTeamUpdate['wins'] ?? 0) + 1;
oppTeamUpdate['losses'] = (oppTeamUpdate['losses'] ?? 0) + 1;
myTeamUpdate['wins'] = (myTeamUpdate['wins'] ?? 0) + 1; oppTeamUpdate['losses'] = (oppTeamUpdate['losses'] ?? 0) + 1;
} else if (myScore < opponentScore) {
myTeamUpdate['losses'] = (myTeamUpdate['losses'] ?? 0) + 1;
oppTeamUpdate['wins'] = (oppTeamUpdate['wins'] ?? 0) + 1;
myTeamUpdate['losses'] = (myTeamUpdate['losses'] ?? 0) + 1; oppTeamUpdate['wins'] = (oppTeamUpdate['wins'] ?? 0) + 1;
} else {
myTeamUpdate['draws'] = (myTeamUpdate['draws'] ?? 0) + 1;
oppTeamUpdate['draws'] = (oppTeamUpdate['draws'] ?? 0) + 1;
myTeamUpdate['draws'] = (myTeamUpdate['draws'] ?? 0) + 1; oppTeamUpdate['draws'] = (oppTeamUpdate['draws'] ?? 0) + 1;
}
await supabase.from('teams').update({
'wins': myTeamUpdate['wins'], 'losses': myTeamUpdate['losses'], 'draws': myTeamUpdate['draws']
}).eq('id', myTeamDbId!);
await supabase.from('teams').update({
'wins': oppTeamUpdate['wins'], 'losses': oppTeamUpdate['losses'], 'draws': oppTeamUpdate['draws']
}).eq('id', oppTeamDbId!);
await supabase.from('teams').update({'wins': myTeamUpdate['wins'], 'losses': myTeamUpdate['losses'], 'draws': myTeamUpdate['draws']}).eq('id', myTeamDbId!);
await supabase.from('teams').update({'wins': oppTeamUpdate['wins'], 'losses': oppTeamUpdate['losses'], 'draws': oppTeamUpdate['draws']}).eq('id', oppTeamDbId!);
gameWasAlreadyFinished = true;
}
List<Map<String, dynamic>> batchStats = [];
playerStats.forEach((playerName, stats) {
String? memberDbId = playerDbIds[playerName];
if (memberDbId != null && stats.values.any((val) => val > 0)) {
bool isMyTeamPlayer = myCourt.contains(playerName) || myBench.contains(playerName);
playerStats.forEach((playerId, stats) {
if (!playerId.startsWith("fake_") && stats.values.any((val) => val > 0)) {
bool isMyTeamPlayer = myCourt.contains(playerId) || myBench.contains(playerId);
batchStats.add({
'game_id': gameId, 'member_id': memberDbId, 'team_id': isMyTeamPlayer ? myTeamDbId! : oppTeamDbId!,
'game_id': gameId, 'member_id': playerId, 'team_id': isMyTeamPlayer ? myTeamDbId! : oppTeamDbId!,
'pts': stats['pts'], 'rbs': stats['rbs'], 'ast': stats['ast'], 'stl': stats['stl'], 'blk': stats['blk'], 'tov': stats['tov'], 'fls': stats['fls'], 'fgm': stats['fgm'], 'fga': stats['fga'], 'ftm': stats['ftm'], 'fta': stats['fta'], 'orb': stats['orb'], 'drb': stats['drb'],
});
}
});
await supabase.from('player_stats').delete().eq('game_id', gameId);
if (batchStats.isNotEmpty) {
await supabase.from('player_stats').insert(batchStats);
}
if (batchStats.isNotEmpty) await supabase.from('player_stats').insert(batchStats);
// 👇 6. GUARDA AS BOLINHAS (MAPA DE CALOR) NO SUPABASE 👇
List<Map<String, dynamic>> batchShots = [];
for (var shot in matchShots) {
String? memberDbId = playerDbIds[shot.playerName];
if (memberDbId != null) {
if (!shot.playerId.startsWith("fake_")) {
batchShots.add({
'game_id': gameId,
'member_id': memberDbId,
'player_name': shot.playerName,
'relative_x': shot.relativeX,
'relative_y': shot.relativeY,
'is_make': shot.isMake,
'zone': shot.zone ?? 'Desconhecida',
'points': shot.points ?? (shot.isMake ? 2 : 0),
'game_id': gameId, 'member_id': shot.playerId, 'player_name': shot.playerName,
'relative_x': shot.relativeX, 'relative_y': shot.relativeY, 'is_make': shot.isMake,
'zone': shot.zone ?? 'Desconhecida', 'points': shot.points ?? (shot.isMake ? 2 : 0),
});
}
}
// Apaga os antigos (para não duplicar) e guarda os novos!
await supabase.from('shot_locations').delete().eq('game_id', gameId);
if (batchShots.isNotEmpty) {
await supabase.from('shot_locations').insert(batchShots);
}
if (batchShots.isNotEmpty) await supabase.from('shot_locations').insert(batchShots);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Estatísticas, Mapa de Calor e Resultados guardados com Sucesso!'), backgroundColor: Colors.green));
}
// 👇 SE O SUPABASE GUARDOU COM SUCESSO, LIMPA A MEMÓRIA DO TELEMÓVEL! 👇
final prefs = await SharedPreferences.getInstance();
await prefs.remove('backup_$gameId');
if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Estatísticas, Mapa de Calor e Resultados guardados com Sucesso!'), backgroundColor: Colors.green));
} catch (e) {
debugPrint("Erro ao gravar estatísticas: $e");
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Erro ao guardar: $e'), backgroundColor: Colors.red));
}
if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Erro ao guardar: $e'), backgroundColor: Colors.red));
} finally {
isSaving = false;
onUpdate();
notifyListeners();
}
}
@override
void dispose() {
timer?.cancel();
super.dispose();
}
}

View File

@@ -1,158 +0,0 @@
/*import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/person_model.dart';
class StatsController {
final SupabaseClient _supabase = Supabase.instance.client;
// 1. LER
Stream<List<Person>> getMembers(String teamId) {
return _supabase
.from('members')
.stream(primaryKey: ['id'])
.eq('team_id', teamId)
.order('name', ascending: true)
.map((data) => data.map((json) => Person.fromMap(json)).toList());
}
// 2. APAGAR
Future<void> deletePerson(String personId) async {
try {
await _supabase.from('members').delete().eq('id', personId);
} catch (e) {
debugPrint("Erro ao eliminar: $e");
}
}
// 3. DIÁLOGOS
void showAddPersonDialog(BuildContext context, String teamId) {
_showForm(context, teamId: teamId);
}
void showEditPersonDialog(BuildContext context, String teamId, Person person) {
_showForm(context, teamId: teamId, person: person);
}
// --- O POPUP ESTÁ AQUI ---
void _showForm(BuildContext context, {required String teamId, Person? person}) {
final isEdit = person != null;
final nameCtrl = TextEditingController(text: person?.name ?? '');
final numCtrl = TextEditingController(text: person?.number ?? '');
// Define o valor inicial
String selectedType = person?.type ?? 'Jogador';
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setState) => AlertDialog(
title: Text(isEdit ? "Editar" : "Adicionar"),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
// NOME
TextField(
controller: nameCtrl,
decoration: const InputDecoration(labelText: "Nome"),
textCapitalization: TextCapitalization.sentences,
),
const SizedBox(height: 10),
// FUNÇÃO
DropdownButtonFormField<String>(
value: selectedType,
decoration: const InputDecoration(labelText: "Função"),
items: ["Jogador", "Treinador"]
.map((e) => DropdownMenuItem(value: e, child: Text(e)))
.toList(),
onChanged: (v) {
if (v != null) setState(() => selectedType = v);
},
),
// NÚMERO (Só aparece se for Jogador)
if (selectedType == "Jogador") ...[
const SizedBox(height: 10),
TextField(
controller: numCtrl,
decoration: const InputDecoration(labelText: "Número da Camisola"),
keyboardType: TextInputType.text, // Aceita texto para evitar erros
),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text("Cancelar")
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF00C853)),
onPressed: () async {
print("--- 1. CLICOU EM GUARDAR ---");
// Validação Simples
if (nameCtrl.text.trim().isEmpty) {
print("ERRO: Nome vazio");
return;
}
// Lógica do Número:
// Se for Treinador -> envia NULL
// Se for Jogador e estiver vazio -> envia NULL
// Se tiver texto -> envia o Texto
String? numeroFinal;
if (selectedType == "Treinador") {
numeroFinal = null;
} else {
numeroFinal = numCtrl.text.trim().isEmpty ? null : numCtrl.text.trim();
}
print("--- 2. DADOS A ENVIAR ---");
print("Nome: ${nameCtrl.text}");
print("Tipo: $selectedType");
print("Número: $numeroFinal");
try {
if (isEdit) {
await _supabase.from('members').update({
'name': nameCtrl.text.trim(),
'type': selectedType,
'number': numeroFinal,
}).eq('id', person!.id);
} else {
await _supabase.from('members').insert({
'team_id': teamId, // Verifica se este teamId é válido!
'name': nameCtrl.text.trim(),
'type': selectedType,
'number': numeroFinal,
});
}
print("--- 3. SUCESSO! FECHANDO DIÁLOGO ---");
if (ctx.mounted) Navigator.pop(ctx);
} catch (e) {
print("--- X. ERRO AO GUARDAR ---");
print(e.toString());
// MOSTRA O ERRO NO TELEMÓVEL
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text("Erro: $e"),
backgroundColor: Colors.red,
duration: const Duration(seconds: 4),
),
);
}
}
},
child: const Text("Guardar", style: TextStyle(color: Colors.white)),
)
],
),
),
);
}
}*/

View File

@@ -1,7 +1,7 @@
import 'dart:io';
import 'package:supabase_flutter/supabase_flutter.dart';
class TeamController {
// Instância do cliente Supabase
final _supabase = Supabase.instance.client;
// 1. STREAM (Realtime)
@@ -13,18 +13,39 @@ class TeamController {
.map((data) => List<Map<String, dynamic>>.from(data));
}
// 2. CRIAR
Future<void> createTeam(String name, String season, String? imageUrl) async {
// 2. CRIAR (Agora aceita um File e faz o Upload!)
Future<void> createTeam(String name, String season, File? imageFile) async {
try {
String? uploadedImageUrl;
// Se o utilizador escolheu uma imagem, fazemos o upload primeiro
if (imageFile != null) {
// Criar um nome único para o ficheiro (ex: id do user + timestamp)
final userId = _supabase.auth.currentUser?.id ?? 'default';
final fileName = '${userId}_${DateTime.now().millisecondsSinceEpoch}.png';
final storagePath = 'teams/$fileName';
// Faz o upload para o bucket 'avatars' (podes usar o mesmo ou criar um chamado 'teams_logos')
await _supabase.storage.from('avatars').upload(
storagePath,
imageFile,
fileOptions: const FileOptions(cacheControl: '3600', upsert: true)
);
// Vai buscar o URL público
uploadedImageUrl = _supabase.storage.from('avatars').getPublicUrl(storagePath);
}
// Agora insere a equipa na base de dados (com ou sem URL)
await _supabase.from('teams').insert({
'name': name,
'season': season,
'image_url': imageUrl,
'image_url': uploadedImageUrl ?? '', // Se não houver foto, guarda vazio
'is_favorite': false,
});
print("✅ Equipa guardada no Supabase!");
} catch (e) {
print("❌ Erro ao criar: $e");
print("❌ Erro ao criar equipa: $e");
}
}
@@ -42,7 +63,7 @@ class TeamController {
try {
await _supabase
.from('teams')
.update({'is_favorite': !currentStatus}) // Inverte o valor
.update({'is_favorite': !currentStatus})
.eq('id', teamId);
} catch (e) {
print("❌ Erro ao favoritar: $e");
@@ -52,28 +73,20 @@ class TeamController {
// 5. CONTAR JOGADORES (LEITURA ÚNICA)
Future<int> getPlayerCount(String teamId) async {
try {
final count = await _supabase
.from('members')
.count()
.eq('team_id', teamId);
final count = await _supabase.from('members').count().eq('team_id', teamId);
return count;
} catch (e) {
print("Erro ao contar jogadores: $e");
return 0;
}
}
// 👇 6. A FUNÇÃO QUE RESOLVE O ERRO (EM TEMPO REAL) 👇
Stream<int> getPlayerCountStream(String teamId) {
return _supabase
.from('members')
.stream(primaryKey: ['id'])
.eq('team_id', teamId)
.map((membros) => membros
.where((membro) => membro['type'] == 'Jogador')
.length);
// 6. CONTAR JOGADORES (STREAM EM TEMPO REAL)
Future<List<Map<String, dynamic>>> getTeamsWithStats() async {
final data = await _supabase
.from('teams_with_stats') // Lemos da View que criámos!
.select('*')
.order('name', ascending: true);
return List<Map<String, dynamic>>.from(data);
}
// Mantemos o dispose vazio para não quebrar a chamada na TeamsPage
void dispose() {}
}

View File

@@ -1,38 +1,71 @@
class Game {
final String id;
final String userId;
final String myTeam;
final String opponentTeam;
final String? myTeamLogo; // URL da imagem
final String? opponentTeamLogo; // URL da imagem
final String myScore;
final String myScore;
final String opponentScore;
final String status;
final String season;
final String status;
final DateTime gameDate;
// Novos campos que estão na tua base de dados
final int remainingSeconds;
final int myTimeouts;
final int oppTimeouts;
final int currentQuarter;
final String topPtsName;
final String topAstName;
final String topRbsName;
final String topDefName;
final String mvpName;
Game({
required this.id,
required this.userId,
required this.myTeam,
required this.opponentTeam,
this.myTeamLogo,
this.opponentTeamLogo,
required this.myScore,
required this.opponentScore,
required this.status,
required this.season,
required this.status,
required this.gameDate,
required this.remainingSeconds,
required this.myTimeouts,
required this.oppTimeouts,
required this.currentQuarter,
required this.topPtsName,
required this.topAstName,
required this.topRbsName,
required this.topDefName,
required this.mvpName,
});
// No seu factory, certifique-se de mapear os campos da tabela (ou de um JOIN)
factory Game.fromMap(Map<String, dynamic> map) {
// 👇 A MÁGICA ACONTECE AQUI: Lemos os dados e protegemos os NULLs
factory Game.fromMap(Map<String, dynamic> json) {
return Game(
id: map['id'],
myTeam: map['my_team_name'],
opponentTeam: map['opponent_team_name'],
myTeamLogo: map['my_team_logo'], // Certifique-se que o Supabase retorna isto
opponentTeamLogo: map['opponent_team_logo'],
myScore: map['my_score'].toString(),
opponentScore: map['opponent_score'].toString(),
status: map['status'],
season: map['season'],
id: json['id']?.toString() ?? '',
userId: json['user_id']?.toString() ?? '',
myTeam: json['my_team']?.toString() ?? 'Minha Equipa',
opponentTeam: json['opponent_team']?.toString() ?? 'Adversário',
myScore: (json['my_score'] ?? 0).toString(), // Protege NULL e converte Int4 para String
opponentScore: (json['opponent_score'] ?? 0).toString(),
season: json['season']?.toString() ?? '---',
status: json['status']?.toString() ?? 'Decorrer',
gameDate: json['game_date'] != null ? DateTime.tryParse(json['game_date']) ?? DateTime.now() : DateTime.now(),
// Proteção para os Inteiros (se for NULL, assume 0)
remainingSeconds: json['remaining_seconds'] as int? ?? 600, // 600s = 10 minutos
myTimeouts: json['my_timeouts'] as int? ?? 0,
oppTimeouts: json['opp_timeouts'] as int? ?? 0,
currentQuarter: json['current_quarter'] as int? ?? 1,
// Proteção para os Nomes (se for NULL, assume '---')
topPtsName: json['top_pts_name']?.toString() ?? '---',
topAstName: json['top_ast_name']?.toString() ?? '---',
topRbsName: json['top_rbs_name']?.toString() ?? '---',
topDefName: json['top_def_name']?.toString() ?? '---',
mvpName: json['mvp_name']?.toString() ?? '---',
);
}
}

View File

@@ -3,24 +3,43 @@ class Person {
final String teamId;
final String name;
final String type; // 'Jogador' ou 'Treinador'
final String number;
final String? number; // O número é opcional (Treinadores não têm)
// 👇 A NOVA PROPRIEDADE AQUI!
final String? imageUrl;
Person({
required this.id,
required this.teamId,
required this.name,
required this.type,
required this.number,
this.number,
this.imageUrl, // 👇 ADICIONADO AO CONSTRUTOR
});
// Converte o JSON do Supabase para o objeto Person
// Lê os dados do Supabase e converte para a classe Person
factory Person.fromMap(Map<String, dynamic> map) {
return Person(
id: map['id'] ?? '',
teamId: map['team_id'] ?? '',
name: map['name'] ?? '',
type: map['type'] ?? 'Jogador',
number: map['number']?.toString() ?? '',
id: map['id']?.toString() ?? '',
teamId: map['team_id']?.toString() ?? '',
name: map['name']?.toString() ?? 'Desconhecido',
type: map['type']?.toString() ?? 'Jogador',
number: map['number']?.toString(),
// 👇 AGORA ELE JÁ SABE LER O LINK DA IMAGEM DA TUA BASE DE DADOS!
imageUrl: map['image_url']?.toString(),
);
}
// Prepara os dados para enviar para o Supabase (se necessário)
Map<String, dynamic> toMap() {
return {
'id': id,
'team_id': teamId,
'name': name,
'type': type,
'number': number,
'image_url': imageUrl, // 👇 TAMBÉM GUARDA A IMAGEM
};
}
}

View File

@@ -4,26 +4,33 @@ class Team {
final String season;
final String imageUrl;
final bool isFavorite;
final String createdAt;
final int playerCount; // 👇 NOVA VARIÁVEL AQUI
Team({
required this.id,
required this.name,
required this.season,
required this.imageUrl,
this.isFavorite = false
required this.isFavorite,
required this.createdAt,
this.playerCount = 0, // 👇 VALOR POR DEFEITO
});
// Mapeia o JSON que vem do Supabase (id costuma ser UUID ou String)
factory Team.fromMap(Map<String, dynamic> map) {
return Team(
id: map['id']?.toString() ?? '',
name: map['name'] ?? '',
season: map['season'] ?? '',
imageUrl: map['image_url'] ?? '',
name: map['name']?.toString() ?? 'Sem Nome',
season: map['season']?.toString() ?? '',
imageUrl: map['image_url']?.toString() ?? '',
isFavorite: map['is_favorite'] ?? false,
createdAt: map['created_at']?.toString() ?? '',
// 👇 AGORA ELE LÊ A CONTAGEM DA TUA NOVA VIEW!
playerCount: map['player_count'] != null ? int.tryParse(map['player_count'].toString()) ?? 0 : 0,
);
}
Map<String, dynamic> toMap() {
return {
'name': name,

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,6 @@ import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:playmaker/pages/status_page.dart';
import '../utils/size_extension.dart';
import 'settings_screen.dart';
// 👇 Importa o ficheiro onde meteste o StatCard e o SportGrid
// import 'home_widgets.dart';
class HomeScreen extends StatefulWidget {
@@ -29,6 +28,37 @@ class _HomeScreenState extends State<HomeScreen> {
int _teamDraws = 0;
final _supabase = Supabase.instance.client;
// 👇 NOVA VARIÁVEL PARA GUARDAR A FOTO
String? _avatarUrl;
@override
void initState() {
super.initState();
_loadUserAvatar(); // Vai buscar a foto logo quando a Home abre!
}
// 👇 FUNÇÃO PARA LER A FOTO DA BASE DE DADOS
Future<void> _loadUserAvatar() async {
final userId = _supabase.auth.currentUser?.id;
if (userId == null) return;
try {
final data = await _supabase
.from('profiles')
.select('avatar_url')
.eq('id', userId)
.maybeSingle();
if (mounted && data != null && data['avatar_url'] != null) {
setState(() {
_avatarUrl = data['avatar_url'];
});
}
} catch (e) {
print("Erro ao carregar avatar na Home: $e");
}
}
@override
Widget build(BuildContext context) {
@@ -45,14 +75,31 @@ class _HomeScreenState extends State<HomeScreen> {
title: Text('PlayMaker', style: TextStyle(fontSize: 20 * context.sf)),
backgroundColor: AppTheme.primaryRed,
foregroundColor: Colors.white,
leading: IconButton(
icon: Icon(Icons.person, size: 24 * context.sf),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
);
},
// 👇 AQUI ESTÁ A MÁGICA DA TUA FOTO NA APPBAR 👇
leading: Padding(
padding: EdgeInsets.all(10.0 * context.sf), // Dá um espacinho para não colar aos bordos
child: InkWell(
borderRadius: BorderRadius.circular(100),
onTap: () async {
// O 'await' faz com que a Home espere que tu feches os settings...
await Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
);
// ... e quando voltas, ele recarrega a foto logo!
_loadUserAvatar();
},
child: CircleAvatar(
backgroundColor: Colors.white.withOpacity(0.2), // Fundo suave caso não haja foto
backgroundImage: _avatarUrl != null && _avatarUrl!.isNotEmpty
? NetworkImage(_avatarUrl!)
: null,
child: _avatarUrl == null || _avatarUrl!.isEmpty
? Icon(Icons.person, color: Colors.white, size: 20 * context.sf)
: null, // Só mostra o ícone se não houver foto
),
),
),
),
@@ -196,7 +243,6 @@ class _HomeScreenState extends State<HomeScreen> {
Text('Histórico de Jogos', style: TextStyle(fontSize: 20 * context.sf, fontWeight: FontWeight.bold, color: textColor)),
SizedBox(height: 16 * context.sf),
// 👇 AQUI ESTÁ O NOVO CARTÃO VAZIO PARA QUANDO NÃO HÁ EQUIPA 👇
_selectedTeamName == "Selecionar Equipa"
? Container(
width: double.infinity,

View File

@@ -1,11 +1,13 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:playmaker/classe/theme.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../utils/size_extension.dart';
import 'login.dart';
import 'package:image_picker/image_picker.dart';
// 👇 OBRIGATÓRIO IMPORTAR O MAIN.DART PARA LER A VARIÁVEL "themeNotifier"
import '../main.dart';
import '../utils/size_extension.dart';
import 'login.dart'; // 👇 Necessário para o redirecionamento do logout
import '../main.dart'; // 👇 OBRIGATÓRIO PARA LER A VARIÁVEL "themeNotifier"
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@@ -16,16 +18,116 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> {
// 👇 VARIÁVEIS DE ESTADO PARA FOTO DE PERFIL
File? _localImageFile;
String? _uploadedImageUrl;
bool _isUploadingImage = false;
final supabase = Supabase.instance.client;
@override
void initState() {
super.initState();
_loadUserAvatar();
}
// 👇 LÊ A IMAGEM ATUAL DA BASE DE DADOS (Tabela 'profiles')
void _loadUserAvatar() async {
final userId = supabase.auth.currentUser?.id;
if (userId == null) return;
try {
// ⚠️ NOTA: Ajusta 'profiles' e 'avatar_url' se os nomes na tua BD forem diferentes!
final data = await supabase
.from('profiles')
.select('avatar_url')
.eq('id', userId)
.maybeSingle(); // maybeSingle evita erro se o perfil ainda não existir
if (mounted && data != null && data['avatar_url'] != null) {
setState(() {
_uploadedImageUrl = data['avatar_url'];
});
}
} catch (e) {
print("Erro ao carregar avatar: $e");
}
}
// =========================================================================
// 👇 A MÁGICA DE ESCOLHER E FAZER UPLOAD DA FOTO 👇
// =========================================================================
Future<void> _handleImageChange() async {
final ImagePicker picker = ImagePicker();
// 1. ABRIR GALERIA
final XFile? pickedFile = await picker.pickImage(source: ImageSource.gallery);
if (pickedFile == null || !mounted) return;
try {
// 2. MOSTRAR IMAGEM LOCAL E ATIVAR LOADING
setState(() {
_localImageFile = File(pickedFile.path);
_isUploadingImage = true;
});
final userId = supabase.auth.currentUser?.id;
if (userId == null) throw Exception("Utilizador não autenticado.");
final String storagePath = '$userId/profile_picture.png';
// 3. FAZER UPLOAD (Método direto e seguro!)
await supabase.storage.from('avatars').upload(
storagePath,
_localImageFile!, // Envia o ficheiro File diretamente!
fileOptions: const FileOptions(cacheControl: '3600', upsert: true)
);
// 4. OBTER URL PÚBLICO
final String publicUrl = supabase.storage.from('avatars').getPublicUrl(storagePath);
// 5. ATUALIZAR NA BASE DE DADOS
// ⚠️ NOTA: Garante que a tabela 'profiles' existe e tem o teu user_id
await supabase
.from('profiles')
.upsert({
'id': userId, // Garante que atualiza o perfil certo ou cria um novo
'avatar_url': publicUrl
});
// 6. SUCESSO!
if (mounted) {
setState(() {
_uploadedImageUrl = publicUrl;
_isUploadingImage = false;
_localImageFile = null;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Foto atualizada!"), backgroundColor: Colors.green)
);
}
} catch (e) {
if (mounted) {
setState(() {
_isUploadingImage = false;
_localImageFile = null;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Erro: $e"), backgroundColor: AppTheme.primaryRed)
);
}
}
}
@override
Widget build(BuildContext context) {
// 👇 CORES DINÂMICAS (A MÁGICA DO MODO ESCURO)
final Color primaryRed = AppTheme.primaryRed;
final Color bgColor = Theme.of(context).scaffoldBackgroundColor;
final Color cardColor = Theme.of(context).cardTheme.color ?? Theme.of(context).colorScheme.surface;
final Color textColor = Theme.of(context).colorScheme.onSurface;
final Color textLightColor = textColor.withOpacity(0.6);
// 👇 SABER SE A APP ESTÁ ESCURA OU CLARA NESTE EXATO MOMENTO
bool isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
@@ -37,10 +139,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
centerTitle: true,
title: Text(
"Perfil e Definições",
style: TextStyle(
fontSize: 18 * context.sf,
fontWeight: FontWeight.w600,
),
style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.w600),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
@@ -62,20 +161,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
borderRadius: BorderRadius.circular(16 * context.sf),
border: Border.all(color: Colors.grey.withOpacity(0.1)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 10,
offset: const Offset(0, 4),
),
BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 10, offset: const Offset(0, 4)),
],
),
child: Row(
children: [
CircleAvatar(
radius: 32 * context.sf,
backgroundColor: primaryRed.withOpacity(0.1),
child: Icon(Icons.person, color: primaryRed, size: 32 * context.sf),
),
// 👇 IMAGEM TAPPABLE AQUI 👇
_buildTappableProfileAvatar(context, primaryRed),
SizedBox(width: 16 * context.sf),
Expanded(
child: Column(
@@ -83,19 +175,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [
Text(
"Treinador",
style: TextStyle(
fontSize: 18 * context.sf,
fontWeight: FontWeight.bold,
color: textColor,
),
style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold, color: textColor),
),
SizedBox(height: 4 * context.sf),
Text(
Supabase.instance.client.auth.currentUser?.email ?? "sem@email.com",
style: TextStyle(
color: textLightColor,
fontSize: 14 * context.sf,
),
supabase.auth.currentUser?.email ?? "sem@email.com",
style: TextStyle(color: textLightColor, fontSize: 14 * context.sf),
),
],
),
@@ -113,11 +198,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
padding: EdgeInsets.only(left: 4 * context.sf, bottom: 12 * context.sf),
child: Text(
"Definições",
style: TextStyle(
color: textLightColor,
fontSize: 14 * context.sf,
fontWeight: FontWeight.bold,
),
style: TextStyle(color: textLightColor, fontSize: 14 * context.sf, fontWeight: FontWeight.bold),
),
),
Container(
@@ -126,11 +207,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
borderRadius: BorderRadius.circular(16 * context.sf),
border: Border.all(color: Colors.grey.withOpacity(0.1)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 10,
offset: const Offset(0, 4),
),
BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 10, offset: const Offset(0, 4)),
],
),
child: ListTile(
@@ -148,7 +225,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
value: isDark,
activeColor: primaryRed,
onChanged: (bool value) {
// 👇 CHAMA A VARIÁVEL DO MAIN.DART E ATUALIZA A APP TODA
themeNotifier.value = value ? ThemeMode.dark : ThemeMode.light;
},
),
@@ -164,11 +240,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
padding: EdgeInsets.only(left: 4 * context.sf, bottom: 12 * context.sf),
child: Text(
"Conta",
style: TextStyle(
color: textLightColor,
fontSize: 14 * context.sf,
fontWeight: FontWeight.bold,
),
style: TextStyle(color: textLightColor, fontSize: 14 * context.sf, fontWeight: FontWeight.bold),
),
),
Container(
@@ -177,11 +249,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
borderRadius: BorderRadius.circular(16 * context.sf),
border: Border.all(color: Colors.grey.withOpacity(0.1)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 10,
offset: const Offset(0, 4),
),
BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 10, offset: const Offset(0, 4)),
],
),
child: ListTile(
@@ -189,13 +257,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
leading: Icon(Icons.logout_outlined, color: primaryRed, size: 26 * context.sf),
title: Text(
"Terminar Sessão",
style: TextStyle(
color: primaryRed,
fontWeight: FontWeight.bold,
fontSize: 15 * context.sf,
),
style: TextStyle(color: primaryRed, fontWeight: FontWeight.bold, fontSize: 15 * context.sf),
),
onTap: () => _confirmLogout(context), // 👇 CHAMA O LOGOUT REAL
onTap: () => _confirmLogout(context),
),
),
@@ -207,10 +271,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Center(
child: Text(
"PlayMaker v1.0.0",
style: TextStyle(
color: textLightColor.withOpacity(0.7),
fontSize: 13 * context.sf,
),
style: TextStyle(color: textLightColor.withOpacity(0.7), fontSize: 13 * context.sf),
),
),
SizedBox(height: 20 * context.sf),
@@ -220,28 +281,83 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
// 👇 FUNÇÃO PARA FAZER LOGOUT
// 👇 O WIDGET DA FOTO DE PERFIL (Protegido com GestureDetector)
Widget _buildTappableProfileAvatar(BuildContext context, Color primaryRed) {
return GestureDetector(
onTap: () {
print("CLIQUEI NA FOTO! A abrir galeria..."); // 👇 Vê na consola se isto aparece
_handleImageChange();
},
child: Stack(
alignment: Alignment.center,
children: [
CircleAvatar(
radius: 36 * context.sf,
backgroundColor: primaryRed.withOpacity(0.1),
backgroundImage: _isUploadingImage && _localImageFile != null
? FileImage(_localImageFile!)
: (_uploadedImageUrl != null && _uploadedImageUrl!.isNotEmpty
? NetworkImage(_uploadedImageUrl!)
: null),
child: (_uploadedImageUrl == null && !(_isUploadingImage && _localImageFile != null))
? Icon(Icons.person, color: primaryRed, size: 36 * context.sf)
: null,
),
// ÍCONE DE LÁPIS
Positioned(
bottom: 0,
right: 0,
child: Container(
padding: EdgeInsets.all(6 * context.sf),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(color: Colors.grey.withOpacity(0.2)),
),
child: Icon(Icons.edit_outlined, color: primaryRed, size: 16 * context.sf),
),
),
// LOADING OVERLAY
if (_isUploadingImage)
Positioned.fill(
child: Container(
decoration: BoxDecoration(color: Colors.black.withOpacity(0.4), shape: BoxShape.circle),
child: const Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 3),
),
),
),
],
),
);
}
// 👇 FUNÇÃO DE LOGOUT
void _confirmLogout(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: Theme.of(context).colorScheme.surface,
title: Text("Terminar Sessão", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16 * context.sf)),
title: Text("Terminar Sessão", style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold)),
content: Text("Tens a certeza que queres sair da conta?", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("Cancelar", style: TextStyle(color: Colors.grey))),
TextButton(
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryRed, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
onPressed: () async {
await Supabase.instance.client.auth.signOut();
if (ctx.mounted) {
// Mata a navegação toda para trás e manda para o Login
Navigator.of(ctx).pushAndRemoveUntil(
MaterialPageRoute(builder: (context) => const LoginPage()),
(Route<dynamic> route) => false,
);
}
},
child: Text("Sair", style: TextStyle(color: AppTheme.primaryRed, fontWeight: FontWeight.bold))
child: const Text("Sair", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold))
),
],
),

View File

@@ -1,6 +1,9 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:image_cropper/image_cropper.dart';
import 'package:playmaker/screens/team_stats_page.dart';
import 'package:playmaker/classe/theme.dart'; // 👇 IMPORT DO TEMA
import 'package:playmaker/classe/theme.dart';
import '../controllers/team_controller.dart';
import '../models/team_model.dart';
import '../utils/size_extension.dart';
@@ -162,16 +165,17 @@ class _TeamsPageState extends State<TeamsPage> {
hintStyle: TextStyle(fontSize: 16 * context.sf, color: Colors.grey),
prefixIcon: Icon(Icons.search, color: AppTheme.primaryRed, size: 22 * context.sf),
filled: true,
fillColor: Theme.of(context).colorScheme.surface, // 👇 Adapta-se ao Dark Mode
fillColor: Theme.of(context).colorScheme.surface,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(15 * context.sf), borderSide: BorderSide.none),
),
),
);
}
// 👇 AGORA USA FUTUREBUILDER E É MUITO MAIS RÁPIDO 👇
Widget _buildTeamsList() {
return StreamBuilder<List<Map<String, dynamic>>>(
stream: controller.teamsStream,
return FutureBuilder<List<Map<String, dynamic>>>(
future: controller.getTeamsWithStats(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) return Center(child: CircularProgressIndicator(color: AppTheme.primaryRed));
if (!snapshot.hasData || snapshot.data!.isEmpty) return Center(child: Text("Nenhuma equipa encontrada.", style: TextStyle(fontSize: 16 * context.sf, color: Theme.of(context).colorScheme.onSurface)));
@@ -190,28 +194,45 @@ class _TeamsPageState extends State<TeamsPage> {
else return (b['created_at'] ?? '').toString().compareTo((a['created_at'] ?? '').toString());
});
return ListView.builder(
padding: EdgeInsets.symmetric(horizontal: 16 * context.sf),
itemCount: data.length,
itemBuilder: (context, index) {
final team = Team.fromMap(data[index]);
return GestureDetector(
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (context) => TeamStatsPage(team: team))),
child: TeamCard(
team: team,
controller: controller,
onFavoriteTap: () => controller.toggleFavorite(team.id, team.isFavorite),
sf: context.sf,
),
);
},
return RefreshIndicator(
color: AppTheme.primaryRed,
onRefresh: () async => setState(() {}), // Puxa para baixo para recarregar
child: ListView.builder(
padding: EdgeInsets.symmetric(horizontal: 16 * context.sf),
itemCount: data.length,
itemBuilder: (context, index) {
final team = Team.fromMap(data[index]);
return GestureDetector(
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (context) => TeamStatsPage(team: team))).then((_) => setState(() {})),
child: TeamCard(
team: team,
controller: controller,
onFavoriteTap: () async {
await controller.toggleFavorite(team.id, team.isFavorite);
setState(() {}); // Atualiza a estrela na hora
},
onDelete: () => setState(() {}), // Atualiza a lista quando apaga
sf: context.sf,
),
);
},
),
);
},
);
}
void _showCreateDialog(BuildContext context) {
showDialog(context: context, builder: (context) => CreateTeamDialog(sf: context.sf, onConfirm: (name, season, imageUrl) => controller.createTeam(name, season, imageUrl)));
showDialog(
context: context,
builder: (context) => CreateTeamDialog(
sf: context.sf,
onConfirm: (name, season, imageFile) async {
await controller.createTeam(name, season, imageFile);
setState(() {}); // 👇 Atualiza a lista quando acaba de criar a equipa!
}
),
);
}
}
@@ -220,6 +241,7 @@ class TeamCard extends StatelessWidget {
final Team team;
final TeamController controller;
final VoidCallback onFavoriteTap;
final VoidCallback onDelete; // 👇 Avisa o pai quando é apagado
final double sf;
const TeamCard({
@@ -227,6 +249,7 @@ class TeamCard extends StatelessWidget {
required this.team,
required this.controller,
required this.onFavoriteTap,
required this.onDelete,
required this.sf,
});
@@ -259,7 +282,7 @@ class TeamCard extends StatelessWidget {
: null,
child: (team.imageUrl.isEmpty || !team.imageUrl.startsWith('http'))
? Text(
team.imageUrl.isEmpty ? "🏀" : team.imageUrl,
team.imageUrl.isEmpty ? "🏀" : team.imageUrl,
style: TextStyle(fontSize: 24 * sf),
)
: null,
@@ -272,9 +295,7 @@ class TeamCard extends StatelessWidget {
team.isFavorite ? Icons.star : Icons.star_border,
color: team.isFavorite ? AppTheme.warningAmber : Theme.of(context).colorScheme.onSurface.withOpacity(0.2),
size: 28 * sf,
shadows: [
Shadow(color: Colors.black.withOpacity(team.isFavorite ? 0.3 : 0.1), blurRadius: 4 * sf),
],
shadows: [Shadow(color: Colors.black.withOpacity(team.isFavorite ? 0.3 : 0.1), blurRadius: 4 * sf)],
),
onPressed: onFavoriteTap,
),
@@ -292,21 +313,17 @@ class TeamCard extends StatelessWidget {
children: [
Icon(Icons.groups_outlined, size: 16 * sf, color: Colors.grey),
SizedBox(width: 4 * sf),
StreamBuilder<int>(
stream: controller.getPlayerCountStream(team.id),
initialData: 0,
builder: (context, snapshot) {
final count = snapshot.data ?? 0;
return Text(
"$count Jogs.",
style: TextStyle(
color: count > 0 ? AppTheme.successGreen : AppTheme.warningAmber, // 👇 Usando cores do tema
fontWeight: FontWeight.bold,
fontSize: 13 * sf,
),
);
},
// 👇 ESTATÍSTICA MUITO MAIS LEVE. LÊ O VALOR DIRETAMENTE! 👇
Text(
"${team.playerCount} Jogs.",
style: TextStyle(
color: team.playerCount > 0 ? AppTheme.successGreen : AppTheme.warningAmber,
fontWeight: FontWeight.bold,
fontSize: 13 * sf,
),
),
SizedBox(width: 8 * sf),
Expanded(
child: Text("| ${team.season}", style: TextStyle(color: Colors.grey, fontSize: 13 * sf), overflow: TextOverflow.ellipsis),
@@ -320,7 +337,7 @@ class TeamCard extends StatelessWidget {
IconButton(
tooltip: 'Ver Estatísticas',
icon: Icon(Icons.bar_chart_rounded, color: Colors.blue, size: 24 * sf),
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (context) => TeamStatsPage(team: team))),
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (context) => TeamStatsPage(team: team))).then((_) => onDelete()), // Atualiza se algo mudou
),
IconButton(
tooltip: 'Eliminar Equipa',
@@ -334,23 +351,30 @@ class TeamCard extends StatelessWidget {
);
}
void _confirmDelete(BuildContext context, double sf, Color cardColor, Color textColor) {
void _confirmDelete(BuildContext context, double sf, Color cardColor, Color textColor) {
showDialog(
context: context,
builder: (context) => AlertDialog(
builder: (ctx) => AlertDialog(
backgroundColor: cardColor,
surfaceTintColor: Colors.transparent,
title: Text('Eliminar Equipa?', style: TextStyle(fontSize: 18 * sf, fontWeight: FontWeight.bold, color: textColor)),
content: Text('Tens a certeza que queres eliminar "${team.name}"?', style: TextStyle(fontSize: 14 * sf, color: textColor)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
onPressed: () => Navigator.pop(ctx),
child: Text('Cancelar', style: TextStyle(fontSize: 14 * sf, color: Colors.grey)),
),
TextButton(
onPressed: () {
controller.deleteTeam(team.id);
Navigator.pop(context);
// ⚡ 1. FECHA LOGO O POP-UP!
Navigator.pop(ctx);
// ⚡ 2. AVISA O PAI PARA ESCONDER A EQUIPA DO ECRÃ NA HORA!
onDelete();
// 3. APAGA NO FUNDO (Sem o utilizador ficar à espera)
controller.deleteTeam(team.id).catchError((e) {
if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Erro ao eliminar: $e'), backgroundColor: Colors.red));
});
},
child: Text('Eliminar', style: TextStyle(color: AppTheme.primaryRed, fontSize: 14 * sf)),
),
@@ -360,9 +384,9 @@ class TeamCard extends StatelessWidget {
}
}
// --- DIALOG DE CRIAÇÃO ---
// --- DIALOG DE CRIAÇÃO (COM CROPPER E ESCUDO) ---
class CreateTeamDialog extends StatefulWidget {
final Function(String name, String season, String imageUrl) onConfirm;
final Function(String name, String season, File? imageFile) onConfirm;
final double sf;
const CreateTeamDialog({super.key, required this.onConfirm, required this.sf});
@@ -373,8 +397,48 @@ class CreateTeamDialog extends StatefulWidget {
class _CreateTeamDialogState extends State<CreateTeamDialog> {
final TextEditingController _nameController = TextEditingController();
final TextEditingController _imageController = TextEditingController();
String _selectedSeason = '2024/25';
File? _selectedImage;
bool _isLoading = false;
bool _isPickerActive = false; // 👇 ESCUDO ANTI-DUPLO-CLIQUE
Future<void> _pickImage() async {
if (_isPickerActive) return;
setState(() => _isPickerActive = true);
try {
final ImagePicker picker = ImagePicker();
final XFile? pickedFile = await picker.pickImage(source: ImageSource.gallery);
if (pickedFile != null) {
// 👇 USA O CROPPER QUE CONFIGURASTE PARA AS CARAS
CroppedFile? croppedFile = await ImageCropper().cropImage(
sourcePath: pickedFile.path,
aspectRatio: const CropAspectRatio(ratioX: 1, ratioY: 1),
uiSettings: [
AndroidUiSettings(
toolbarTitle: 'Recortar Logo',
toolbarColor: AppTheme.primaryRed,
toolbarWidgetColor: Colors.white,
initAspectRatio: CropAspectRatioPreset.square,
lockAspectRatio: true,
hideBottomControls: true,
),
IOSUiSettings(title: 'Recortar Logo', aspectRatioLockEnabled: true, resetButtonHidden: true),
],
);
if (croppedFile != null && mounted) {
setState(() {
_selectedImage = File(croppedFile.path);
});
}
}
} finally {
if (mounted) setState(() => _isPickerActive = false);
}
}
@override
Widget build(BuildContext context) {
@@ -386,6 +450,34 @@ class _CreateTeamDialogState extends State<CreateTeamDialog> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: _pickImage,
child: Stack(
children: [
CircleAvatar(
radius: 40 * widget.sf,
backgroundColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.05),
backgroundImage: _selectedImage != null ? FileImage(_selectedImage!) : null,
child: _selectedImage == null
? Icon(Icons.add_photo_alternate_outlined, size: 30 * widget.sf, color: Colors.grey)
: null,
),
if (_selectedImage == null)
Positioned(
bottom: 0, right: 0,
child: Container(
padding: EdgeInsets.all(4 * widget.sf),
decoration: const BoxDecoration(color: AppTheme.primaryRed, shape: BoxShape.circle),
child: Icon(Icons.add, color: Colors.white, size: 16 * widget.sf),
),
),
],
),
),
SizedBox(height: 10 * widget.sf),
Text("Logótipo (Opcional)", style: TextStyle(fontSize: 12 * widget.sf, color: Colors.grey)),
SizedBox(height: 20 * widget.sf),
TextField(controller: _nameController, style: TextStyle(fontSize: 14 * widget.sf, color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration(labelText: 'Nome da Equipa', labelStyle: TextStyle(fontSize: 14 * widget.sf)), textCapitalization: TextCapitalization.words),
SizedBox(height: 15 * widget.sf),
DropdownButtonFormField<String>(
@@ -395,8 +487,6 @@ class _CreateTeamDialogState extends State<CreateTeamDialog> {
items: ['2023/24', '2024/25', '2025/26'].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
onChanged: (val) => setState(() => _selectedSeason = val!),
),
SizedBox(height: 15 * widget.sf),
TextField(controller: _imageController, style: TextStyle(fontSize: 14 * widget.sf, color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration(labelText: 'URL Imagem ou Emoji', labelStyle: TextStyle(fontSize: 14 * widget.sf), hintText: 'Ex: 🏀 ou https://...', hintStyle: TextStyle(fontSize: 14 * widget.sf, color: Colors.grey))),
],
),
),
@@ -404,8 +494,16 @@ class _CreateTeamDialogState extends State<CreateTeamDialog> {
TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancelar', style: TextStyle(fontSize: 14 * widget.sf, color: Colors.grey))),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryRed, padding: EdgeInsets.symmetric(horizontal: 16 * widget.sf, vertical: 10 * widget.sf)),
onPressed: () { if (_nameController.text.trim().isNotEmpty) { widget.onConfirm(_nameController.text.trim(), _selectedSeason, _imageController.text.trim()); Navigator.pop(context); } },
child: Text('Criar', style: TextStyle(color: Colors.white, fontSize: 14 * widget.sf)),
onPressed: _isLoading ? null : () async {
if (_nameController.text.trim().isNotEmpty) {
setState(() => _isLoading = true);
await widget.onConfirm(_nameController.text.trim(), _selectedSeason, _selectedImage);
if (context.mounted) Navigator.pop(context);
}
},
child: _isLoading
? SizedBox(width: 16 * widget.sf, height: 16 * widget.sf, child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: Text('Criar', style: TextStyle(color: Colors.white, fontSize: 14 * widget.sf)),
),
],
);

View File

@@ -1,23 +1,38 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:image_cropper/image_cropper.dart';
import 'package:shimmer/shimmer.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:playmaker/classe/theme.dart'; // 👇 IMPORT DO TEMA!
import 'package:playmaker/classe/theme.dart';
import '../models/team_model.dart';
import '../models/person_model.dart';
import '../utils/size_extension.dart'; // 👇 SUPERPODER SF
import '../utils/size_extension.dart';
// --- CABEÇALHO ---
// ==========================================
// 1. CABEÇALHO (AGORA COM CACHE DE IMAGEM)
// ==========================================
class StatsHeader extends StatelessWidget {
final Team team;
final String? currentImageUrl;
final VoidCallback onEditPhoto;
final bool isUploading;
const StatsHeader({super.key, required this.team});
const StatsHeader({
super.key,
required this.team,
required this.currentImageUrl,
required this.onEditPhoto,
required this.isUploading,
});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.only(top: 50 * context.sf, left: 20 * context.sf, right: 20 * context.sf, bottom: 20 * context.sf),
decoration: BoxDecoration(
color: AppTheme.primaryRed, // 👇 Usando a cor oficial
color: AppTheme.primaryRed,
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(30 * context.sf),
bottomRight: Radius.circular(30 * context.sf)
@@ -26,23 +41,42 @@ class StatsHeader extends StatelessWidget {
child: Row(
children: [
IconButton(
icon: Icon(Icons.arrow_back, color: Colors.white, size: 24 * context.sf),
onPressed: () => Navigator.pop(context),
icon: Icon(Icons.arrow_back, color: Colors.white, size: 24 * context.sf),
onPressed: () => Navigator.pop(context)
),
SizedBox(width: 10 * context.sf),
CircleAvatar(
radius: 24 * context.sf,
backgroundColor: Colors.white24,
backgroundImage: (team.imageUrl.isNotEmpty && team.imageUrl.startsWith('http'))
? NetworkImage(team.imageUrl)
: null,
child: (team.imageUrl.isEmpty || !team.imageUrl.startsWith('http'))
? Text(
team.imageUrl.isEmpty ? "🛡️" : team.imageUrl,
style: TextStyle(fontSize: 20 * context.sf),
GestureDetector(
onTap: onEditPhoto,
child: Stack(
alignment: Alignment.center,
children: [
CircleAvatar(
radius: 28 * context.sf,
backgroundColor: Colors.white24,
backgroundImage: (currentImageUrl != null && currentImageUrl!.isNotEmpty && currentImageUrl!.startsWith('http'))
? CachedNetworkImageProvider(currentImageUrl!)
: null,
child: (currentImageUrl == null || currentImageUrl!.isEmpty || !currentImageUrl!.startsWith('http'))
? Text((currentImageUrl != null && currentImageUrl!.isNotEmpty) ? currentImageUrl! : "🛡️", style: TextStyle(fontSize: 24 * context.sf))
: null,
),
Positioned(
bottom: 0, right: 0,
child: Container(
padding: EdgeInsets.all(4 * context.sf),
decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle),
child: Icon(Icons.edit, color: AppTheme.primaryRed, size: 12 * context.sf),
),
),
if (isUploading)
Container(
width: 56 * context.sf, height: 56 * context.sf,
decoration: const BoxDecoration(color: Colors.black45, shape: BoxShape.circle),
child: const Padding(padding: EdgeInsets.all(12.0), child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)),
)
: null,
],
),
),
SizedBox(width: 15 * context.sf),
@@ -50,15 +84,8 @@ class StatsHeader extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
team.name,
style: TextStyle(color: Colors.white, fontSize: 20 * context.sf, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis
),
Text(
team.season,
style: TextStyle(color: Colors.white70, fontSize: 14 * context.sf)
),
Text(team.name, style: TextStyle(color: Colors.white, fontSize: 20 * context.sf, fontWeight: FontWeight.bold), overflow: TextOverflow.ellipsis),
Text(team.season, style: TextStyle(color: Colors.white70, fontSize: 14 * context.sf)),
],
),
),
@@ -71,41 +98,28 @@ class StatsHeader extends StatelessWidget {
// --- CARD DE RESUMO ---
class StatsSummaryCard extends StatelessWidget {
final int total;
const StatsSummaryCard({super.key, required this.total});
@override
Widget build(BuildContext context) {
// 👇 Adapta-se ao Modo Claro/Escuro
final Color bgColor = Theme.of(context).brightness == Brightness.dark ? const Color(0xFF1E1E1E) : Colors.white;
return Card(
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20 * context.sf)),
child: Container(
padding: EdgeInsets.all(20 * context.sf),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(20 * context.sf),
border: Border.all(color: Colors.grey.withOpacity(0.15)),
),
decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(20 * context.sf), border: Border.all(color: Colors.grey.withOpacity(0.15))),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.groups, color: AppTheme.primaryRed, size: 28 * context.sf), // 👇 Cor do tema
Icon(Icons.groups, color: AppTheme.primaryRed, size: 28 * context.sf),
SizedBox(width: 10 * context.sf),
Text(
"Total de Membros",
style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 16 * context.sf, fontWeight: FontWeight.w600)
),
Text("Total de Membros", style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 16 * context.sf, fontWeight: FontWeight.w600)),
],
),
Text(
"$total",
style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 28 * context.sf, fontWeight: FontWeight.bold)
),
Text("$total", style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 28 * context.sf, fontWeight: FontWeight.bold)),
],
),
),
@@ -116,7 +130,6 @@ class StatsSummaryCard extends StatelessWidget {
// --- TÍTULO DE SECÇÃO ---
class StatsSectionTitle extends StatelessWidget {
final String title;
const StatsSectionTitle({super.key, required this.title});
@override
@@ -124,79 +137,107 @@ class StatsSectionTitle extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface)
),
Text(title, style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface)),
Divider(color: Colors.grey.withOpacity(0.2)),
],
);
}
}
// --- CARD DA PESSOA (JOGADOR/TREINADOR) ---
// --- CARD DA PESSOA (FOTO + NÚMERO + NOME E CACHE) ---
class PersonCard extends StatelessWidget {
final Person person;
final bool isCoach;
final VoidCallback onEdit;
final VoidCallback onDelete;
const PersonCard({
super.key,
required this.person,
required this.isCoach,
required this.onEdit,
required this.onDelete,
});
const PersonCard({super.key, required this.person, required this.isCoach, required this.onEdit, required this.onDelete});
@override
Widget build(BuildContext context) {
// 👇 Adapta as cores do Card ao Modo Escuro e ao Tema
final Color defaultBg = Theme.of(context).brightness == Brightness.dark ? const Color(0xFF1E1E1E) : Colors.white;
final Color coachBg = Theme.of(context).brightness == Brightness.dark ? AppTheme.warningAmber.withOpacity(0.1) : const Color(0xFFFFF9C4);
final String? pImage = person.imageUrl;
return Card(
margin: EdgeInsets.only(top: 12 * context.sf),
elevation: 2,
elevation: 2,
color: isCoach ? coachBg : defaultBg,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * context.sf)),
child: ListTile(
contentPadding: EdgeInsets.symmetric(horizontal: 16 * context.sf, vertical: 4 * context.sf),
leading: isCoach
? CircleAvatar(
radius: 22 * context.sf,
backgroundColor: AppTheme.warningAmber, // 👇 Cor do tema
child: Icon(Icons.person, color: Colors.white, size: 24 * context.sf)
)
: Container(
width: 45 * context.sf,
height: 45 * context.sf,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppTheme.primaryRed.withOpacity(0.1), // 👇 Cor do tema
borderRadius: BorderRadius.circular(10 * context.sf)
),
child: Text(
person.number ?? "J",
style: TextStyle(color: AppTheme.primaryRed, fontWeight: FontWeight.bold, fontSize: 16 * context.sf)
),
),
title: Text(
person.name,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16 * context.sf, color: Theme.of(context).colorScheme.onSurface)
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16 * context.sf, vertical: 12 * context.sf),
child: Row(
children: [
IconButton(
icon: Icon(Icons.edit_outlined, color: Colors.blue, size: 22 * context.sf),
onPressed: onEdit,
CircleAvatar(
radius: 22 * context.sf,
backgroundColor: isCoach ? AppTheme.warningAmber : AppTheme.primaryRed.withOpacity(0.1),
backgroundImage: (pImage != null && pImage.isNotEmpty) ? CachedNetworkImageProvider(pImage) : null,
child: (pImage == null || pImage.isEmpty) ? Icon(Icons.person, color: isCoach ? Colors.white : AppTheme.primaryRed, size: 24 * context.sf) : null,
),
IconButton(
icon: Icon(Icons.delete_outline, color: AppTheme.primaryRed, size: 22 * context.sf), // 👇 Cor do tema
onPressed: onDelete,
SizedBox(width: 12 * context.sf),
Expanded(
child: Row(
children: [
if (!isCoach && person.number != null && person.number!.isNotEmpty) ...[
Container(
padding: EdgeInsets.symmetric(horizontal: 8 * context.sf, vertical: 4 * context.sf),
decoration: BoxDecoration(color: AppTheme.primaryRed.withOpacity(0.1), borderRadius: BorderRadius.circular(6 * context.sf)),
child: Text(person.number!, style: TextStyle(color: AppTheme.primaryRed, fontWeight: FontWeight.bold, fontSize: 14 * context.sf)),
),
SizedBox(width: 10 * context.sf),
],
Expanded(
child: Text(person.name, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16 * context.sf, color: Theme.of(context).colorScheme.onSurface), overflow: TextOverflow.ellipsis)
),
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: Icon(Icons.edit_outlined, color: Colors.blue, size: 22 * context.sf), onPressed: onEdit, padding: EdgeInsets.zero, constraints: const BoxConstraints()),
SizedBox(width: 16 * context.sf),
IconButton(icon: Icon(Icons.delete_outline, color: AppTheme.primaryRed, size: 22 * context.sf), onPressed: onDelete, padding: EdgeInsets.zero, constraints: const BoxConstraints()),
],
),
],
),
),
);
}
}
// ==========================================
// WIDGET NOVO: SKELETON LOADING (SHIMMER)
// ==========================================
class SkeletonLoadingStats extends StatelessWidget {
const SkeletonLoadingStats({super.key});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final baseColor = isDark ? Colors.grey[800]! : Colors.grey[300]!;
final highlightColor = isDark ? Colors.grey[700]! : Colors.grey[100]!;
return Shimmer.fromColors(
baseColor: baseColor,
highlightColor: highlightColor,
child: SingleChildScrollView(
padding: EdgeInsets.all(16.0 * context.sf),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(height: 80 * context.sf, width: double.infinity, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20 * context.sf))),
SizedBox(height: 30 * context.sf),
Container(height: 20 * context.sf, width: 150 * context.sf, color: Colors.white),
SizedBox(height: 10 * context.sf),
for (int i = 0; i < 3; i++) ...[
Container(
height: 60 * context.sf, width: double.infinity,
margin: EdgeInsets.only(top: 12 * context.sf),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(15 * context.sf)),
),
]
],
),
),
@@ -207,10 +248,8 @@ class PersonCard extends StatelessWidget {
// ==========================================
// 2. PÁGINA PRINCIPAL
// ==========================================
class TeamStatsPage extends StatefulWidget {
final Team team;
const TeamStatsPage({super.key, required this.team});
@override
@@ -219,31 +258,79 @@ class TeamStatsPage extends StatefulWidget {
class _TeamStatsPageState extends State<TeamStatsPage> {
final StatsController _controller = StatsController();
late String _teamImageUrl;
bool _isUploadingTeamPhoto = false;
bool _isPickerActive = false;
@override
void initState() {
super.initState();
_teamImageUrl = widget.team.imageUrl;
}
Future<void> _updateTeamPhoto() async {
if (_isPickerActive) return;
setState(() => _isPickerActive = true);
try {
final File? croppedFile = await _controller.pickAndCropImage(context);
if (croppedFile == null) return;
setState(() => _isUploadingTeamPhoto = true);
final fileName = 'team_${widget.team.id}_${DateTime.now().millisecondsSinceEpoch}.png';
final supabase = Supabase.instance.client;
await supabase.storage.from('avatars').upload(fileName, croppedFile, fileOptions: const FileOptions(upsert: true));
final publicUrl = supabase.storage.from('avatars').getPublicUrl(fileName);
await supabase.from('teams').update({'image_url': publicUrl}).eq('id', widget.team.id);
if (_teamImageUrl.isNotEmpty && _teamImageUrl.startsWith('http')) {
final oldPath = _controller.extractPathFromUrl(_teamImageUrl, 'avatars');
if (oldPath != null) await supabase.storage.from('avatars').remove([oldPath]);
}
if (mounted) setState(() => _teamImageUrl = publicUrl);
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Erro: $e"), backgroundColor: AppTheme.primaryRed));
} finally {
if (mounted) {
setState(() {
_isUploadingTeamPhoto = false;
_isPickerActive = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor, // 👇 Adapta-se ao Modo Escuro
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: Column(
children: [
StatsHeader(team: widget.team),
StatsHeader(team: widget.team, currentImageUrl: _teamImageUrl, onEditPhoto: _updateTeamPhoto, isUploading: _isUploadingTeamPhoto),
Expanded(
child: StreamBuilder<List<Person>>(
stream: _controller.getMembers(widget.team.id),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(child: CircularProgressIndicator(color: AppTheme.primaryRed));
return const SkeletonLoadingStats();
}
if (snapshot.hasError) {
return Center(child: Text("Erro ao carregar: ${snapshot.error}", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)));
}
if (snapshot.hasError) return Center(child: Text("Erro ao carregar: ${snapshot.error}", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)));
final members = snapshot.data ?? [];
final coaches = members.where((m) => m.type == 'Treinador').toList();
final players = members.where((m) => m.type == 'Jogador').toList();
final coaches = members.where((m) => m.type == 'Treinador').toList()..sort((a, b) => a.name.compareTo(b.name));
final players = members.where((m) => m.type == 'Jogador').toList()..sort((a, b) {
int numA = int.tryParse(a.number ?? '999') ?? 999;
int numB = int.tryParse(b.number ?? '999') ?? 999;
return numA.compareTo(numB);
});
return RefreshIndicator(
color: AppTheme.primaryRed,
@@ -257,32 +344,17 @@ class _TeamStatsPageState extends State<TeamStatsPage> {
StatsSummaryCard(total: members.length),
SizedBox(height: 30 * context.sf),
// TREINADORES
if (coaches.isNotEmpty) ...[
const StatsSectionTitle(title: "Treinadores"),
...coaches.map((c) => PersonCard(
person: c,
isCoach: true,
onEdit: () => _controller.showEditPersonDialog(context, widget.team.id, c),
onDelete: () => _confirmDelete(context, c),
)),
...coaches.map((c) => PersonCard(person: c, isCoach: true, onEdit: () => _controller.showEditPersonDialog(context, widget.team.id, c), onDelete: () => _confirmDelete(context, c))),
SizedBox(height: 30 * context.sf),
],
// JOGADORES
const StatsSectionTitle(title: "Jogadores"),
if (players.isEmpty)
Padding(
padding: EdgeInsets.only(top: 20 * context.sf),
child: Text("Nenhum jogador nesta equipa.", style: TextStyle(color: Colors.grey, fontSize: 16 * context.sf)),
)
Padding(padding: EdgeInsets.only(top: 20 * context.sf), child: Text("Nenhum jogador nesta equipa.", style: TextStyle(color: Colors.grey, fontSize: 16 * context.sf)))
else
...players.map((p) => PersonCard(
person: p,
isCoach: false,
onEdit: () => _controller.showEditPersonDialog(context, widget.team.id, p),
onDelete: () => _confirmDelete(context, p),
)),
...players.map((p) => PersonCard(person: p, isCoach: false, onEdit: () => _controller.showEditPersonDialog(context, widget.team.id, p), onDelete: () => _confirmDelete(context, p))),
SizedBox(height: 80 * context.sf),
],
),
@@ -296,13 +368,13 @@ class _TeamStatsPageState extends State<TeamStatsPage> {
floatingActionButton: FloatingActionButton(
heroTag: 'fab_team_${widget.team.id}',
onPressed: () => _controller.showAddPersonDialog(context, widget.team.id),
backgroundColor: AppTheme.successGreen, // 👇 Cor de sucesso do tema
backgroundColor: AppTheme.successGreen,
child: Icon(Icons.add, color: Colors.white, size: 24 * context.sf),
),
);
}
void _confirmDelete(BuildContext context, Person person) {
void _confirmDelete(BuildContext context, Person person) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
@@ -310,53 +382,91 @@ class _TeamStatsPageState extends State<TeamStatsPage> {
title: Text("Eliminar Membro?", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)),
content: Text("Tens a certeza que queres remover ${person.name}?", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("Cancelar", style: TextStyle(color: Colors.grey))),
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text("Cancelar", style: TextStyle(color: Colors.grey))
),
TextButton(
onPressed: () async {
await _controller.deletePerson(person.id);
if (ctx.mounted) Navigator.pop(ctx);
onPressed: () {
// ⚡ FECHA LOGO O POP-UP!
Navigator.pop(ctx);
// Mostra um aviso rápido para o utilizador saber que a app está a trabalhar
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("A remover ${person.name}..."), duration: const Duration(seconds: 1)));
// APAGA NO FUNDO
_controller.deletePerson(person).catchError((e) {
if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Erro: $e"), backgroundColor: AppTheme.primaryRed));
});
},
child: Text("Eliminar", style: TextStyle(color: AppTheme.primaryRed)), // 👇 Cor oficial
child: const Text("Eliminar", style: TextStyle(color: AppTheme.primaryRed)),
),
],
),
);
}
}
}
// ==========================================
// 3. CONTROLLER
// ==========================================
class StatsController {
final _supabase = Supabase.instance.client;
Stream<List<Person>> getMembers(String teamId) {
return _supabase
.from('members')
.stream(primaryKey: ['id'])
.eq('team_id', teamId)
.order('name', ascending: true)
.map((data) => data.map((json) => Person.fromMap(json)).toList());
return _supabase.from('members').stream(primaryKey: ['id']).eq('team_id', teamId).map((data) => data.map((json) => Person.fromMap(json)).toList());
}
Future<void> deletePerson(String personId) async {
try {
await _supabase.from('members').delete().eq('id', personId);
} catch (e) {
debugPrint("Erro ao eliminar: $e");
String? extractPathFromUrl(String url, String bucket) {
if (url.isEmpty) return null;
final parts = url.split('/$bucket/');
if (parts.length > 1) return parts.last;
return null;
}
Future<void> deletePerson(Person person) async {
try {
await _supabase.from('members').delete().eq('id', person.id);
if (person.imageUrl != null && person.imageUrl!.isNotEmpty) {
final path = extractPathFromUrl(person.imageUrl!, 'avatars');
if (path != null) await _supabase.storage.from('avatars').remove([path]);
}
} catch (e) {
debugPrint("Erro ao eliminar: $e");
}
}
void showAddPersonDialog(BuildContext context, String teamId) {
_showForm(context, teamId: teamId);
}
void showAddPersonDialog(BuildContext context, String teamId) { _showForm(context, teamId: teamId); }
void showEditPersonDialog(BuildContext context, String teamId, Person person) { _showForm(context, teamId: teamId, person: person); }
void showEditPersonDialog(BuildContext context, String teamId, Person person) {
_showForm(context, teamId: teamId, person: person);
Future<File?> pickAndCropImage(BuildContext context) async {
final picker = ImagePicker();
final pickedFile = await picker.pickImage(source: ImageSource.gallery);
if (pickedFile == null) return null;
CroppedFile? croppedFile = await ImageCropper().cropImage(
sourcePath: pickedFile.path,
aspectRatio: const CropAspectRatio(ratioX: 1, ratioY: 1),
uiSettings: [
AndroidUiSettings(
toolbarTitle: 'Recortar Foto',
toolbarColor: AppTheme.primaryRed,
toolbarWidgetColor: Colors.white,
initAspectRatio: CropAspectRatioPreset.square,
lockAspectRatio: true,
hideBottomControls: true,
),
IOSUiSettings(
title: 'Recortar Foto',
aspectRatioLockEnabled: true,
resetButtonHidden: true,
),
],
);
if (croppedFile != null) {
return File(croppedFile.path);
}
return null;
}
void _showForm(BuildContext context, {required String teamId, Person? person}) {
@@ -364,6 +474,15 @@ class StatsController {
final nameCtrl = TextEditingController(text: person?.name ?? '');
final numCtrl = TextEditingController(text: person?.number ?? '');
String selectedType = person?.type ?? 'Jogador';
File? selectedImage;
bool isUploading = false;
bool isPickerActive = false;
String? currentImageUrl = isEdit ? person.imageUrl : null;
// 👇 VARIÁVEIS PARA O TEXTO PEQUENO VERMELHO (ESTILO LOGIN) 👇
String? nameError;
String? numError;
showDialog(
context: context,
@@ -371,18 +490,58 @@ class StatsController {
builder: (ctx, setState) => AlertDialog(
backgroundColor: Theme.of(context).colorScheme.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * context.sf)),
title: Text(
isEdit ? "Editar Membro" : "Novo Membro",
style: TextStyle(color: Theme.of(context).colorScheme.onSurface)
),
title: Text(isEdit ? "Editar Membro" : "Novo Membro", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () async {
if (isPickerActive) return;
setState(() => isPickerActive = true);
try {
final File? croppedFile = await pickAndCropImage(context);
if (croppedFile != null) {
setState(() => selectedImage = croppedFile);
}
} finally {
setState(() => isPickerActive = false);
}
},
child: Stack(
alignment: Alignment.center,
children: [
CircleAvatar(
radius: 40 * context.sf,
backgroundColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.05),
backgroundImage: selectedImage != null
? FileImage(selectedImage!)
: (currentImageUrl != null && currentImageUrl!.isNotEmpty ? CachedNetworkImageProvider(currentImageUrl!) : null) as ImageProvider?,
child: (selectedImage == null && (currentImageUrl == null || currentImageUrl!.isEmpty))
? Icon(Icons.add_a_photo, size: 30 * context.sf, color: Colors.grey)
: null,
),
Positioned(
bottom: 0, right: 0,
child: Container(
padding: EdgeInsets.all(6 * context.sf),
decoration: BoxDecoration(color: AppTheme.primaryRed, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2)),
child: Icon(Icons.edit, color: Colors.white, size: 14 * context.sf),
),
),
],
),
),
SizedBox(height: 20 * context.sf),
TextField(
controller: nameCtrl,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: const InputDecoration(labelText: "Nome Completo"),
decoration: InputDecoration(
labelText: "Nome Completo",
errorText: nameError, // 👇 ERRO PEQUENO AQUI
),
textCapitalization: TextCapitalization.words,
),
SizedBox(height: 15 * context.sf),
@@ -391,19 +550,18 @@ class StatsController {
dropdownColor: Theme.of(context).colorScheme.surface,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 16 * context.sf),
decoration: const InputDecoration(labelText: "Função"),
items: ["Jogador", "Treinador"]
.map((e) => DropdownMenuItem(value: e, child: Text(e)))
.toList(),
onChanged: (v) {
if (v != null) setState(() => selectedType = v);
},
items: ["Jogador", "Treinador"].map((e) => DropdownMenuItem(value: e, child: Text(e))).toList(),
onChanged: (v) { if (v != null) setState(() => selectedType = v); },
),
if (selectedType == "Jogador") ...[
SizedBox(height: 15 * context.sf),
TextField(
controller: numCtrl,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: const InputDecoration(labelText: "Número da Camisola"),
decoration: InputDecoration(
labelText: "Número da Camisola",
errorText: numError, // 👇 ERRO PEQUENO AQUI
),
keyboardType: TextInputType.number,
),
]
@@ -411,29 +569,46 @@ class StatsController {
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text("Cancelar", style: TextStyle(color: Colors.grey))
),
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("Cancelar", style: TextStyle(color: Colors.grey))),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.successGreen, // 👇 Cor verde do tema
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8 * context.sf))
),
onPressed: () async {
if (nameCtrl.text.trim().isEmpty) return;
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.successGreen, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8 * context.sf))),
onPressed: isUploading ? null : () async {
// Limpa os erros antes de tentar de novo
setState(() {
nameError = null;
numError = null;
});
String? numeroFinal = (selectedType == "Treinador")
? null
: (numCtrl.text.trim().isEmpty ? null : numCtrl.text.trim());
if (nameCtrl.text.trim().isEmpty) {
setState(() => nameError = "O nome é obrigatório");
return;
}
setState(() => isUploading = true);
String? numeroFinal = (selectedType == "Treinador") ? null : (numCtrl.text.trim().isEmpty ? null : numCtrl.text.trim());
try {
String? finalImageUrl = currentImageUrl;
if (selectedImage != null) {
final fileName = 'person_${DateTime.now().millisecondsSinceEpoch}.png';
await _supabase.storage.from('avatars').upload(fileName, selectedImage!, fileOptions: const FileOptions(upsert: true));
finalImageUrl = _supabase.storage.from('avatars').getPublicUrl(fileName);
if (currentImageUrl != null && currentImageUrl!.isNotEmpty) {
final oldPath = extractPathFromUrl(currentImageUrl!, 'avatars');
if (oldPath != null) await _supabase.storage.from('avatars').remove([oldPath]);
}
}
if (isEdit) {
await _supabase.from('members').update({
'name': nameCtrl.text.trim(),
'type': selectedType,
'number': numeroFinal,
'image_url': finalImageUrl,
}).eq('id', person.id);
} else {
await _supabase.from('members').insert({
@@ -441,23 +616,25 @@ class StatsController {
'name': nameCtrl.text.trim(),
'type': selectedType,
'number': numeroFinal,
'image_url': finalImageUrl,
});
}
if (ctx.mounted) Navigator.pop(ctx);
} catch (e) {
debugPrint("Erro Supabase: $e");
if (ctx.mounted) {
String errorMsg = "Erro ao guardar: $e";
if (e.toString().contains('unique')) {
errorMsg = "Já existe um membro com este numero na equipa.";
// 👇 AGORA OS ERROS VÃO DIRETOS PARA OS CAMPOS (ESTILO LOGIN) 👇
setState(() {
isUploading = false;
if (e is PostgrestException && e.code == '23505') {
numError = "Este número já está em uso!";
} else if (e.toString().toLowerCase().contains('unique') || e.toString().toLowerCase().contains('duplicate')) {
numError = "Este número já está em uso!";
} else {
nameError = "Erro ao guardar. Tente novamente.";
}
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(errorMsg), backgroundColor: AppTheme.primaryRed) // 👇 Cor oficial para erro
);
}
});
}
},
child: const Text("Guardar"),
child: isUploading ? SizedBox(width: 16 * context.sf, height: 16 * context.sf, child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : const Text("Guardar"),
)
],
),

View File

@@ -1,15 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:playmaker/controllers/placar_controller.dart';
import 'package:playmaker/utils/size_extension.dart';
import 'package:playmaker/classe/theme.dart';
import 'dart:math' as math;
import 'package:playmaker/classe/theme.dart';
import 'package:playmaker/controllers/placar_controller.dart';
import 'package:playmaker/zone_map_dialog.dart';
import 'dart:math' as math;
// ============================================================================
// 1. PLACAR SUPERIOR (CRONÓMETRO E RESULTADO)
// ============================================================================
// ============================================================================
// 1. PLACAR SUPERIOR (COM CRONÓMETRO DE ALTA PERFORMANCE)
// ============================================================================
class TopScoreboard extends StatelessWidget {
final PlacarController controller;
final double sf;
@@ -19,60 +19,86 @@ class TopScoreboard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.symmetric(vertical: 10 * sf, horizontal: 35 * sf),
padding: EdgeInsets.symmetric(vertical: 6 * sf, horizontal: 20 * sf),
decoration: BoxDecoration(
color: AppTheme.placarDarkSurface,
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(22 * sf),
bottomRight: Radius.circular(22 * sf)
),
border: Border.all(color: Colors.white, width: 2.5 * sf),
borderRadius: BorderRadius.only(bottomLeft: Radius.circular(22 * sf), bottomRight: Radius.circular(22 * sf)),
border: Border.all(color: Colors.white, width: 2.0 * sf),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildTeamSection(controller.myTeam, controller.myScore, controller.myFouls, controller.myTimeoutsUsed, AppTheme.myTeamBlue, false, sf),
SizedBox(width: 30 * sf),
SizedBox(width: 20 * sf),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: EdgeInsets.symmetric(horizontal: 18 * sf, vertical: 5 * sf),
decoration: BoxDecoration(
color: AppTheme.placarTimerBg,
borderRadius: BorderRadius.circular(9 * sf)
),
child: Text(
controller.formatTime(),
style: TextStyle(color: Colors.white, fontSize: 28 * sf, fontWeight: FontWeight.w900, fontFamily: 'monospace', letterSpacing: 2 * sf)
padding: EdgeInsets.symmetric(horizontal: 14 * sf, vertical: 4 * sf),
decoration: BoxDecoration(color: AppTheme.placarTimerBg, borderRadius: BorderRadius.circular(9 * sf)),
// 👇 AQUI ESTÁ A MAGIA DE PERFORMANCE! Só este texto se atualiza a cada segundo! 👇
child: ValueListenableBuilder<Duration>(
valueListenable: controller.durationNotifier,
builder: (context, duration, child) {
String formatTime = "${duration.inMinutes.toString().padLeft(2, '0')}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}";
return Text(formatTime, style: TextStyle(color: Colors.white, fontSize: 24 * sf, fontWeight: FontWeight.w900, fontFamily: 'monospace', letterSpacing: 1.5 * sf));
}
),
),
SizedBox(height: 5 * sf),
Text(
"PERÍODO ${controller.currentQuarter}",
style: TextStyle(color: AppTheme.warningAmber, fontSize: 14 * sf, fontWeight: FontWeight.w900)
),
SizedBox(height: 4 * sf),
Text("PERÍODO ${controller.currentQuarter}", style: TextStyle(color: AppTheme.warningAmber, fontSize: 12 * sf, fontWeight: FontWeight.w900)),
],
),
SizedBox(width: 30 * sf),
SizedBox(width: 20 * sf),
_buildTeamSection(controller.opponentTeam, controller.opponentScore, controller.opponentFouls, controller.opponentTimeoutsUsed, AppTheme.oppTeamRed, true, sf),
],
),
);
}
Widget _buildTeamSection(String name, int score, int fouls, int timeouts, Color color, bool isOpp, double sf) {
int displayFouls = fouls > 5 ? 5 : fouls;
final timeoutIndicators = Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (index) => Container(
margin: EdgeInsets.symmetric(horizontal: 2.5 * sf), width: 10 * sf, height: 10 * sf,
decoration: BoxDecoration(shape: BoxShape.circle, color: index < timeouts ? AppTheme.warningAmber : Colors.grey.shade600, border: Border.all(color: Colors.white54, width: 1.0 * sf)),
)),
);
List<Widget> content = [
Column(children: [_scoreBox(score, color, sf), SizedBox(height: 5 * sf), timeoutIndicators]),
SizedBox(width: 12 * sf),
Column(
crossAxisAlignment: isOpp ? CrossAxisAlignment.start : CrossAxisAlignment.end,
children: [
Text(name.toUpperCase(), style: TextStyle(color: Colors.white, fontSize: 16 * sf, fontWeight: FontWeight.w900, letterSpacing: 1.0 * sf)),
SizedBox(height: 3 * sf),
Text("FALTAS: $displayFouls", style: TextStyle(color: displayFouls >= 5 ? AppTheme.actionMiss : AppTheme.warningAmber, fontSize: 11 * sf, fontWeight: FontWeight.bold)),
],
)
];
return Row(crossAxisAlignment: CrossAxisAlignment.center, children: isOpp ? content : content.reversed.toList());
}
Widget _scoreBox(int score, Color color, double sf) => Container(
width: 45 * sf, height: 35 * sf, alignment: Alignment.center,
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(6 * sf)),
child: Text(score.toString(), style: TextStyle(color: Colors.white, fontSize: 20 * sf, fontWeight: FontWeight.w900)),
);
}
Widget _buildTeamSection(String name, int score, int fouls, int timeouts, Color color, bool isOpp, double sf) {
int displayFouls = fouls > 5 ? 5 : fouls;
final timeoutIndicators = Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (index) => Container(
margin: EdgeInsets.symmetric(horizontal: 3.5 * sf),
width: 12 * sf, height: 12 * sf,
margin: EdgeInsets.symmetric(horizontal: 2.5 * sf),
width: 10 * sf, height: 10 * sf,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: index < timeouts ? AppTheme.warningAmber : Colors.grey.shade600,
border: Border.all(color: Colors.white54, width: 1.5 * sf)
border: Border.all(color: Colors.white54, width: 1.0 * sf)
),
)),
);
@@ -81,40 +107,38 @@ class TopScoreboard extends StatelessWidget {
Column(
children: [
_scoreBox(score, color, sf),
SizedBox(height: 7 * sf),
SizedBox(height: 5 * sf),
timeoutIndicators
]
),
SizedBox(width: 18 * sf),
SizedBox(width: 12 * sf),
Column(
crossAxisAlignment: isOpp ? CrossAxisAlignment.start : CrossAxisAlignment.end,
children: [
Text(
name.toUpperCase(),
style: TextStyle(color: Colors.white, fontSize: 20 * sf, fontWeight: FontWeight.w900, letterSpacing: 1.2 * sf)
style: TextStyle(color: Colors.white, fontSize: 16 * sf, fontWeight: FontWeight.w900, letterSpacing: 1.0 * sf)
),
SizedBox(height: 5 * sf),
SizedBox(height: 3 * sf),
Text(
"FALTAS: $displayFouls",
style: TextStyle(color: displayFouls >= 5 ? AppTheme.actionMiss : AppTheme.warningAmber, fontSize: 13 * sf, fontWeight: FontWeight.bold)
style: TextStyle(color: displayFouls >= 5 ? AppTheme.actionMiss : AppTheme.warningAmber, fontSize: 11 * sf, fontWeight: FontWeight.bold)
),
],
)
),
];
return Row(crossAxisAlignment: CrossAxisAlignment.center, children: isOpp ? content : content.reversed.toList());
}
Widget _scoreBox(int score, Color color, double sf) => Container(
width: 58 * sf, height: 45 * sf,
width: 45 * sf, height: 35 * sf,
alignment: Alignment.center,
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(7 * sf)),
child: Text(score.toString(), style: TextStyle(color: Colors.white, fontSize: 26 * sf, fontWeight: FontWeight.w900)),
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(6 * sf)),
child: Text(score.toString(), style: TextStyle(color: Colors.white, fontSize: 20 * sf, fontWeight: FontWeight.w900)),
);
}
// ============================================================================
// 2. BANCO DE SUPLENTES (DRAG & DROP)
// 2. BANCO DE SUPLENTES (COM TRADUTOR DE NOME)
// ============================================================================
class BenchPlayersList extends StatelessWidget {
final PlacarController controller;
@@ -131,51 +155,45 @@ class BenchPlayersList extends StatelessWidget {
return Column(
mainAxisSize: MainAxisSize.min,
children: bench.map((playerName) {
final num = controller.playerNumbers[playerName] ?? "0";
final int fouls = controller.playerStats[playerName]?["fls"] ?? 0;
children: bench.map((playerId) {
final playerName = controller.playerNames[playerId] ?? "Erro";
final num = controller.playerNumbers[playerId] ?? "0";
final int fouls = controller.playerStats[playerId]?["fls"] ?? 0;
final bool isFouledOut = fouls >= 5;
String shortName = playerName.length > 8 ? "${playerName.substring(0, 7)}." : playerName;
Widget avatarUI = Container(
margin: EdgeInsets.only(bottom: 7 * sf),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 1.8 * sf),
boxShadow: [BoxShadow(color: Colors.black45, blurRadius: 5 * sf, offset: Offset(0, 2.5 * sf))]
),
child: CircleAvatar(
radius: 22 * sf,
backgroundColor: isFouledOut ? Colors.grey.shade800 : teamColor,
child: Text(
num,
style: TextStyle(
color: isFouledOut ? Colors.red.shade300 : Colors.white,
fontSize: 16 * sf,
fontWeight: FontWeight.bold,
decoration: isFouledOut ? TextDecoration.lineThrough : TextDecoration.none
)
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 1.5 * sf),
boxShadow: [BoxShadow(color: Colors.black45, blurRadius: 4 * sf, offset: Offset(0, 2.0 * sf))]
),
child: CircleAvatar(
radius: 18 * sf,
backgroundColor: isFouledOut ? Colors.grey.shade800 : teamColor,
child: Text(num, style: TextStyle(color: isFouledOut ? Colors.red.shade300 : Colors.white, fontSize: 14 * sf, fontWeight: FontWeight.bold, decoration: isFouledOut ? TextDecoration.lineThrough : TextDecoration.none)),
),
),
SizedBox(height: 4 * sf),
Text(shortName, style: TextStyle(color: Colors.white, fontSize: 10 * sf, fontWeight: FontWeight.bold), overflow: TextOverflow.ellipsis),
],
),
);
if (isFouledOut) {
return GestureDetector(
onTap: () => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('🛑 $playerName não pode voltar (Expulso).'), backgroundColor: AppTheme.actionMiss)),
child: avatarUI
);
return GestureDetector(onTap: () => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('🛑 $playerName não pode voltar (Expulso).'), backgroundColor: AppTheme.actionMiss)), child: avatarUI);
}
return Draggable<String>(
data: "$prefix$playerName",
feedback: Material(
color: Colors.transparent,
child: CircleAvatar(
radius: 28 * sf,
backgroundColor: teamColor,
child: Text(num, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 18 * sf))
)
),
childWhenDragging: Opacity(opacity: 0.5, child: SizedBox(width: 45 * sf, height: 45 * sf)),
data: "$prefix$playerId",
feedback: Material(color: Colors.transparent, child: CircleAvatar(radius: 22 * sf, backgroundColor: teamColor, child: Text(num, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16 * sf)))),
childWhenDragging: Opacity(opacity: 0.5, child: SizedBox(width: 36 * sf, height: 36 * sf)),
child: avatarUI,
);
}).toList(),
@@ -184,34 +202,36 @@ class BenchPlayersList extends StatelessWidget {
}
// ============================================================================
// 3. CARTÃO DO JOGADOR NO CAMPO (TARGET DE FALTAS/PONTOS/SUBSTITUIÇÕES)
// 3. CARTÃO DO JOGADOR NO CAMPO (COM TRADUTOR DE NOME)
// ============================================================================
class PlayerCourtCard extends StatelessWidget {
final PlacarController controller;
final String name;
final String playerId;
final bool isOpponent;
final double sf;
const PlayerCourtCard({super.key, required this.controller, required this.name, required this.isOpponent, required this.sf});
const PlayerCourtCard({super.key, required this.controller, required this.playerId, required this.isOpponent, required this.sf});
@override
Widget build(BuildContext context) {
final teamColor = isOpponent ? AppTheme.oppTeamRed : AppTheme.myTeamBlue;
final stats = controller.playerStats[name]!;
final number = controller.playerNumbers[name]!;
final realName = controller.playerNames[playerId] ?? "Erro";
final stats = controller.playerStats[playerId]!;
final number = controller.playerNumbers[playerId]!;
final prefix = isOpponent ? "player_opp_" : "player_my_";
return Draggable<String>(
data: "$prefix$name",
data: "$prefix$playerId",
feedback: Material(
color: Colors.transparent,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(color: teamColor.withOpacity(0.9), borderRadius: BorderRadius.circular(8)),
child: Text(name, style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(color: teamColor.withOpacity(0.9), borderRadius: BorderRadius.circular(6)),
child: Text(realName, style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
),
),
childWhenDragging: Opacity(opacity: 0.5, child: _playerCardUI(number, name, stats, teamColor, false, false, sf)),
childWhenDragging: Opacity(opacity: 0.5, child: _playerCardUI(number, realName, stats, teamColor, false, false, sf)),
child: DragTarget<String>(
onAcceptWithDetails: (details) {
final action = details.data;
@@ -223,85 +243,70 @@ class PlayerCourtCard extends StatelessWidget {
showDialog(
context: context,
builder: (ctx) => ZoneMapDialog(
playerName: name,
playerName: realName,
isMake: isMake,
is3PointAction: is3Pt,
onZoneSelected: (zone, points, relX, relY) {
controller.registerShotFromPopup(context, action, "$prefix$name", zone, points, relX, relY);
Navigator.pop(ctx);
controller.registerShotFromPopup(context, action, "$prefix$playerId", zone, points, relX, relY);
},
),
);
}
else if (action.startsWith("add_") || action.startsWith("sub_") || action.startsWith("miss_")) {
controller.handleActionDrag(context, action, "$prefix$name");
controller.handleActionDrag(context, action, "$prefix$playerId");
}
else if (action.startsWith("bench_")) {
controller.handleSubbing(context, action, name, isOpponent);
controller.handleSubbing(context, action, playerId, isOpponent);
}
},
builder: (context, candidateData, rejectedData) {
bool isSubbing = candidateData.any((data) => data != null && (data.startsWith("bench_my_") || data.startsWith("bench_opp_")));
bool isActionHover = candidateData.any((data) => data != null && (data.startsWith("add_") || data.startsWith("sub_") || data.startsWith("miss_")));
return _playerCardUI(number, name, stats, teamColor, isSubbing, isActionHover, sf);
return _playerCardUI(number, realName, stats, teamColor, isSubbing, isActionHover, sf);
},
),
);
}
Widget _playerCardUI(String number, String name, Map<String, int> stats, Color teamColor, bool isSubbing, bool isActionHover, double sf) {
Widget _playerCardUI(String number, String displayNameStr, Map<String, int> stats, Color teamColor, bool isSubbing, bool isActionHover, double sf) {
bool isFouledOut = stats["fls"]! >= 5;
Color bgColor = isFouledOut ? Colors.red.shade100 : Colors.white;
Color borderColor = isFouledOut ? AppTheme.actionMiss : Colors.transparent;
if (isSubbing) {
bgColor = Colors.blue.shade50; borderColor = AppTheme.myTeamBlue;
} else if (isActionHover && !isFouledOut) {
bgColor = Colors.orange.shade50; borderColor = AppTheme.actionPoints;
}
if (isSubbing) { bgColor = Colors.blue.shade50; borderColor = AppTheme.myTeamBlue; }
else if (isActionHover && !isFouledOut) { bgColor = Colors.orange.shade50; borderColor = AppTheme.actionPoints; }
int fgm = stats["fgm"]!;
int fga = stats["fga"]!;
int fgm = stats["fgm"]!; int fga = stats["fga"]!;
String fgPercent = fga > 0 ? ((fgm / fga) * 100).toStringAsFixed(0) : "0";
String displayName = name.length > 12 ? "${name.substring(0, 10)}..." : name;
String displayName = displayNameStr.length > 12 ? "${displayNameStr.substring(0, 10)}..." : displayNameStr;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: bgColor, borderRadius: BorderRadius.circular(12), border: Border.all(color: borderColor, width: 2),
boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 6, offset: Offset(0, 3))],
),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(8), border: Border.all(color: borderColor, width: 1.5), boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 4, offset: Offset(0, 2))]),
child: ClipRRect(
borderRadius: BorderRadius.circular(9 * sf),
borderRadius: BorderRadius.circular(6 * sf),
child: IntrinsicHeight(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: EdgeInsets.symmetric(horizontal: 16 * sf),
padding: EdgeInsets.symmetric(horizontal: 10 * sf),
color: isFouledOut ? Colors.grey[700] : teamColor,
alignment: Alignment.center,
child: Text(number, style: TextStyle(color: Colors.white, fontSize: 22 * sf, fontWeight: FontWeight.bold)),
child: Text(number, style: TextStyle(color: Colors.white, fontSize: 18 * sf, fontWeight: FontWeight.bold)),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12 * sf, vertical: 7 * sf),
padding: EdgeInsets.symmetric(horizontal: 8 * sf, vertical: 4 * sf),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
displayName,
style: TextStyle(fontSize: 16 * sf, fontWeight: FontWeight.bold, color: isFouledOut ? AppTheme.actionMiss : Colors.black87, decoration: isFouledOut ? TextDecoration.lineThrough : TextDecoration.none)
),
SizedBox(height: 2.5 * sf),
Text(
"${stats["pts"]} Pts | FG: $fgm/$fga ($fgPercent%)",
style: TextStyle(fontSize: 12 * sf, color: isFouledOut ? AppTheme.actionMiss : Colors.grey[700], fontWeight: FontWeight.w600)
),
Text(
"${stats["ast"]} Ast | ${stats["orb"]! + stats["drb"]!} Rbs | ${stats["fls"]} Fls",
style: TextStyle(fontSize: 12 * sf, color: isFouledOut ? AppTheme.actionMiss : Colors.grey[500], fontWeight: FontWeight.w600)
),
Text(displayName, style: TextStyle(fontSize: 14 * sf, fontWeight: FontWeight.bold, color: isFouledOut ? AppTheme.actionMiss : Colors.black87, decoration: isFouledOut ? TextDecoration.lineThrough : TextDecoration.none)),
SizedBox(height: 1.5 * sf),
Text("${stats["pts"]} Pts | FG: $fgm/$fga ($fgPercent%)", style: TextStyle(fontSize: 10 * sf, color: isFouledOut ? AppTheme.actionMiss : Colors.grey[700], fontWeight: FontWeight.w600)),
Text("${stats["ast"]} Ast | ${stats["orb"]! + stats["drb"]!} Rbs | ${stats["fls"]} Fls", style: TextStyle(fontSize: 10 * sf, color: isFouledOut ? AppTheme.actionMiss : Colors.grey[500], fontWeight: FontWeight.w600)),
],
),
),
@@ -314,7 +319,7 @@ class PlayerCourtCard extends StatelessWidget {
}
// ============================================================================
// 4. PAINEL DE BOTÕES DE AÇÃO (PONTOS, RESSALTOS, ETC)
// 4. PAINEL DE BOTÕES DE AÇÃO
// ============================================================================
class ActionButtonsPanel extends StatelessWidget {
final PlacarController controller;
@@ -324,9 +329,9 @@ class ActionButtonsPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final double baseSize = 65 * sf;
final double feedSize = 82 * sf;
final double gap = 7 * sf;
final double baseSize = 58 * sf;
final double feedSize = 73 * sf;
final double gap = 5 * sf;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -452,329 +457,7 @@ class ActionButtonsPanel extends StatelessWidget {
}
// ============================================================================
// 5. PÁGINA DO PLACAR
// ============================================================================
class PlacarPage extends StatefulWidget {
final String gameId, myTeam, opponentTeam;
const PlacarPage({
super.key,
required this.gameId,
required this.myTeam,
required this.opponentTeam
});
@override
State<PlacarPage> createState() => _PlacarPageState();
}
class _PlacarPageState extends State<PlacarPage> {
late PlacarController _controller;
@override
void initState() {
super.initState();
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeRight,
DeviceOrientation.landscapeLeft,
]);
_controller = PlacarController(
gameId: widget.gameId,
myTeam: widget.myTeam,
opponentTeam: widget.opponentTeam,
onUpdate: () {
if (mounted) setState(() {});
}
);
_controller.loadPlayers();
}
@override
void dispose() {
_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<String>(
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)),
],
),
),
);
}
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: 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),
),
);
}
// 👇 ATIVA O NOVO MAPA DE CALOR 👇
void _showHeatmap(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => HeatmapDialog(
shots: _controller.matchShots,
myTeamName: _controller.myTeam,
oppTeamName: _controller.opponentTeam,
myPlayers: [..._controller.myCourt, ..._controller.myBench],
oppPlayers: [..._controller.oppCourt, ..._controller.oppBench],
playerStats: _controller.playerStats, // Passa os stats para mostrar os pontos
),
);
}
@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 (_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),
StreamBuilder(
stream: Stream.periodic(const Duration(seconds: 3)),
builder: (context, snapshot) {
List<String> frases = [
"O Treinador está a desenhar a tática...",
"A encher as bolas com ar de campeão...",
"O árbitro está a testar o apito...",
"A verificar se o cesto está nivelado...",
"Os jogadores estão a terminar o aquecimento..."
];
String frase = frases[DateTime.now().second % frases.length];
return Text(frase, style: TextStyle(color: AppTheme.actionPoints.withOpacity(0.7), fontSize: 26 * sf, fontStyle: FontStyle.italic));
},
),
],
),
),
);
}
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) {
_controller.registerShotLocation(context, details.localPosition, Size(w, h));
}
},
child: Container(
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage('assets/campo.png'),
fit: BoxFit.fill,
),
),
),
),
if (!_controller.isSelectingShotLocation) ...[
Positioned(top: h * 0.25, left: w * 0.02, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[0], isOpponent: false, sf: sf)),
Positioned(top: h * 0.68, left: w * 0.02, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[1], isOpponent: false, sf: sf)),
Positioned(top: h * 0.45, left: w * 0.25, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[2], isOpponent: false, sf: sf)),
Positioned(top: h * 0.15, left: w * 0.20, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[3], isOpponent: false, sf: sf)),
Positioned(top: h * 0.80, left: w * 0.20, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[4], isOpponent: false, sf: sf)),
Positioned(top: h * 0.25, right: w * 0.02, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[0], isOpponent: true, sf: sf)),
Positioned(top: h * 0.68, right: w * 0.02, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[1], isOpponent: true, sf: sf)),
Positioned(top: h * 0.45, right: w * 0.25, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[2], isOpponent: true, sf: sf)),
Positioned(top: h * 0.15, right: w * 0.20, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[3], isOpponent: true, sf: sf)),
Positioned(top: h * 0.80, right: w * 0.20, child: PlayerCourtCard(controller: _controller, name: _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: () => _controller.toggleTimer(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 (!_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 DO LANÇAMENTO", style: TextStyle(color: Colors.white, fontSize: 27 * sf, fontWeight: FontWeight.bold)),
),
),
),
],
);
},
),
),
Positioned(
top: 50 * sf, left: 12 * sf,
child: _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);
}
}
),
),
Positioned(
top: 50 * sf, right: 12 * sf,
child: _buildCornerBtn(
heroTag: 'btn_heatmap',
icon: Icons.local_fire_department,
color: Colors.orange.shade800,
size: cornerBtnSize,
onTap: () => _showHeatmap(context),
),
),
Positioned(
bottom: 55 * sf, left: 12 * sf,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_controller.showMyBench) BenchPlayersList(controller: _controller, isOpponent: false, sf: sf),
SizedBox(height: 12 * sf),
_buildCornerBtn(heroTag: 'btn_sub_home', icon: Icons.swap_horiz, color: AppTheme.myTeamBlue, size: cornerBtnSize, onTap: () { _controller.showMyBench = !_controller.showMyBench; _controller.onUpdate(); }),
SizedBox(height: 12 * sf),
_buildCornerBtn(
heroTag: 'btn_to_home',
icon: Icons.timer,
color: _controller.myTimeoutsUsed >= 3 ? Colors.grey : AppTheme.myTeamBlue,
size: cornerBtnSize,
onTap: _controller.myTimeoutsUsed >= 3
? () => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: const Text('🛑 A equipa da casa já usou os 3 Timeouts deste período!'), backgroundColor: AppTheme.actionMiss))
: () => _controller.useTimeout(false)
),
],
),
),
Positioned(
bottom: 55 * sf, right: 12 * sf,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_controller.showOppBench) BenchPlayersList(controller: _controller, isOpponent: true, sf: sf),
SizedBox(height: 12 * sf),
_buildCornerBtn(heroTag: 'btn_sub_away', icon: Icons.swap_horiz, color: AppTheme.oppTeamRed, size: cornerBtnSize, onTap: () { _controller.showOppBench = !_controller.showOppBench; _controller.onUpdate(); }),
SizedBox(height: 12 * sf),
_buildCornerBtn(
heroTag: 'btn_to_away',
icon: Icons.timer,
color: _controller.opponentTimeoutsUsed >= 3 ? Colors.grey : AppTheme.oppTeamRed,
size: cornerBtnSize,
onTap: _controller.opponentTimeoutsUsed >= 3
? () => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: const Text('🛑 A equipa visitante já usou os 3 Timeouts deste período!'), backgroundColor: AppTheme.actionMiss))
: () => _controller.useTimeout(true)
),
],
),
),
if (_controller.isSaving)
Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.4),
),
),
],
),
),
),
);
}
}
// ============================================================================
// 👇 O TEU MAPA DE CALOR: ADVERSÁRIO À ESQUERDA | TUA EQUIPA À DIREITA 👇
// MAPA DE CALOR
// ============================================================================
class HeatmapDialog extends StatefulWidget {
final List<ShotRecord> shots;
@@ -864,21 +547,21 @@ class _HeatmapDialogState extends State<HeatmapDialog> {
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
// 👇 ESQUERDA: COLUNA DA EQUIPA ADVERSÁRIA 👇
Expanded(
child: _buildTeamColumn(
teamName: widget.oppTeamName,
players: widget.oppPlayers,
teamColor: AppTheme.oppTeamRed, // Vermelho do Tema
),
),
const SizedBox(width: 8),
// 👇 DIREITA: COLUNA DA TUA EQUIPA 👇
// 👇 ESQUERDA: COLUNA DA TUA EQUIPA (AZUL) 👇
Expanded(
child: _buildTeamColumn(
teamName: widget.myTeamName,
players: widget.myPlayers,
teamColor: AppTheme.myTeamBlue, // Azul do Tema
teamColor: AppTheme.myTeamBlue,
),
),
const SizedBox(width: 8),
// 👇 DIREITA: COLUNA DA EQUIPA ADVERSÁRIA (VERMELHA) 👇
Expanded(
child: _buildTeamColumn(
teamName: widget.oppTeamName,
players: widget.oppPlayers,
teamColor: AppTheme.oppTeamRed,
),
),
],
@@ -899,7 +582,6 @@ class _HeatmapDialogState extends State<HeatmapDialog> {
),
child: Column(
children: [
// CABEÇALHO DA EQUIPA (Botão para ver a equipa toda)
InkWell(
onTap: () => setState(() {
_selectedTeam = teamName;
@@ -926,8 +608,6 @@ class _HeatmapDialogState extends State<HeatmapDialog> {
),
),
),
// LISTA DOS JOGADORES COM OS SEUS PONTOS
Expanded(
child: ListView.separated(
itemCount: realPlayers.length,
@@ -945,7 +625,7 @@ class _HeatmapDialogState extends State<HeatmapDialog> {
onTap: () => setState(() {
_selectedTeam = teamName;
_selectedPlayer = p;
_isMapVisible = true; // Abre o mapa para este jogador!
_isMapVisible = true;
}),
);
},
@@ -956,9 +636,6 @@ class _HeatmapDialogState extends State<HeatmapDialog> {
);
}
// ==========================================
// TELA 2: O MAPA DE CALOR DESENHADO
// ==========================================
Widget _buildMapScreen(Color headerColor) {
List<ShotRecord> filteredShots = widget.shots.where((s) {
if (_selectedPlayer != 'Todos') return s.playerName == _selectedPlayer;
@@ -973,7 +650,6 @@ class _HeatmapDialogState extends State<HeatmapDialog> {
return Column(
children: [
// CABEÇALHO COM BOTÃO VOLTAR
Container(
height: 40,
color: headerColor,
@@ -984,7 +660,7 @@ class _HeatmapDialogState extends State<HeatmapDialog> {
Positioned(
left: 8,
child: InkWell(
onTap: () => setState(() => _isMapVisible = false), // Botão de voltar ao menu de seleção!
onTap: () => setState(() => _isMapVisible = false),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
@@ -1005,7 +681,7 @@ class _HeatmapDialogState extends State<HeatmapDialog> {
Positioned(
right: 8,
child: InkWell(
onTap: () => Navigator.pop(context), // Fecha o popup todo
onTap: () => Navigator.pop(context),
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle),
@@ -1016,14 +692,17 @@ class _HeatmapDialogState extends State<HeatmapDialog> {
],
),
),
// O DESENHO DO CAMPO E AS BOLAS
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return Stack(
children: [
CustomPaint(size: Size(constraints.maxWidth, constraints.maxHeight), painter: HeatmapCourtPainter()),
// 👇 A MÁGICA: O CAMPO DESENHADO IGUAL AO POP-UP (CustomPaint) 👇
CustomPaint(
size: Size(constraints.maxWidth, constraints.maxHeight),
painter: HeatmapCourtPainter(),
),
// AS BOLINHAS DOS LANÇAMENTOS POR CIMA DAS LINHAS
...filteredShots.map((shot) => Positioned(
left: (shot.relativeX * constraints.maxWidth) - 8,
top: (shot.relativeY * constraints.maxHeight) - 8,
@@ -1043,6 +722,7 @@ class _HeatmapDialogState extends State<HeatmapDialog> {
}
}
// 👇 O PINTOR QUE DESENHA AS LINHAS PERFEITAS DO CAMPO 👇
class HeatmapCourtPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {

View File

@@ -118,8 +118,7 @@ class PersonCard extends StatelessWidget {
height: 45,
alignment: Alignment.center,
decoration: BoxDecoration(color: Colors.blue.withOpacity(0.1), borderRadius: BorderRadius.circular(10)),
child: Text(person.number, style: const TextStyle(color: Colors.blue, fontWeight: FontWeight.bold, fontSize: 16)),
),
child: Text(person.number ?? "J", style: const TextStyle(color: Colors.blue, fontWeight: FontWeight.bold, fontSize: 16)), ),
title: Text(person.name, style: const TextStyle(fontWeight: FontWeight.bold)),
trailing: Row(
mainAxisSize: MainAxisSize.min,