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

@@ -1,8 +1,18 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="playmaker"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name="com.yalantis.ucrop.UCropActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.AppCompat.Light.NoActionBar"/>
<activity
android:name=".MainActivity"
android:exported="true"

View File

@@ -45,5 +45,12 @@
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>A PlayMaker precisa de aceder à tua galeria para poderes escolher uma foto de perfil.</string>
<key>NSCameraUsageDescription</key>
<string>A PlayMaker precisa de aceder à câmara para poderes tirar uma foto de perfil.</string>
</dict>
</dict>
</plist>

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;
@@ -472,17 +494,19 @@ class PlacarController {
int defScore = stl + blk;
int mvpScore = pts + ast + rbs + defScore;
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; }
String pName = playerNames[playerId] ?? '---';
if (pts > maxPts && pts > 0) { maxPts = pts; topPtsName = '$pName ($pts)'; }
if (ast > maxAst && ast > 0) { maxAst = ast; topAstName = '$pName ($ast)'; }
if (rbs > maxRbs && rbs > 0) { maxRbs = rbs; topRbsName = '$pName ($rbs)'; }
if (defScore > maxDef && defScore > 0) { maxDef = defScore; topDefName = '$pName ($defScore)'; }
if (mvpScore > maxMvpScore && mvpScore > 0) { maxMvpScore = mvpScore; mvpName = pName; }
});
await supabase.from('games').update({
'my_score': myScore,
'opponent_score': opponentScore,
'remaining_seconds': 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 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 {
@@ -30,6 +29,37 @@ class _HomeScreenState extends State<HomeScreen> {
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) {
final List<Widget> pages = [
@@ -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,
});
@@ -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,9 +397,49 @@ 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) {
return AlertDialog(
@@ -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)
@@ -27,22 +42,41 @@ class StatsHeader extends StatelessWidget {
children: [
IconButton(
icon: Icon(Icons.arrow_back, color: Colors.white, size: 24 * context.sf),
onPressed: () => Navigator.pop(context),
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,
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
@@ -220,30 +259,78 @@ 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 {
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', personId);
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}) {
@@ -365,24 +475,73 @@ class StatsController {
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,
builder: (ctx) => StatefulBuilder(
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 {
String? numeroFinal = (selectedType == "Treinador")
? null
: (numCtrl.text.trim().isEmpty ? null : numCtrl.text.trim());
// Limpa os erros antes de tentar de novo
setState(() {
nameError = null;
numError = null;
});
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/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,

View File

@@ -6,10 +6,14 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <gtk/gtk_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) gtk_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin");
gtk_plugin_register_with_registrar(gtk_registrar);

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
gtk
url_launcher_linux
)

View File

@@ -6,13 +6,17 @@ import FlutterMacOS
import Foundation
import app_links
import file_selector_macos
import path_provider_foundation
import shared_preferences_foundation
import sqflite_darwin
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}

View File

@@ -57,14 +57,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
cached_network_image:
dependency: "direct main"
description:
name: cached_network_image
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
cached_network_image_platform_interface:
dependency: transitive
description:
name: cached_network_image_platform_interface
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
cached_network_image_web:
dependency: transitive
description:
name: cached_network_image_web
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
characters:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "1.4.1"
clock:
dependency: transitive
description:
@@ -89,6 +113,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev"
source: hosted
version: "0.3.5+2"
crypto:
dependency: transitive
description:
@@ -145,6 +177,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
url: "https://pub.dev"
source: hosted
version: "0.9.5"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
fixnum:
dependency: transitive
description:
@@ -158,6 +222,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_cache_manager:
dependency: transitive
description:
name: flutter_cache_manager
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_lints:
dependency: "direct dev"
description:
@@ -166,6 +238,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
url: "https://pub.dev"
source: hosted
version: "2.0.33"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -216,6 +296,94 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image_cropper:
dependency: "direct main"
description:
name: image_cropper
sha256: "46c8f9aae51c8350b2a2982462f85a129e77b04675d35b09db5499437d7a996b"
url: "https://pub.dev"
source: hosted
version: "11.0.0"
image_cropper_for_web:
dependency: transitive
description:
name: image_cropper_for_web
sha256: e09749714bc24c4e3b31fbafa2e5b7229b0ff23e8b14d4ba44bd723b77611a0f
url: "https://pub.dev"
source: hosted
version: "7.0.0"
image_cropper_platform_interface:
dependency: transitive
description:
name: image_cropper_platform_interface
sha256: "886a30ec199362cdcc2fbb053b8e53347fbfb9dbbdaa94f9ff85622609f5e7ff"
url: "https://pub.dev"
source: hosted
version: "8.0.0"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156
url: "https://pub.dev"
source: hosted
version: "0.8.13+14"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
url: "https://pub.dev"
source: hosted
version: "0.8.13+6"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
url: "https://pub.dev"
source: hosted
version: "0.2.2+1"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev"
source: hosted
version: "0.2.2"
jwt_decode:
dependency: transitive
description:
@@ -268,18 +436,18 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.18"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
meta:
dependency: transitive
description:
@@ -304,6 +472,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
octo_image:
dependency: transitive
description:
name: octo_image
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
path:
dependency: transitive
description:
@@ -425,7 +601,7 @@ packages:
source: hosted
version: "0.28.0"
shared_preferences:
dependency: transitive
dependency: "direct main"
description:
name: shared_preferences
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
@@ -480,6 +656,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shimmer:
dependency: "direct main"
description:
name: shimmer
sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
sky_engine:
dependency: transitive
description: flutter
@@ -493,6 +677,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.1"
sqflite:
dependency: transitive
description:
name: sqflite
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
url: "https://pub.dev"
source: hosted
version: "2.4.2"
sqflite_android:
dependency: transitive
description:
name: sqflite_android
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
url: "https://pub.dev"
source: hosted
version: "2.4.2+3"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
sqflite_darwin:
dependency: transitive
description:
name: sqflite_darwin
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
sqflite_platform_interface:
dependency: transitive
description:
name: sqflite_platform_interface
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
stack_trace:
dependency: transitive
description:
@@ -541,6 +765,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.12.0"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
url: "https://pub.dev"
source: hosted
version: "3.4.0"
term_glyph:
dependency: transitive
description:
@@ -553,10 +785,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
url: "https://pub.dev"
source: hosted
version: "0.7.7"
version: "0.7.9"
typed_data:
dependency: transitive
description:
@@ -629,6 +861,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.5"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math:
dependency: transitive
description:

View File

@@ -36,6 +36,11 @@ dependencies:
cupertino_icons: ^1.0.8
provider: ^6.1.5+1
supabase_flutter: ^2.12.0
image_picker: ^1.2.1
image_cropper: ^11.0.0
shimmer: ^3.0.0
cached_network_image: ^3.4.1
shared_preferences: ^2.5.4
dev_dependencies:
flutter_test:

View File

@@ -7,11 +7,14 @@
#include "generated_plugin_registrant.h"
#include <app_links/app_links_plugin_c_api.h>
#include <file_selector_windows/file_selector_windows.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
AppLinksPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

View File

@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
app_links
file_selector_windows
url_launcher_windows
)