melhorar o sensor de calor

This commit is contained in:
2026-03-13 18:08:15 +00:00
parent cae3bbfe3b
commit 0369b5376c
10 changed files with 1053 additions and 926 deletions

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
@@ -6,7 +7,7 @@ class ShotRecord {
final double relativeX;
final double relativeY;
final bool isMake;
final String playerName; // Bónus: Agora guardamos quem foi o jogador!
final String playerName;
ShotRecord({
required this.relativeX,
@@ -31,8 +32,6 @@ class PlacarController {
bool isLoading = true;
bool isSaving = false;
// 👇 TRINCO DE SEGURANÇA: Evita contar vitórias duas vezes se clicares no Guardar repetidamente!
bool gameWasAlreadyFinished = false;
int myScore = 0;
@@ -67,35 +66,31 @@ class PlacarController {
Timer? timer;
bool isRunning = false;
// --- 🔄 CARREGAMENTO COMPLETO (DADOS REAIS + ESTATÍSTICAS SALVAS) ---
// OS TEUS NÚMEROS DE OURO DO TABLET
bool isCalibrating = false;
double hoopBaseX = 0.000;
double arcRadius = 0.500;
double cornerY = 0.443;
Future<void> loadPlayers() async {
final supabase = Supabase.instance.client;
try {
await Future.delayed(const Duration(milliseconds: 1500));
myCourt.clear();
myBench.clear();
oppCourt.clear();
oppBench.clear();
playerStats.clear();
playerNumbers.clear();
playerDbIds.clear();
myFouls = 0;
opponentFouls = 0;
myCourt.clear(); myBench.clear(); oppCourt.clear(); oppBench.clear();
playerStats.clear(); playerNumbers.clear(); playerDbIds.clear();
myFouls = 0; opponentFouls = 0;
final gameResponse = await supabase.from('games').select().eq('id', gameId).single();
myScore = int.tryParse(gameResponse['my_score']?.toString() ?? '0') ?? 0;
opponentScore = int.tryParse(gameResponse['opponent_score']?.toString() ?? '0') ?? 0;
int totalSeconds = int.tryParse(gameResponse['remaining_seconds']?.toString() ?? '600') ?? 600;
duration = Duration(seconds: totalSeconds);
myTimeoutsUsed = int.tryParse(gameResponse['my_timeouts']?.toString() ?? '0') ?? 0;
opponentTimeoutsUsed = int.tryParse(gameResponse['opp_timeouts']?.toString() ?? '0') ?? 0;
currentQuarter = int.tryParse(gameResponse['current_quarter']?.toString() ?? '1') ?? 1;
// 👇 Verifica se o jogo já tinha acabado noutra sessão
gameWasAlreadyFinished = gameResponse['status'] == 'Terminado';
final teamsResponse = await supabase.from('teams').select('id, name').inFilter('name', [myTeam, opponentTeam]);
@@ -115,17 +110,10 @@ class PlacarController {
for (int i = 0; i < myPlayers.length; i++) {
String dbId = myPlayers[i]['id'].toString();
String name = myPlayers[i]['name'].toString();
_registerPlayer(name: name, number: myPlayers[i]['number']?.toString() ?? "0", dbId: dbId, isMyTeam: true, isCourt: i < 5);
if (savedStats.containsKey(dbId)) {
var s = savedStats[dbId];
playerStats[name] = {
"pts": s['pts'] ?? 0, "rbs": s['rbs'] ?? 0, "ast": s['ast'] ?? 0,
"stl": s['stl'] ?? 0, "tov": s['tov'] ?? 0, "blk": s['blk'] ?? 0,
"fls": s['fls'] ?? 0, "fgm": s['fgm'] ?? 0, "fga": s['fga'] ?? 0,
"ftm": s['ftm'] ?? 0, "fta": s['fta'] ?? 0, "orb": s['orb'] ?? 0, "drb": s['drb'] ?? 0,
};
playerStats[name] = { "pts": s['pts'] ?? 0, "rbs": s['rbs'] ?? 0, "ast": s['ast'] ?? 0, "stl": s['stl'] ?? 0, "tov": s['tov'] ?? 0, "blk": s['blk'] ?? 0, "fls": s['fls'] ?? 0, "fgm": s['fgm'] ?? 0, "fga": s['fga'] ?? 0, "ftm": s['ftm'] ?? 0, "fta": s['fta'] ?? 0, "orb": s['orb'] ?? 0, "drb": s['drb'] ?? 0 };
myFouls += (s['fls'] as int? ?? 0);
}
}
@@ -134,28 +122,28 @@ class PlacarController {
for (int i = 0; i < oppPlayers.length; i++) {
String dbId = oppPlayers[i]['id'].toString();
String name = oppPlayers[i]['name'].toString();
_registerPlayer(name: name, number: oppPlayers[i]['number']?.toString() ?? "0", dbId: dbId, isMyTeam: false, isCourt: i < 5);
if (savedStats.containsKey(dbId)) {
var s = savedStats[dbId];
playerStats[name] = {
"pts": s['pts'] ?? 0, "rbs": s['rbs'] ?? 0, "ast": s['ast'] ?? 0,
"stl": s['stl'] ?? 0, "tov": s['tov'] ?? 0, "blk": s['blk'] ?? 0,
"fls": s['fls'] ?? 0, "fgm": s['fgm'] ?? 0, "fga": s['fga'] ?? 0,
"ftm": s['ftm'] ?? 0, "fta": s['fta'] ?? 0, "orb": s['orb'] ?? 0, "drb": s['drb'] ?? 0,
};
playerStats[name] = { "pts": s['pts'] ?? 0, "rbs": s['rbs'] ?? 0, "ast": s['ast'] ?? 0, "stl": s['stl'] ?? 0, "tov": s['tov'] ?? 0, "blk": s['blk'] ?? 0, "fls": s['fls'] ?? 0, "fgm": s['fgm'] ?? 0, "fga": s['fga'] ?? 0, "ftm": s['ftm'] ?? 0, "fta": s['fta'] ?? 0, "orb": s['orb'] ?? 0, "drb": s['drb'] ?? 0 };
opponentFouls += (s['fls'] as int? ?? 0);
}
}
_padTeam(oppCourt, oppBench, "Adversário", isMyTeam: false);
// Carregar Shots salvos para o HeatMap
final shotsResponse = await supabase.from('game_shots').select().eq('game_id', gameId);
matchShots = (shotsResponse as List).map((s) => ShotRecord(
relativeX: (s['relative_x'] as num).toDouble(),
relativeY: (s['relative_y'] as num).toDouble(),
isMake: s['is_make'] as bool,
playerName: s['player_name'],
)).toList();
isLoading = false;
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();
}
@@ -165,17 +153,9 @@ class PlacarController {
if (playerNumbers.containsKey(name)) name = "$name (Opp)";
playerNumbers[name] = number;
if (dbId != null) playerDbIds[name] = dbId;
playerStats[name] = {
"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);
} else {
if (isCourt) oppCourt.add(name); else oppBench.add(name);
}
playerStats[name] = { "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); }
else { if (isCourt) oppCourt.add(name); else oppBench.add(name); }
}
void _padTeam(List<String> court, List<String> bench, String prefix, {required bool isMyTeam}) {
@@ -197,13 +177,8 @@ class PlacarController {
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));
myFouls = 0; opponentFouls = 0; myTimeoutsUsed = 0; opponentTimeoutsUsed = 0;
onUpdate();
}
}
onUpdate();
@@ -214,11 +189,8 @@ class PlacarController {
}
void useTimeout(bool isOpponent) {
if (isOpponent) {
if (opponentTimeoutsUsed < 3) opponentTimeoutsUsed++;
} else {
if (myTimeoutsUsed < 3) myTimeoutsUsed++;
}
if (isOpponent) { if (opponentTimeoutsUsed < 3) opponentTimeoutsUsed++; }
else { if (myTimeoutsUsed < 3) myTimeoutsUsed++; }
isRunning = false;
timer?.cancel();
onUpdate();
@@ -254,7 +226,6 @@ class PlacarController {
myCourt[courtIndex] = benchPlayer;
myBench[benchIndex] = courtPlayerName;
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_", "");
@@ -264,41 +235,36 @@ class PlacarController {
oppCourt[courtIndex] = benchPlayer;
oppBench[benchIndex] = courtPlayerName;
showOppBench = false;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Sai $courtPlayerName, Entra $benchPlayer')));
}
onUpdate();
}
// ==============================================================
// 🎯 REGISTO DO TOQUE (INTELIGENTE E SILENCIOSO)
// ==============================================================
void registerShotLocation(BuildContext context, Offset position, Size size) {
if (pendingAction == null || pendingPlayer == null) return;
bool isOpponent = pendingPlayer!.startsWith("player_opp_");
bool is3Pt = pendingAction!.contains("_3");
bool is2Pt = pendingAction!.contains("_2");
if (is3Pt || is2Pt) {
bool isValid = _validateShotZone(position, size, is3Pt);
if (!isValid) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('🛑 Local incompatível com a pontuação.'), backgroundColor: Colors.red, duration: Duration(seconds: 2)));
bool isInside2Pts = _validateShotZone(position, size, isOpponent);
// Bloqueio silencioso (sem notificações chamas)
if ((is2Pt && !isInside2Pts) || (is3Pt && isInside2Pts)) {
cancelShotLocation();
return;
}
}
bool isMake = pendingAction!.startsWith("add_pts_");
// 👇 A MÁGICA DAS COORDENADAS RELATIVAS (0.0 a 1.0) 👇
double relX = position.dx / size.width;
double relY = position.dy / size.height;
// Extrai só o nome do jogador
String name = pendingPlayer!.replaceAll("player_my_", "").replaceAll("player_opp_", "");
// Guarda na lista!
matchShots.add(ShotRecord(
relativeX: relX,
relativeY: relY,
isMake: isMake,
playerName: name
));
matchShots.add(ShotRecord(relativeX: relX, relativeY: relY, isMake: isMake, playerName: name));
commitStat(pendingAction!, pendingPlayer!);
isSelectingShotLocation = false;
@@ -307,17 +273,36 @@ void registerShotLocation(BuildContext context, Offset position, Size size) {
onUpdate();
}
bool _validateShotZone(Offset pos, Size size, bool is3Pt) {
double w = size.width; double h = size.height;
Offset leftHoop = Offset(w * 0.12, h * 0.5);
Offset rightHoop = Offset(w * 0.88, h * 0.5);
double threePointRadius = w * 0.28;
Offset activeHoop = pos.dx < w / 2 ? leftHoop : rightHoop;
double distanceToHoop = (pos - activeHoop).distance;
bool isCorner3 = (pos.dy < h * 0.15 || pos.dy > h * 0.85) && (pos.dx < w * 0.20 || pos.dx > w * 0.80);
// ==============================================================
// 📐 MATEMÁTICA PURA: LÓGICA DE MEIO-CAMPO ATACANTE (SOLUÇÃO DIVIDIDA)
// ==============================================================
bool _validateShotZone(Offset position, Size size, bool isOpponent) {
double relX = position.dx / size.width;
double relY = position.dy / size.height;
if (is3Pt) return distanceToHoop >= threePointRadius || isCorner3;
else return distanceToHoop < threePointRadius && !isCorner3;
double hX = hoopBaseX;
double radius = arcRadius;
double cY = cornerY;
// A Minha Equipa defende na Esquerda (0.0), logo ataca o cesto da Direita (1.0)
// O Adversário defende na Direita (1.0), logo ataca o cesto da Esquerda (0.0)
double hoopX = isOpponent ? hX : (1.0 - hX);
double hoopY = 0.50;
double aspectRatio = size.width / size.height;
double distFromCenterY = (relY - hoopY).abs();
// Descobre se o toque foi feito na metade atacante daquela equipa
bool isAttackingHalf = isOpponent ? (relX < 0.5) : (relX > 0.5);
if (isAttackingHalf && distFromCenterY > cY) {
return false; // É 3 pontos (Zona dos Cantos)
} else {
double dx = (relX - hoopX) * aspectRatio;
double dy = (relY - hoopY);
double distanceToHoop = math.sqrt((dx * dx) + (dy * dy));
return distanceToHoop <= radius;
}
}
void cancelShotLocation() {
@@ -368,97 +353,63 @@ void registerShotLocation(BuildContext context, Offset position, Size size) {
}
}
// --- 💾 FUNÇÃO PARA GUARDAR DADOS NA BD ---
Future<void> saveGameStats(BuildContext context) async {
final supabase = Supabase.instance.client;
isSaving = true;
onUpdate();
try {
bool isGameFinishedNow = currentQuarter >= 4 && duration.inSeconds == 0;
bool isGameFinishedNow = (currentQuarter >= 4 && duration.inSeconds == 0);
String newStatus = isGameFinishedNow ? 'Terminado' : 'Pausado';
// 👇👇👇 0. CÉREBRO: CALCULAR OS LÍDERES E MVP DO JOGO 👇👇👇
String topPtsName = '---'; int maxPts = -1;
String topAstName = '---'; int maxAst = -1;
String topRbsName = '---'; int maxRbs = -1;
String topDefName = '---'; int maxDef = -1;
String mvpName = '---'; int maxMvpScore = -1;
// Passa por todos os jogadores e calcula a matemática
playerStats.forEach((playerName, stats) {
int pts = stats['pts'] ?? 0;
int ast = stats['ast'] ?? 0;
int rbs = stats['rbs'] ?? 0;
int stl = stats['stl'] ?? 0;
int blk = stats['blk'] ?? 0;
int defScore = stl + blk; // Defesa: Roubos + Cortes
int mvpScore = pts + ast + rbs + defScore; // Impacto Total (MVP)
// Compara com o máximo atual e substitui se for maior
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; } // MVP não leva nº à frente, fica mais limpo
if (mvpScore > maxMvpScore && mvpScore > 0) { maxMvpScore = mvpScore; mvpName = playerName; }
});
// 👆👆👆 FIM DO CÉREBRO 👆👆👆
// 1. Atualizar o Jogo na BD (Agora inclui os Reis da partida!)
await supabase.from('games').update({
'my_score': myScore,
'opponent_score': opponentScore,
'remaining_seconds': duration.inSeconds,
'my_timeouts': myTimeoutsUsed,
'opp_timeouts': opponentTimeoutsUsed,
'current_quarter': currentQuarter,
'status': newStatus,
// ENVIA A MATEMÁTICA PARA A TUA BASE DE DADOS
'top_pts_name': topPtsName,
'top_ast_name': topAstName,
'top_rbs_name': topRbsName,
'top_def_name': topDefName,
'mvp_name': mvpName,
'my_score': myScore, 'opponent_score': opponentScore, 'remaining_seconds': duration.inSeconds,
'my_timeouts': myTimeoutsUsed, 'opp_timeouts': opponentTimeoutsUsed, 'current_quarter': currentQuarter,
'status': newStatus, 'top_pts_name': topPtsName, 'top_ast_name': topAstName, 'top_rbs_name': topRbsName,
'top_def_name': topDefName, 'mvp_name': mvpName,
}).eq('id', gameId);
// 2. LÓGICA DE VITÓRIAS, DERROTAS E EMPATES
// Atualiza Vitórias/Derrotas se o jogo terminou
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 = {};
for(var t in teamsData) {
if(t['id'].toString() == myTeamDbId) myTeamUpdate = Map.from(t);
if(t['id'].toString() == oppTeamDbId) oppTeamUpdate = Map.from(t);
}
if (myScore > opponentScore) {
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;
if(t['id'].toString() == myTeamDbId) {
int w = (t['wins'] ?? 0) + (myScore > opponentScore ? 1 : 0);
int l = (t['losses'] ?? 0) + (myScore < opponentScore ? 1 : 0);
int d = (t['draws'] ?? 0) + (myScore == opponentScore ? 1 : 0);
await supabase.from('teams').update({'wins': w, 'losses': l, 'draws': d}).eq('id', myTeamDbId!);
} else {
myTeamUpdate['draws'] = (myTeamUpdate['draws'] ?? 0) + 1;
oppTeamUpdate['draws'] = (oppTeamUpdate['draws'] ?? 0) + 1;
int w = (t['wins'] ?? 0) + (opponentScore > myScore ? 1 : 0);
int l = (t['losses'] ?? 0) + (opponentScore < myScore ? 1 : 0);
int d = (t['draws'] ?? 0) + (opponentScore == myScore ? 1 : 0);
await supabase.from('teams').update({'wins': w, 'losses': l, 'draws': d}).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;
}
// 3. Atualizar as Estatísticas dos Jogadores
// Salvar Estatísticas Gerais
List<Map<String, dynamic>> batchStats = [];
playerStats.forEach((playerName, stats) {
String? memberDbId = playerDbIds[playerName];
@@ -470,21 +421,32 @@ void registerShotLocation(BuildContext context, Offset position, Size size) {
});
}
});
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);
// ===============================================
// 🔥 GRAVAR COORDENADAS PARA O HEATMAP
// ===============================================
List<Map<String, dynamic>> shotsData = [];
for (var shot in matchShots) {
bool isMyTeamPlayer = myCourt.contains(shot.playerName) || myBench.contains(shot.playerName);
shotsData.add({
'game_id': gameId,
'team_id': isMyTeamPlayer ? myTeamDbId! : oppTeamDbId!,
'player_name': shot.playerName,
'relative_x': shot.relativeX,
'relative_y': shot.relativeY,
'is_make': shot.isMake,
});
}
await supabase.from('game_shots').delete().eq('game_id', gameId);
if (shotsData.isNotEmpty) await supabase.from('game_shots').insert(shotsData);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Estatísticas e Resultados guardados com Sucesso!'), backgroundColor: Colors.green));
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tudo guardado com Sucesso!'), backgroundColor: Colors.green));
}
} catch (e) {
debugPrint("Erro ao gravar estatísticas: $e");
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Erro ao guardar: $e'), backgroundColor: Colors.red));
}
if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Erro ao guardar: $e'), backgroundColor: Colors.red));
} finally {
isSaving = false;
onUpdate();

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:playmaker/grafico%20de%20pizza/widgets/grafico_widgets.dart';
import 'dados_grafico.dart';
import 'dart:math' as math;
class PieChartCard extends StatefulWidget {
final int victories;
@@ -63,26 +64,21 @@ class _PieChartCardState extends State<PieChartCard> with SingleTickerProviderSt
animation: _animation,
builder: (context, child) {
return Transform.scale(
// O scale pode passar de 1.0 (efeito back), mas a opacidade NÃO
scale: 0.95 + (_animation.value * 0.05),
child: Opacity(
// 👇 AQUI ESTÁ A FIX: Garante que fica entre 0 e 1
opacity: _animation.value.clamp(0.0, 1.0),
child: child,
),
child: Opacity(opacity: _animation.value.clamp(0.0, 1.0), child: child),
);
},
child: Card(
margin: EdgeInsets.zero,
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
elevation: 8,
shadowColor: Colors.black54,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(14),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [widget.backgroundColor.withOpacity(0.9), widget.backgroundColor.withOpacity(0.7)]),
color: const Color(0xFF1A222D),
),
child: LayoutBuilder(
builder: (context, constraints) {
@@ -90,29 +86,31 @@ class _PieChartCardState extends State<PieChartCard> with SingleTickerProviderSt
final double cw = constraints.maxWidth;
return Padding(
padding: EdgeInsets.all(cw * 0.06),
padding: EdgeInsets.symmetric(horizontal: cw * 0.05, vertical: ch * 0.03),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 👇 TÍTULOS UM POUCO MAIS PRESENTES
// --- CABEÇALHO ---
FittedBox(
fit: BoxFit.scaleDown,
child: Text(widget.title.toUpperCase(), style: TextStyle(fontSize: ch * 0.06, fontWeight: FontWeight.bold, color: Colors.white.withOpacity(0.9), letterSpacing: 1.0)),
),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(widget.subtitle, style: TextStyle(fontSize: ch * 0.07, fontWeight: FontWeight.bold, color: Colors.white)),
child: Text(widget.title.toUpperCase(),
style: TextStyle(fontSize: ch * 0.045, fontWeight: FontWeight.bold, color: Colors.white70, letterSpacing: 1.2)),
),
Text(widget.subtitle,
style: TextStyle(fontSize: ch * 0.055, fontWeight: FontWeight.bold, color: Colors.white)),
SizedBox(height: ch * 0.03),
const Expanded(flex: 1, child: SizedBox()),
// MEIO (GRÁFICO + ESTATÍSTICAS)
// --- MIOLO (GRÁFICO + STATS GIGANTES À ESQUERDA) ---
Expanded(
flex: 9,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Expanded(
flex: 1,
// 1. Lado Esquerdo: Donut Chart LIMPO (Sem texto sobreposto)
SizedBox(
width: cw * 0.38,
height: cw * 0.38,
child: PieChartWidget(
victoryPercentage: data.victoryPercentage,
defeatPercentage: data.defeatPercentage,
@@ -120,59 +118,60 @@ class _PieChartCardState extends State<PieChartCard> with SingleTickerProviderSt
sf: widget.sf,
),
),
SizedBox(width: cw * 0.05),
SizedBox(width: cw * 0.08),
// 2. Lado Direito: Números Dinâmicos
Expanded(
flex: 1,
child: FittedBox(
alignment: Alignment.centerLeft,
fit: BoxFit.scaleDown,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDynStatRow("VIT", data.victories.toString(), (data.victoryPercentage * 100).toStringAsFixed(0), Colors.green, ch),
_buildDynStatRow("EMP", data.draws.toString(), (data.drawPercentage * 100).toStringAsFixed(0), Colors.yellow, ch),
_buildDynStatRow("DER", data.defeats.toString(), (data.defeatPercentage * 100).toStringAsFixed(0), Colors.red, ch),
_buildDynDivider(ch),
_buildDynStatRow("TOT", data.total.toString(), "100", Colors.white, ch),
_buildDynStatRow("VIT", data.victories.toString(), (data.victoryPercentage * 100).toStringAsFixed(0), Colors.greenAccent, ch, cw),
_buildDynStatRow("EMP", data.draws.toString(), (data.drawPercentage * 100).toStringAsFixed(0), Colors.yellowAccent, ch, cw),
_buildDynStatRow("DER", data.defeats.toString(), (data.defeatPercentage * 100).toStringAsFixed(0), Colors.redAccent, ch, cw),
_buildDynDivider(cw),
_buildDynStatRow("TOT", data.total.toString(), "100", Colors.white, ch, cw),
],
),
),
),
],
),
),
// 👇 RODAPÉ AJUSTADO
SizedBox(height: ch * 0.03),
const Expanded(flex: 1, child: SizedBox()),
// --- RODAPÉ: BOTÃO WIN RATE GIGANTE ---
Container(
width: double.infinity,
padding: EdgeInsets.symmetric(vertical: ch * 0.035),
padding: EdgeInsets.symmetric(vertical: ch * 0.025),
decoration: BoxDecoration(
color: Colors.white24, // Igual ao fundo do botão detalhes
borderRadius: BorderRadius.circular(ch * 0.03), // Borda arredondada
color: Colors.white.withOpacity(0.08),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
data.victoryPercentage >= 0.5 ? Icons.trending_up : Icons.trending_down,
color: Colors.green,
size: ch * 0.09
),
SizedBox(width: cw * 0.02),
Text(
'WIN RATE: ${(data.victoryPercentage * 100).toStringAsFixed(1)}%',
Icon(Icons.stars, color: Colors.greenAccent, size: ch * 0.075),
const SizedBox(width: 10),
Text('WIN RATE: ${(data.victoryPercentage * 100).toStringAsFixed(1)}%',
style: TextStyle(
fontSize: ch * 0.05,
fontWeight: FontWeight.bold,
color: Colors.white
)
color: Colors.white,
fontWeight: FontWeight.w900,
letterSpacing: 1.0,
fontSize: ch * 0.06
),
),
],
),
),
),
),
],
),
);
@@ -183,34 +182,38 @@ class _PieChartCardState extends State<PieChartCard> with SingleTickerProviderSt
),
);
}
// 👇 PERCENTAGENS SUBIDAS LIGEIRAMENTE (0.10 e 0.045)
Widget _buildDynStatRow(String label, String number, String percent, Color color, double ch) {
Widget _buildDynStatRow(String label, String number, String percent, Color color, double ch, double cw) {
return Padding(
padding: EdgeInsets.only(bottom: ch * 0.01),
padding: EdgeInsets.symmetric(vertical: ch * 0.005),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Número subiu para 0.10
Expanded(flex: 2, child: FittedBox(fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, child: Text(number, style: TextStyle(fontSize: ch * 0.10, fontWeight: FontWeight.bold, color: color, height: 1.0)))),
SizedBox(width: ch * 0.02),
Expanded(
flex: 3,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Row(children: [
Container(width: ch * 0.018, height: ch * 0.018, margin: EdgeInsets.only(right: ch * 0.015), decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
// Label subiu para 0.045
Expanded(child: FittedBox(fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, child: Text(label, style: TextStyle(fontSize: ch * 0.033, color: Colors.white.withOpacity(0.8), fontWeight: FontWeight.w600))))
]),
// Percentagem subiu para 0.05
FittedBox(fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, child: Text('$percent%', style: TextStyle(fontSize: ch * 0.04, color: color, fontWeight: FontWeight.bold))),
]),
SizedBox(
width: cw * 0.12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: TextStyle(fontSize: ch * 0.035, color: Colors.white60, fontWeight: FontWeight.bold)),
Text('$percent%', style: TextStyle(fontSize: ch * 0.04, color: color, fontWeight: FontWeight.bold)),
],
),
),
SizedBox(width: cw * 0.03),
Text(number, style: TextStyle(fontSize: ch * 0.125, fontWeight: FontWeight.w900, color: color, height: 1)),
],
),
);
}
Widget _buildDynDivider(double ch) {
return Container(height: 0.5, color: Colors.white.withOpacity(0.1), margin: EdgeInsets.symmetric(vertical: ch * 0.01));
Widget _buildDynDivider(double cw) {
return Container(
width: cw * 0.35,
height: 1.5,
color: Colors.white24,
margin: const EdgeInsets.symmetric(vertical: 4)
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:playmaker/controllers/placar_controller.dart';
import 'package:playmaker/pages/heatmap_page.dart';
import 'package:playmaker/utils/size_extension.dart';
import 'package:playmaker/widgets/placar_widgets.dart';
import 'dart:math' as math;
@@ -42,29 +43,15 @@ import 'package:playmaker/utils/size_extension.dart';
super.dispose();
}
// --- BOTÕES FLUTUANTES DE FALTA ---
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,
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)
),
),
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),
),
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)),
],
@@ -73,20 +60,14 @@ import 'package:playmaker/utils/size_extension.dart';
);
}
// --- BOTÕES LATERAIS QUADRADOS ---
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,
width: size, height: size,
child: FloatingActionButton(
heroTag: heroTag,
backgroundColor: color,
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),
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),
),
);
}
@@ -96,54 +77,34 @@ import 'package:playmaker/utils/size_extension.dart';
final double wScreen = MediaQuery.of(context).size.width;
final double hScreen = MediaQuery.of(context).size.height;
// 👇 CÁLCULO MANUAL DO SF 👇
final double sf = math.min(wScreen / 1150, hScreen / 720);
// Calcula o tamanho normal
double sf = math.min(wScreen / 1150, hScreen / 720);
final double cornerBtnSize = 48 * sf; // Tamanho ideal (Nem 38 nem 55)
// 👇 O TRAVÃO DE MÃO PARA OS TABLETS 👇
sf = math.min(sf, 0.9);
final double cornerBtnSize = 48 * sf;
if (_controller.isLoading) {
return Scaffold(
backgroundColor: const Color(0xFF16202C),
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: Colors.orange.withOpacity(0.7), fontSize: 26 * sf, fontStyle: FontStyle.italic));
},
),
],
),
),
);
return Scaffold(backgroundColor: const Color(0xFF16202C), body: Center(child: Text("PREPARANDO O PAVILHÃO...", style: TextStyle(color: Colors.white24, fontSize: 45 * sf, fontWeight: FontWeight.bold))));
}
return Scaffold(
backgroundColor: const Color(0xFF266174),
body: SafeArea(
top: false,
bottom: false,
// 👇 A MÁGICA DO IGNORE POINTER COMEÇA AQUI 👇
top: false, bottom: false,
child: IgnorePointer(
ignoring: _controller.isSaving, // Se estiver a gravar, ignora os toques!
ignoring: _controller.isSaving,
child: Stack(
children: [
// --- O CAMPO ---
// ==========================================
// --- 1. O CAMPO ---
// ==========================================
Container(
margin: EdgeInsets.only(left: 65 * sf, right: 65 * sf, bottom: 55 * sf),
decoration: BoxDecoration(border: Border.all(color: Colors.white, width: 2.5)),
decoration: BoxDecoration(
border: Border.all(color: Colors.white, width: 2.5),
image: const DecorationImage(image: AssetImage('assets/campo.png'), fit: BoxFit.fill),
),
child: LayoutBuilder(
builder: (context, constraints) {
final w = constraints.maxWidth;
@@ -151,29 +112,19 @@ import 'package:playmaker/utils/size_extension.dart';
return Stack(
children: [
GestureDetector(
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
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,
),
),
child: Stack(
children: _controller.matchShots.map((shot) => Positioned(
// Agora usamos relativeX e relativeY multiplicados pela largura(w) e altura(h)
left: (shot.relativeX * w) - (9 * context.sf),
top: (shot.relativeY * h) - (9 * context.sf),
child: CircleAvatar(
radius: 9 * context.sf,
backgroundColor: shot.isMake ? Colors.green : Colors.red,
child: Icon(shot.isMake ? Icons.check : Icons.close, size: 11 * context.sf, color: Colors.white)
),
left: (shot.relativeX * w) - (9 * sf),
top: (shot.relativeY * h) - (9 * sf),
child: CircleAvatar(radius: 9 * sf, backgroundColor: shot.isMake ? Colors.green : Colors.red, child: Icon(shot.isMake ? Icons.check : Icons.close, size: 11 * sf, color: Colors.white)),
)).toList(),
),
),
@@ -203,7 +154,7 @@ child: Stack(
// --- BOTÃO PLAY/PAUSE ---
if (!_controller.isSelectingShotLocation)
Positioned(
top: (h * 0.32) + (40 * sf),
top: (h * 0.36) + (40 * sf),
left: 0, right: 0,
child: Center(
child: GestureDetector(
@@ -212,110 +163,42 @@ child: Stack(
radius: 68 * sf,
backgroundColor: Colors.grey.withOpacity(0.5),
child: Icon(_controller.isRunning ? Icons.pause : Icons.play_arrow, color: Colors.white, size: 58 * sf)
)
)
)
),
),
),
),
// --- PLACAR NO TOPO ---
Positioned(top: 0, left: 0, right: 0, child: Center(child: TopScoreboard(controller: _controller, sf: sf))),
// --- BOTÕES DE AÇÃO ---
if (!_controller.isSelectingShotLocation) Positioned(bottom: -10 * sf, left: 0, right: 0, child: ActionButtonsPanel(controller: _controller, sf: sf)),
// --- OVERLAY LANÇAMENTO ---
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)),
),
),
),
],
);
},
),
),
// --- BOTÕES LATERAIS ---
// Topo Esquerdo: Guardar e Sair (Botão Único)
// ==========================================
// --- 2. O RODAPÉ (BOTÕES DE JOGO) ---
// ==========================================
if (!_controller.isSelectingShotLocation)
Positioned(
top: 50 * sf, left: 12 * sf,
child: _buildCornerBtn(
heroTag: 'btn_save_exit',
icon: Icons.save_alt,
color: const Color(0xFFD92C2C),
size: cornerBtnSize,
isLoading: _controller.isSaving,
onTap: () async {
// 1. Primeiro obriga a guardar os dados na BD
await _controller.saveGameStats(context);
// 2. Só depois de acabar de guardar é que volta para trás
if (context.mounted) {
Navigator.pop(context);
}
}
),
bottom: 60 * sf,
left: 0,
right: 0,
child: ActionButtonsPanel(controller: _controller, sf: sf)
),
// Base Esquerda: Banco Casa + TIMEOUT DA CASA
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: const Color(0xFF1E5BB2), 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 : const Color(0xFF1E5BB2),
size: cornerBtnSize,
onTap: _controller.myTimeoutsUsed >= 3
? () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('🛑 A equipa da casa já usou os 3 Timeouts deste período!'), backgroundColor: Colors.red))
: () => _controller.useTimeout(false)
),
],
),
),
// ==========================================
// --- 3. BOTÕES LATERAIS ---
// ==========================================
// Base Direita: Banco Visitante + TIMEOUT DO VISITANTE
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: const Color(0xFFD92C2C), 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 : const Color(0xFFD92C2C),
size: cornerBtnSize,
onTap: _controller.opponentTimeoutsUsed >= 3
? () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('🛑 A equipa visitante já usou os 3 Timeouts deste período!'), backgroundColor: Colors.red))
: () => _controller.useTimeout(true)
),
],
),
),
Positioned(top: 50 * sf, left: 12 * sf, child: _buildCornerBtn(heroTag: 'btn_save_exit', icon: Icons.save_alt, color: const Color(0xFFD92C2C), size: cornerBtnSize, isLoading: _controller.isSaving, onTap: () async { await _controller.saveGameStats(context); if (context.mounted) Navigator.pop(context); })),
// 👇 EFEITO VISUAL (Ecrã escurece para mostrar que está a carregar) 👇
if (_controller.isSaving)
Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.4),
),
),
Positioned(top: 50 * sf, right: 12 * sf, child: _buildCornerBtn(heroTag: 'btn_heatmap', icon: Icons.analytics_outlined, color: Colors.purple.shade700, size: cornerBtnSize, onTap: () { if (_controller.matchShots.isEmpty) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Ainda não há lançamentos!'))); return; } Navigator.push(context, MaterialPageRoute(builder: (context) => HeatmapPage(shots: _controller.matchShots, teamName: _controller.myTeam))); })),
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: const Color(0xFF1E5BB2), 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 : const Color(0xFF1E5BB2), size: cornerBtnSize, onTap: _controller.myTimeoutsUsed >= 3 ? () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('🛑 Esgotado!'), backgroundColor: Colors.red)) : () => _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: const Color(0xFFD92C2C), 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 : const Color(0xFFD92C2C), size: cornerBtnSize, onTap: _controller.opponentTimeoutsUsed >= 3 ? () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('🛑 Esgotado!'), backgroundColor: Colors.red)) : () => _controller.useTimeout(true))])),
if (_controller.isSaving) Positioned.fill(child: Container(color: Colors.black.withOpacity(0.4))),
],
),
),
@@ -323,3 +206,162 @@ child: Stack(
);
}
}
// ==============================================================
// 🏀 WIDGETS AUXILIARES (TopScoreboard, ActionButtonsPanel, etc)
// ==============================================================
class TopScoreboard extends StatelessWidget {
final PlacarController controller;
final double sf;
const TopScoreboard({super.key, required this.controller, required this.sf});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.symmetric(vertical: 10 * sf, horizontal: 35 * sf),
decoration: BoxDecoration(color: const Color(0xFF16202C), borderRadius: BorderRadius.only(bottomLeft: Radius.circular(22 * sf), bottomRight: Radius.circular(22 * sf)), border: Border.all(color: Colors.white, width: 2.5 * sf)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildTeamSection(controller.myTeam, controller.myScore, controller.myFouls, controller.myTimeoutsUsed, const Color(0xFF1E5BB2), false, sf),
SizedBox(width: 30 * sf),
Column(mainAxisSize: MainAxisSize.min, children: [Container(padding: EdgeInsets.symmetric(horizontal: 18 * sf, vertical: 5 * sf), decoration: BoxDecoration(color: const Color(0xFF2C3E50), 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))), SizedBox(height: 5 * sf), Text("PERÍODO ${controller.currentQuarter}", style: TextStyle(color: Colors.orangeAccent, fontSize: 14 * sf, fontWeight: FontWeight.w900))]),
SizedBox(width: 30 * sf),
_buildTeamSection(controller.opponentTeam, controller.opponentScore, controller.opponentFouls, controller.opponentTimeoutsUsed, const Color(0xFFD92C2C), 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: 3.5 * sf), width: 12 * sf, height: 12 * sf, decoration: BoxDecoration(shape: BoxShape.circle, color: index < timeouts ? Colors.yellow : Colors.grey.shade600, border: Border.all(color: Colors.white54, width: 1.5 * sf)))));
List<Widget> content = [Column(children: [_scoreBox(score, color, sf), SizedBox(height: 7 * sf), timeoutIndicators]), SizedBox(width: 18 * 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)), SizedBox(height: 5 * sf), Text("FALTAS: $displayFouls", style: TextStyle(color: displayFouls >= 5 ? Colors.redAccent : Colors.yellowAccent, fontSize: 13 * 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, 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)));
}
class BenchPlayersList extends StatelessWidget {
final PlacarController controller;
final bool isOpponent;
final double sf;
const BenchPlayersList({super.key, required this.controller, required this.isOpponent, required this.sf});
@override
Widget build(BuildContext context) {
final bench = isOpponent ? controller.oppBench : controller.myBench;
final teamColor = isOpponent ? const Color(0xFFD92C2C) : const Color(0xFF1E5BB2);
final prefix = isOpponent ? "bench_opp_" : "bench_my_";
return Column(mainAxisSize: MainAxisSize.min, children: bench.map((playerName) {
final num = controller.playerNumbers[playerName] ?? "0";
final bool isFouledOut = (controller.playerStats[playerName]?["fls"] ?? 0) >= 5;
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))));
if (isFouledOut) return GestureDetector(onTap: () => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('🛑 $playerName não pode voltar (Expulso).'), backgroundColor: Colors.red)), 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)), child: avatarUI);
}).toList());
}
}
class PlayerCourtCard extends StatelessWidget {
final PlacarController controller;
final String name;
final bool isOpponent;
final double sf;
const PlayerCourtCard({super.key, required this.controller, required this.name, required this.isOpponent, required this.sf});
@override
Widget build(BuildContext context) {
final teamColor = isOpponent ? const Color(0xFFD92C2C) : const Color(0xFF1E5BB2);
final stats = controller.playerStats[name]!;
final number = controller.playerNumbers[name]!;
final prefix = isOpponent ? "player_opp_" : "player_my_";
final int fouls = stats["fls"] ?? 0;
return Draggable<String>(
data: "$prefix$name",
feedback: Material(color: Colors.transparent, child: Container(padding: EdgeInsets.symmetric(horizontal: 18 * sf, vertical: 11 * sf), decoration: BoxDecoration(color: teamColor.withOpacity(0.9), borderRadius: BorderRadius.circular(9 * sf)), child: Text(name, style: TextStyle(color: Colors.white, fontSize: 20 * sf, fontWeight: FontWeight.bold)))),
childWhenDragging: Opacity(opacity: 0.5, child: _playerCardUI(context, number, name, stats, teamColor, false, false, sf, fouls)),
child: DragTarget<String>(
onAcceptWithDetails: (details) {
final action = details.data;
if (action.startsWith("add_") || action.startsWith("sub_") || action.startsWith("miss_")) controller.handleActionDrag(context, action, "$prefix$name");
else if (action.startsWith("bench_")) controller.handleSubbing(context, action, name, isOpponent);
},
builder: (context, candidateData, rejectedData) => _playerCardUI(
context,
number,
name,
stats,
teamColor,
candidateData.any((d) => d != null && d.startsWith("bench_")),
candidateData.any((d) => d != null && (d.startsWith("add_") || d.startsWith("sub_") || d.startsWith("miss_"))),
sf,
fouls),
),
);
}
Widget _playerCardUI(BuildContext context, String number, String name, Map<String, int> stats, Color teamColor, bool isSubbing, bool isActionHover, double sf, int fouls) {
bool isFouledOut = fouls >= 5;
Color bgColor = isFouledOut ? Colors.red.shade50 : (isSubbing ? Colors.blue.shade50 : (isActionHover ? Colors.orange.shade50 : Colors.white));
Color borderColor = isFouledOut ? Colors.redAccent : (isSubbing ? Colors.blue : (isActionHover ? Colors.orange : Colors.transparent));
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;
return GestureDetector(
onTap: () {
final playerShots = controller.matchShots.where((s) => s.playerName == name).toList();
if (playerShots.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('O $name ainda não lançou!')));
return;
}
Navigator.push(context, MaterialPageRoute(builder: (context) => HeatmapPage(shots: playerShots, teamName: name)));
},
child: Container(
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(11 * sf),
border: Border.all(color: borderColor, width: 2 * sf),
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 5 * sf, offset: Offset(2 * sf, 3.5 * sf))]
),
child: ClipRRect(
borderRadius: BorderRadius.circular(9 * sf),
child: IntrinsicHeight(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// --- LADO ESQUERDO: APENAS O NÚMERO ---
Container(
padding: EdgeInsets.symmetric(horizontal: 16 * sf),
color: isFouledOut ? Colors.grey[700] : teamColor,
alignment: Alignment.center,
child: Text(number, style: TextStyle(color: Colors.white, fontSize: 24 * sf, fontWeight: FontWeight.bold)),
),
// --- LADO DIREITO: INFO ---
Padding(
padding: EdgeInsets.symmetric(horizontal: 12 * sf, vertical: 7 * sf),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(displayName, style: TextStyle(fontSize: 16 * sf, fontWeight: FontWeight.bold, color: isFouledOut ? Colors.red : Colors.black87, decoration: isFouledOut ? TextDecoration.lineThrough : TextDecoration.none)),
SizedBox(height: 2 * sf),
Text("${stats["pts"]} Pts | FG: $fgm/$fga ($fgPercent%)", style: TextStyle(fontSize: 12 * sf, color: isFouledOut ? Colors.red : Colors.grey[700], fontWeight: FontWeight.bold)),
// Texto de faltas com destaque se estiver em perigo (4 ou 5)
Text("AST: ${stats["ast"]} | REB: ${stats["orb"]! + stats["drb"]!} | FALTAS: $fouls",
style: TextStyle(
fontSize: 11 * sf,
color: fouls >= 4 ? Colors.red : Colors.grey[600],
fontWeight: fouls >= 4 ? FontWeight.w900 : FontWeight.w600
)),
],
),
)
],
),
),
),
),
);
}
}

View File

@@ -3,7 +3,8 @@ import 'package:playmaker/pages/PlacarPage.dart';
import '../controllers/game_controller.dart';
import '../controllers/team_controller.dart';
import '../models/game_model.dart';
import '../utils/size_extension.dart'; // 👇 NOVO SUPERPODER AQUI TAMBÉM!
import '../utils/size_extension.dart';
import 'dart:math' as math; // 👇 IMPORTANTE PARA O TRAVÃO DE MÃO
// --- CARD DE EXIBIÇÃO DO JOGO ---
class GameResultCard extends StatelessWidget {
@@ -18,59 +19,61 @@ class GameResultCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final double safeSf = math.min(context.sf, 1.15); // TRAVÃO DO TABLET
return Container(
margin: EdgeInsets.only(bottom: 16 * context.sf),
padding: EdgeInsets.all(16 * context.sf),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20 * context.sf), boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10 * context.sf)]),
margin: EdgeInsets.only(bottom: 16 * safeSf),
padding: EdgeInsets.all(16 * safeSf),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20 * safeSf), boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10 * safeSf)]),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: _buildTeamInfo(context, myTeam, const Color(0xFFE74C3C), myTeamLogo)),
_buildScoreCenter(context, gameId),
Expanded(child: _buildTeamInfo(context, opponentTeam, Colors.black87, opponentTeamLogo)),
Expanded(child: _buildTeamInfo(myTeam, const Color(0xFFE74C3C), myTeamLogo, safeSf)),
_buildScoreCenter(context, gameId, safeSf),
Expanded(child: _buildTeamInfo(opponentTeam, Colors.black87, opponentTeamLogo, safeSf)),
],
),
);
}
Widget _buildTeamInfo(BuildContext context, String name, Color color, String? logoUrl) {
Widget _buildTeamInfo(String name, Color color, String? logoUrl, double safeSf) {
return Column(
children: [
CircleAvatar(radius: 24 * context.sf, backgroundColor: color, backgroundImage: (logoUrl != null && logoUrl.isNotEmpty) ? NetworkImage(logoUrl) : null, child: (logoUrl == null || logoUrl.isEmpty) ? Icon(Icons.shield, color: Colors.white, size: 24 * context.sf) : null),
SizedBox(height: 6 * context.sf),
Text(name, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13 * context.sf), textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, maxLines: 2),
CircleAvatar(radius: 24 * safeSf, backgroundColor: color, backgroundImage: (logoUrl != null && logoUrl.isNotEmpty) ? NetworkImage(logoUrl) : null, child: (logoUrl == null || logoUrl.isEmpty) ? Icon(Icons.shield, color: Colors.white, size: 24 * safeSf) : null),
SizedBox(height: 6 * safeSf),
Text(name, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13 * safeSf), textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, maxLines: 2),
],
);
}
Widget _buildScoreCenter(BuildContext context, String id) {
Widget _buildScoreCenter(BuildContext context, String id, double safeSf) {
return Column(
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
_scoreBox(context, myScore, Colors.green),
Text(" : ", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 22 * context.sf)),
_scoreBox(context, opponentScore, Colors.grey),
_scoreBox(myScore, Colors.green, safeSf),
Text(" : ", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 22 * safeSf)),
_scoreBox(opponentScore, Colors.grey, safeSf),
],
),
SizedBox(height: 10 * context.sf),
SizedBox(height: 10 * safeSf),
TextButton.icon(
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (context) => PlacarPage(gameId: id, myTeam: myTeam, opponentTeam: opponentTeam))),
icon: Icon(Icons.play_circle_fill, size: 18 * context.sf, color: const Color(0xFFE74C3C)),
label: Text("RETORNAR", style: TextStyle(fontSize: 11 * context.sf, color: const Color(0xFFE74C3C), fontWeight: FontWeight.bold)),
style: TextButton.styleFrom(backgroundColor: const Color(0xFFE74C3C).withOpacity(0.1), padding: EdgeInsets.symmetric(horizontal: 14 * context.sf, vertical: 8 * context.sf), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20 * context.sf)), visualDensity: VisualDensity.compact),
icon: Icon(Icons.play_circle_fill, size: 18 * safeSf, color: const Color(0xFFE74C3C)),
label: Text("RETORNAR", style: TextStyle(fontSize: 11 * safeSf, color: const Color(0xFFE74C3C), fontWeight: FontWeight.bold)),
style: TextButton.styleFrom(backgroundColor: const Color(0xFFE74C3C).withOpacity(0.1), padding: EdgeInsets.symmetric(horizontal: 14 * safeSf, vertical: 8 * safeSf), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20 * safeSf)), visualDensity: VisualDensity.compact),
),
SizedBox(height: 6 * context.sf),
Text(status, style: TextStyle(fontSize: 12 * context.sf, color: Colors.blue, fontWeight: FontWeight.bold)),
SizedBox(height: 6 * safeSf),
Text(status, style: TextStyle(fontSize: 12 * safeSf, color: Colors.blue, fontWeight: FontWeight.bold)),
],
);
}
Widget _scoreBox(BuildContext context, String pts, Color c) => Container(
padding: EdgeInsets.symmetric(horizontal: 12 * context.sf, vertical: 6 * context.sf),
decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(8 * context.sf)),
child: Text(pts, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16 * context.sf)),
Widget _scoreBox(String pts, Color c, double safeSf) => Container(
padding: EdgeInsets.symmetric(horizontal: 12 * safeSf, vertical: 6 * safeSf),
decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(8 * safeSf)),
child: Text(pts, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16 * safeSf)),
);
}
@@ -104,25 +107,30 @@ class _CreateGameDialogManualState extends State<CreateGameDialogManual> {
@override
Widget build(BuildContext context) {
final double safeSf = math.min(context.sf, 1.15); // TRAVÃO DO TABLET
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20 * context.sf)),
title: Text('Configurar Partida', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18 * context.sf)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20 * safeSf)),
title: Text('Configurar Partida', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18 * safeSf)),
content: SingleChildScrollView(
child: Container(
constraints: BoxConstraints(maxWidth: 450 * safeSf), // LIMITA A LARGURA NO TABLET
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: _seasonController, style: TextStyle(fontSize: 14 * context.sf), decoration: InputDecoration(labelText: 'Temporada', labelStyle: TextStyle(fontSize: 14 * context.sf), border: const OutlineInputBorder(), prefixIcon: Icon(Icons.calendar_today, size: 20 * context.sf))),
SizedBox(height: 15 * context.sf),
_buildSearch(context, "Minha Equipa", _myTeamController),
Padding(padding: EdgeInsets.symmetric(vertical: 10 * context.sf), child: Text("VS", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey, fontSize: 16 * context.sf))),
_buildSearch(context, "Adversário", _opponentController),
TextField(controller: _seasonController, style: TextStyle(fontSize: 14 * safeSf), decoration: InputDecoration(labelText: 'Temporada', labelStyle: TextStyle(fontSize: 14 * safeSf), border: const OutlineInputBorder(), prefixIcon: Icon(Icons.calendar_today, size: 20 * safeSf))),
SizedBox(height: 15 * safeSf),
_buildSearch(label: "Minha Equipa", controller: _myTeamController, safeSf: safeSf),
Padding(padding: EdgeInsets.symmetric(vertical: 10 * safeSf), child: Text("VS", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey, fontSize: 16 * safeSf))),
_buildSearch(label: "Adversário", controller: _opponentController, safeSf: safeSf),
],
),
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text('CANCELAR', style: TextStyle(fontSize: 14 * context.sf))),
TextButton(onPressed: () => Navigator.pop(context), child: Text('CANCELAR', style: TextStyle(fontSize: 14 * safeSf))),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFFE74C3C), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10 * context.sf)), padding: EdgeInsets.symmetric(horizontal: 16 * context.sf, vertical: 10 * context.sf)),
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFFE74C3C), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10 * safeSf)), padding: EdgeInsets.symmetric(horizontal: 16 * safeSf, vertical: 10 * safeSf)),
onPressed: _isLoading ? null : () async {
if (_myTeamController.text.isNotEmpty && _opponentController.text.isNotEmpty) {
setState(() => _isLoading = true);
@@ -134,13 +142,13 @@ class _CreateGameDialogManualState extends State<CreateGameDialogManual> {
}
}
},
child: _isLoading ? SizedBox(width: 20 * context.sf, height: 20 * context.sf, child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : Text('CRIAR', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14 * context.sf)),
child: _isLoading ? SizedBox(width: 20 * safeSf, height: 20 * safeSf, child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : Text('CRIAR', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14 * safeSf)),
),
],
);
}
Widget _buildSearch(BuildContext context, String label, TextEditingController controller) {
Widget _buildSearch({required String label, required TextEditingController controller, required double safeSf}) {
return StreamBuilder<List<Map<String, dynamic>>>(
stream: widget.teamController.teamsStream,
builder: (context, snapshot) {
@@ -156,9 +164,9 @@ class _CreateGameDialogManualState extends State<CreateGameDialogManual> {
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4.0, borderRadius: BorderRadius.circular(8 * context.sf),
elevation: 4.0, borderRadius: BorderRadius.circular(8 * safeSf),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: 250 * context.sf, maxWidth: MediaQuery.of(context).size.width * 0.7),
constraints: BoxConstraints(maxHeight: 250 * safeSf, maxWidth: 400 * safeSf), // Limita também o dropdown
child: ListView.builder(
padding: EdgeInsets.zero, shrinkWrap: true, itemCount: options.length,
itemBuilder: (BuildContext context, int index) {
@@ -166,8 +174,8 @@ class _CreateGameDialogManualState extends State<CreateGameDialogManual> {
final String name = option['name'].toString();
final String? imageUrl = option['image_url'];
return ListTile(
leading: CircleAvatar(radius: 20 * context.sf, backgroundColor: Colors.grey.shade200, backgroundImage: (imageUrl != null && imageUrl.isNotEmpty) ? NetworkImage(imageUrl) : null, child: (imageUrl == null || imageUrl.isEmpty) ? Icon(Icons.shield, color: Colors.grey, size: 20 * context.sf) : null),
title: Text(name, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14 * context.sf)),
leading: CircleAvatar(radius: 20 * safeSf, backgroundColor: Colors.grey.shade200, backgroundImage: (imageUrl != null && imageUrl.isNotEmpty) ? NetworkImage(imageUrl) : null, child: (imageUrl == null || imageUrl.isEmpty) ? Icon(Icons.shield, color: Colors.grey, size: 20 * safeSf) : null),
title: Text(name, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14 * safeSf)),
onTap: () { onSelected(option); },
);
},
@@ -180,8 +188,8 @@ class _CreateGameDialogManualState extends State<CreateGameDialogManual> {
if (txtCtrl.text.isEmpty && controller.text.isNotEmpty) txtCtrl.text = controller.text;
txtCtrl.addListener(() { controller.text = txtCtrl.text; });
return TextField(
controller: txtCtrl, focusNode: node, style: TextStyle(fontSize: 14 * context.sf),
decoration: InputDecoration(labelText: label, labelStyle: TextStyle(fontSize: 14 * context.sf), prefixIcon: Icon(Icons.search, size: 20 * context.sf), border: const OutlineInputBorder()),
controller: txtCtrl, focusNode: node, style: TextStyle(fontSize: 14 * safeSf),
decoration: InputDecoration(labelText: label, labelStyle: TextStyle(fontSize: 14 * safeSf), prefixIcon: Icon(Icons.search, size: 20 * safeSf), border: const OutlineInputBorder()),
);
},
);
@@ -190,6 +198,8 @@ class _CreateGameDialogManualState extends State<CreateGameDialogManual> {
}
}
// (O RESTO DA CLASSE GamePage CONTINUA IGUAL, o sf nativo já estava protegido lá dentro)
// --- PÁGINA PRINCIPAL DOS JOGOS ---
class GamePage extends StatefulWidget {
const GamePage({super.key});

View File

@@ -8,6 +8,7 @@ import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:playmaker/pages/status_page.dart';
import '../utils/size_extension.dart';
import 'package:playmaker/grafico%20de%20pizza/controllers/contollers_grafico.dart';
import 'dart:math' as math; // 👇 IMPORTANTE
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@@ -30,10 +31,10 @@ class _HomeScreenState extends State<HomeScreen> {
@override
Widget build(BuildContext context) {
// Já não precisamos calcular o sf aqui!
final double safeSf = math.min(context.sf, 1.15); // TRAVÃO
final List<Widget> pages = [
_buildHomeContent(context), // Passamos só o context
_buildHomeContent(context, safeSf), // Passamos o safeSf
const GamePage(),
const TeamsPage(),
const StatusPage(),
@@ -42,11 +43,11 @@ class _HomeScreenState extends State<HomeScreen> {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: Text('PlayMaker', style: TextStyle(fontSize: 20 * context.sf)),
title: Text('PlayMaker', style: TextStyle(fontSize: 20 * safeSf)),
backgroundColor: HomeConfig.primaryColor,
foregroundColor: Colors.white,
leading: IconButton(
icon: Icon(Icons.person, size: 24 * context.sf),
icon: Icon(Icons.person, size: 24 * safeSf),
onPressed: () {},
),
),
@@ -62,8 +63,7 @@ class _HomeScreenState extends State<HomeScreen> {
backgroundColor: Theme.of(context).colorScheme.surface,
surfaceTintColor: Theme.of(context).colorScheme.surfaceTint,
elevation: 1,
// O math.min não é necessário se já tens o sf. Mas podes usar context.sf
height: 70 * (context.sf < 1.2 ? context.sf : 1.2),
height: 70 * safeSf,
destinations: const [
NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home_filled), label: 'Home'),
NavigationDestination(icon: Icon(Icons.sports_soccer_outlined), selectedIcon: Icon(Icons.sports_soccer), label: 'Jogo'),
@@ -74,16 +74,16 @@ class _HomeScreenState extends State<HomeScreen> {
);
}
void _showTeamSelector(BuildContext context) {
void _showTeamSelector(BuildContext context, double safeSf) {
showModalBottomSheet(
context: context,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20 * context.sf))),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20 * safeSf))),
builder: (context) {
return StreamBuilder<List<Map<String, dynamic>>>(
stream: _teamController.teamsStream,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) return const SizedBox(height: 200, child: Center(child: CircularProgressIndicator()));
if (!snapshot.hasData || snapshot.data!.isEmpty) return SizedBox(height: 200 * context.sf, child: const Center(child: Text("Nenhuma equipa criada.")));
if (!snapshot.hasData || snapshot.data!.isEmpty) return SizedBox(height: 200 * safeSf, child: const Center(child: Text("Nenhuma equipa criada.")));
final teams = snapshot.data!;
return ListView.builder(
@@ -92,7 +92,7 @@ class _HomeScreenState extends State<HomeScreen> {
itemBuilder: (context, index) {
final team = teams[index];
return ListTile(
title: Text(team['name']),
title: Text(team['name'], style: TextStyle(fontSize: 16 * safeSf)),
onTap: () {
setState(() {
_selectedTeamId = team['id'];
@@ -112,9 +112,10 @@ class _HomeScreenState extends State<HomeScreen> {
);
}
Widget _buildHomeContent(BuildContext context) {
Widget _buildHomeContent(BuildContext context, double safeSf) {
final double wScreen = MediaQuery.of(context).size.width;
final double cardHeight = wScreen * 0.5;
// Evita que os cartões fiquem muito altos no tablet:
final double cardHeight = math.min(wScreen * 0.5, 200 * safeSf);
return StreamBuilder<List<Map<String, dynamic>>>(
stream: _selectedTeamId != null
@@ -125,44 +126,44 @@ class _HomeScreenState extends State<HomeScreen> {
return SingleChildScrollView(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 22.0 * context.sf, vertical: 16.0 * context.sf),
padding: EdgeInsets.symmetric(horizontal: 22.0 * safeSf, vertical: 16.0 * safeSf),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () => _showTeamSelector(context),
onTap: () => _showTeamSelector(context, safeSf),
child: Container(
padding: EdgeInsets.all(12 * context.sf),
decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(15 * context.sf), border: Border.all(color: Colors.grey.shade300)),
padding: EdgeInsets.all(12 * safeSf),
decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(15 * safeSf), border: Border.all(color: Colors.grey.shade300)),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(children: [Icon(Icons.shield, color: HomeConfig.primaryColor, size: 24 * context.sf), SizedBox(width: 10 * context.sf), Text(_selectedTeamName, style: TextStyle(fontSize: 16 * context.sf, fontWeight: FontWeight.bold))]),
Row(children: [Icon(Icons.shield, color: HomeConfig.primaryColor, size: 24 * safeSf), SizedBox(width: 10 * safeSf), Text(_selectedTeamName, style: TextStyle(fontSize: 16 * safeSf, fontWeight: FontWeight.bold))]),
const Icon(Icons.arrow_drop_down),
],
),
),
),
SizedBox(height: 20 * context.sf),
SizedBox(height: 20 * safeSf),
SizedBox(
height: cardHeight,
child: Row(
children: [
Expanded(child: _buildStatCard(context: context, title: 'Mais Pontos', playerName: leaders['pts_name'], statValue: leaders['pts_val'].toString(), statLabel: 'TOTAL', color: const Color(0xFF1565C0), isHighlighted: true)),
SizedBox(width: 12 * context.sf),
SizedBox(width: 12 * safeSf),
Expanded(child: _buildStatCard(context: context, title: 'Assistências', playerName: leaders['ast_name'], statValue: leaders['ast_val'].toString(), statLabel: 'TOTAL', color: const Color(0xFF2E7D32))),
],
),
),
SizedBox(height: 12 * context.sf),
SizedBox(height: 12 * safeSf),
SizedBox(
height: cardHeight,
child: Row(
children: [
Expanded(child: _buildStatCard(context: context, title: 'Rebotes', playerName: leaders['rbs_name'], statValue: leaders['rbs_val'].toString(), statLabel: 'TOTAL', color: const Color(0xFF6A1B9A))),
SizedBox(width: 12 * context.sf),
SizedBox(width: 12 * safeSf),
Expanded(
child: PieChartCard(
victories: _teamWins,
@@ -171,22 +172,22 @@ class _HomeScreenState extends State<HomeScreen> {
title: 'DESEMPENHO',
subtitle: 'Temporada',
backgroundColor: const Color(0xFFC62828),
sf: context.sf // Aqui o PieChartCard ainda usa sf, então passamos
sf: safeSf
),
),
],
),
),
SizedBox(height: 40 * context.sf),
SizedBox(height: 40 * safeSf),
Text('Histórico de Jogos', style: TextStyle(fontSize: 20 * context.sf, fontWeight: FontWeight.bold, color: Colors.grey[800])),
SizedBox(height: 16 * context.sf),
Text('Histórico de Jogos', style: TextStyle(fontSize: 20 * safeSf, fontWeight: FontWeight.bold, color: Colors.grey[800])),
SizedBox(height: 16 * safeSf),
_selectedTeamName == "Selecionar Equipa"
? Container(
padding: EdgeInsets.all(20 * context.sf),
padding: EdgeInsets.all(20 * safeSf),
alignment: Alignment.center,
child: Text("Seleciona uma equipa no topo.", style: TextStyle(color: Colors.grey, fontSize: 14 * context.sf)),
child: Text("Seleciona uma equipa no topo.", style: TextStyle(color: Colors.grey, fontSize: 14 * safeSf)),
)
: StreamBuilder<List<Map<String, dynamic>>>(
stream: _supabase.from('games').stream(primaryKey: ['id'])
@@ -206,7 +207,7 @@ class _HomeScreenState extends State<HomeScreen> {
if (gamesList.isEmpty) {
return Container(
padding: EdgeInsets.all(20 * context.sf),
padding: EdgeInsets.all(20 * safeSf),
decoration: BoxDecoration(color: Colors.grey.shade50, borderRadius: BorderRadius.circular(14)),
alignment: Alignment.center,
child: Text("Ainda não há jogos terminados para $_selectedTeamName.", style: TextStyle(color: Colors.grey)),
@@ -236,7 +237,7 @@ class _HomeScreenState extends State<HomeScreen> {
if (myScore < oppScore) result = 'D';
return _buildGameHistoryCard(
context: context, // Usamos o context para o sf
context: context,
opponent: opponent,
result: result,
myScore: myScore,
@@ -247,13 +248,14 @@ class _HomeScreenState extends State<HomeScreen> {
topRbs: game['top_rbs_name'] ?? '---',
topDef: game['top_def_name'] ?? '---',
mvp: game['mvp_name'] ?? '---',
safeSf: safeSf // Passa a escala aqui
);
}).toList(),
);
},
),
SizedBox(height: 20 * context.sf),
SizedBox(height: 20 * safeSf),
],
),
),
@@ -323,14 +325,14 @@ class _HomeScreenState extends State<HomeScreen> {
Widget _buildGameHistoryCard({
required BuildContext context, required String opponent, required String result, required int myScore, required int oppScore, required String date,
required String topPts, required String topAst, required String topRbs, required String topDef, required String mvp
required String topPts, required String topAst, required String topRbs, required String topDef, required String mvp, required double safeSf
}) {
bool isWin = result == 'V';
bool isDraw = result == 'E';
Color statusColor = isWin ? Colors.green : (isDraw ? Colors.yellow.shade700 : Colors.red);
return Container(
margin: EdgeInsets.only(bottom: 14 * context.sf),
margin: EdgeInsets.only(bottom: 14 * safeSf),
decoration: BoxDecoration(
color: Colors.white, borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade200), boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 8, offset: const Offset(0, 4))],
@@ -338,34 +340,34 @@ class _HomeScreenState extends State<HomeScreen> {
child: Column(
children: [
Padding(
padding: EdgeInsets.all(14 * context.sf),
padding: EdgeInsets.all(14 * safeSf),
child: Row(
children: [
Container(
width: 36 * context.sf, height: 36 * context.sf,
width: 36 * safeSf, height: 36 * safeSf,
decoration: BoxDecoration(color: statusColor.withOpacity(0.15), shape: BoxShape.circle),
child: Center(child: Text(result, style: TextStyle(color: statusColor, fontWeight: FontWeight.bold, fontSize: 16 * context.sf))),
child: Center(child: Text(result, style: TextStyle(color: statusColor, fontWeight: FontWeight.bold, fontSize: 16 * safeSf))),
),
SizedBox(width: 14 * context.sf),
SizedBox(width: 14 * safeSf),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(date, style: TextStyle(fontSize: 11 * context.sf, color: Colors.grey, fontWeight: FontWeight.w600)),
SizedBox(height: 6 * context.sf),
Text(date, style: TextStyle(fontSize: 11 * safeSf, color: Colors.grey, fontWeight: FontWeight.w600)),
SizedBox(height: 6 * safeSf),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: Text(_selectedTeamName == "Selecionar Equipa" ? "Minha Equipa" : _selectedTeamName, style: TextStyle(fontSize: 14 * context.sf, fontWeight: FontWeight.bold), maxLines: 1, overflow: TextOverflow.ellipsis)),
Expanded(child: Text(_selectedTeamName == "Selecionar Equipa" ? "Minha Equipa" : _selectedTeamName, style: TextStyle(fontSize: 14 * safeSf, fontWeight: FontWeight.bold), maxLines: 1, overflow: TextOverflow.ellipsis)),
Padding(
padding: EdgeInsets.symmetric(horizontal: 8 * context.sf),
padding: EdgeInsets.symmetric(horizontal: 8 * safeSf),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 8 * context.sf, vertical: 4 * context.sf),
padding: EdgeInsets.symmetric(horizontal: 8 * safeSf, vertical: 4 * safeSf),
decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(8)),
child: Text('$myScore - $oppScore', style: TextStyle(fontSize: 15 * context.sf, fontWeight: FontWeight.w900, letterSpacing: 1.5, color: Colors.black87)),
child: Text('$myScore - $oppScore', style: TextStyle(fontSize: 15 * safeSf, fontWeight: FontWeight.w900, letterSpacing: 1.5, color: Colors.black87)),
),
),
Expanded(child: Text(opponent, style: TextStyle(fontSize: 14 * context.sf, fontWeight: FontWeight.bold), textAlign: TextAlign.right, maxLines: 1, overflow: TextOverflow.ellipsis)),
Expanded(child: Text(opponent, style: TextStyle(fontSize: 14 * safeSf, fontWeight: FontWeight.bold), textAlign: TextAlign.right, maxLines: 1, overflow: TextOverflow.ellipsis)),
],
),
],
@@ -376,27 +378,27 @@ class _HomeScreenState extends State<HomeScreen> {
),
Divider(height: 1, color: Colors.grey.shade100, thickness: 1.5),
Container(
width: double.infinity, padding: EdgeInsets.symmetric(horizontal: 16 * context.sf, vertical: 12 * context.sf),
width: double.infinity, padding: EdgeInsets.symmetric(horizontal: 16 * safeSf, vertical: 12 * safeSf),
decoration: BoxDecoration(color: Colors.grey.shade50, borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(16), bottomRight: Radius.circular(16))),
child: Column(
children: [
Row(
children: [
Expanded(child: _buildGridStatRow(context, Icons.workspace_premium, Colors.amber.shade700, "MVP", mvp, isMvp: true)),
Expanded(child: _buildGridStatRow(context, Icons.shield, Colors.deepOrange.shade700, "Defesa", topDef)),
Expanded(child: _buildGridStatRow(Icons.workspace_premium, Colors.amber.shade700, "MVP", mvp, safeSf, isMvp: true)),
Expanded(child: _buildGridStatRow(Icons.shield, Colors.deepOrange.shade700, "Defesa", topDef, safeSf)),
],
),
SizedBox(height: 8 * context.sf),
SizedBox(height: 8 * safeSf),
Row(
children: [
Expanded(child: _buildGridStatRow(context, Icons.bolt, Colors.blue.shade700, "Pontos", topPts)),
Expanded(child: _buildGridStatRow(context, Icons.trending_up, Colors.purple.shade700, "Rebotes", topRbs)),
Expanded(child: _buildGridStatRow(Icons.bolt, Colors.blue.shade700, "Pontos", topPts, safeSf)),
Expanded(child: _buildGridStatRow(Icons.trending_up, Colors.purple.shade700, "Rebotes", topRbs, safeSf)),
],
),
SizedBox(height: 8 * context.sf),
SizedBox(height: 8 * safeSf),
Row(
children: [
Expanded(child: _buildGridStatRow(context, Icons.star, Colors.green.shade700, "Assists", topAst)),
Expanded(child: _buildGridStatRow(Icons.star, Colors.green.shade700, "Assists", topAst, safeSf)),
const Expanded(child: SizedBox()),
],
),
@@ -408,17 +410,17 @@ class _HomeScreenState extends State<HomeScreen> {
);
}
Widget _buildGridStatRow(BuildContext context, IconData icon, Color color, String label, String value, {bool isMvp = false}) {
Widget _buildGridStatRow(IconData icon, Color color, String label, String value, double safeSf, {bool isMvp = false}) {
return Row(
children: [
Icon(icon, size: 14 * context.sf, color: color),
SizedBox(width: 4 * context.sf),
Text('$label: ', style: TextStyle(fontSize: 11 * context.sf, color: Colors.grey.shade600, fontWeight: FontWeight.bold)),
Icon(icon, size: 14 * safeSf, color: color),
SizedBox(width: 4 * safeSf),
Text('$label: ', style: TextStyle(fontSize: 11 * safeSf, color: Colors.grey.shade600, fontWeight: FontWeight.bold)),
Expanded(
child: Text(
value,
style: TextStyle(
fontSize: 11 * context.sf,
fontSize: 11 * safeSf,
color: isMvp ? Colors.amber.shade900 : Colors.black87,
fontWeight: FontWeight.bold
),

View File

@@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../controllers/team_controller.dart';
import '../utils/size_extension.dart'; // 👇 A MAGIA DO SF!
import '../utils/size_extension.dart';
import 'dart:math' as math;
import '../controllers/placar_controller.dart'; // Para a classe ShotRecord
import '../pages/heatmap_page.dart'; // Para abrir a página do mapa
class StatusPage extends StatefulWidget {
const StatusPage({super.key});
@@ -19,19 +22,70 @@ class _StatusPageState extends State<StatusPage> {
String _sortColumn = 'pts';
bool _isAscending = false;
// 👇 NOVA FUNÇÃO: BUSCA OS LANÇAMENTOS DO JOGADOR NO SUPABASE E ABRE O MAPA
Future<void> _openPlayerHeatmap(String playerName) async {
if (_selectedTeamId == null) return;
// Mostra um loading rápido
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator(color: Color(0xFFE74C3C)))
);
try {
final response = await _supabase
.from('game_shots')
.select()
.eq('team_id', _selectedTeamId!)
.eq('player_name', playerName);
if (mounted) Navigator.pop(context); // Fecha o loading
if (response == null || (response as List).isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('O $playerName ainda não tem lançamentos registados!'))
);
}
return;
}
final List<ShotRecord> shots = (response as List).map((s) => ShotRecord(
relativeX: (s['relative_x'] as num).toDouble(),
relativeY: (s['relative_y'] as num).toDouble(),
isMake: s['is_make'] as bool,
playerName: s['player_name'],
)).toList();
if (mounted) {
Navigator.push(context, MaterialPageRoute(
builder: (_) => HeatmapPage(shots: shots, teamName: "Mapa de: $playerName")
));
}
} catch (e) {
if (mounted) Navigator.pop(context);
debugPrint("Erro ao carregar heatmap: $e");
}
}
@override
Widget build(BuildContext context) {
final double safeSf = math.min(context.sf, 1.15);
final double screenWidth = MediaQuery.of(context).size.width;
return Column(
children: [
// --- SELETOR DE EQUIPA ---
Padding(
padding: EdgeInsets.all(16.0 * context.sf),
padding: EdgeInsets.all(16.0 * safeSf),
child: InkWell(
onTap: () => _showTeamSelector(context),
onTap: () => _showTeamSelector(context, safeSf),
child: Container(
padding: EdgeInsets.all(12 * context.sf),
padding: EdgeInsets.all(12 * safeSf),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15 * context.sf),
borderRadius: BorderRadius.circular(15 * safeSf),
border: Border.all(color: Colors.grey.shade300),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 5)]
),
@@ -39,9 +93,9 @@ class _StatusPageState extends State<StatusPage> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(children: [
Icon(Icons.shield, color: const Color(0xFFE74C3C), size: 24 * context.sf),
SizedBox(width: 10 * context.sf),
Text(_selectedTeamName, style: TextStyle(fontSize: 16 * context.sf, fontWeight: FontWeight.bold))
Icon(Icons.shield, color: const Color(0xFFE74C3C), size: 24 * safeSf),
SizedBox(width: 10 * safeSf),
Text(_selectedTeamName, style: TextStyle(fontSize: 16 * safeSf, fontWeight: FontWeight.bold))
]),
const Icon(Icons.arrow_drop_down),
],
@@ -50,9 +104,10 @@ class _StatusPageState extends State<StatusPage> {
),
),
// --- TABELA DE ESTATÍSTICAS ---
Expanded(
child: _selectedTeamId == null
? Center(child: Text("Seleciona uma equipa acima.", style: TextStyle(color: Colors.grey, fontSize: 14 * context.sf)))
? Center(child: Text("Seleciona uma equipa acima.", style: TextStyle(color: Colors.grey, fontSize: 14 * safeSf)))
: StreamBuilder<List<Map<String, dynamic>>>(
stream: _supabase.from('player_stats_with_names').stream(primaryKey: ['id']).eq('team_id', _selectedTeamId!),
builder: (context, statsSnapshot) {
@@ -67,7 +122,7 @@ class _StatusPageState extends State<StatusPage> {
}
final membersData = membersSnapshot.data ?? [];
if (membersData.isEmpty) return Center(child: Text("Esta equipa não tem jogadores registados.", style: TextStyle(color: Colors.grey, fontSize: 14 * context.sf)));
if (membersData.isEmpty) return Center(child: Text("Esta equipa não tem jogadores registados.", style: TextStyle(color: Colors.grey, fontSize: 14 * safeSf)));
final statsData = statsSnapshot.data ?? [];
final gamesData = gamesSnapshot.data ?? [];
@@ -82,7 +137,7 @@ class _StatusPageState extends State<StatusPage> {
return _isAscending ? valA.compareTo(valB) : valB.compareTo(valA);
});
return _buildStatsGrid(context, playerTotals, teamTotals);
return _buildStatsGrid(context, playerTotals, teamTotals, safeSf, screenWidth);
}
);
}
@@ -94,29 +149,21 @@ class _StatusPageState extends State<StatusPage> {
);
}
// (Lógica de _aggregateStats e _calculateTeamTotals continua igual...)
List<Map<String, dynamic>> _aggregateStats(List<dynamic> stats, List<dynamic> games, List<dynamic> members) {
Map<String, Map<String, dynamic>> aggregated = {};
for (var member in members) {
String name = member['name']?.toString() ?? "Desconhecido";
aggregated[name] = {'name': name, 'j': 0, 'pts': 0, 'ast': 0, 'rbs': 0, 'stl': 0, 'blk': 0, 'mvp': 0, 'def': 0};
}
for (var row in stats) {
String name = row['player_name']?.toString() ?? "Desconhecido";
if (!aggregated.containsKey(name)) aggregated[name] = {'name': name, 'j': 0, 'pts': 0, 'ast': 0, 'rbs': 0, 'stl': 0, 'blk': 0, 'mvp': 0, 'def': 0};
aggregated[name]!['j'] += 1;
aggregated[name]!['pts'] += (row['pts'] ?? 0);
aggregated[name]!['ast'] += (row['ast'] ?? 0);
aggregated[name]!['rbs'] += (row['rbs'] ?? 0);
aggregated[name]!['stl'] += (row['stl'] ?? 0);
aggregated[name]!['blk'] += (row['blk'] ?? 0);
aggregated[name]!['j'] += 1; aggregated[name]!['pts'] += (row['pts'] ?? 0); aggregated[name]!['ast'] += (row['ast'] ?? 0);
aggregated[name]!['rbs'] += (row['rbs'] ?? 0); aggregated[name]!['stl'] += (row['stl'] ?? 0); aggregated[name]!['blk'] += (row['blk'] ?? 0);
}
for (var game in games) {
String? mvp = game['mvp_name'];
String? defRaw = game['top_def_name'];
String? mvp = game['mvp_name']; String? defRaw = game['top_def_name'];
if (mvp != null && aggregated.containsKey(mvp)) aggregated[mvp]!['mvp'] += 1;
if (defRaw != null) {
String defName = defRaw.split(' (')[0].trim();
@@ -134,92 +181,113 @@ class _StatusPageState extends State<StatusPage> {
return {'name': 'TOTAL EQUIPA', 'j': teamGames, 'pts': tPts, 'ast': tAst, 'rbs': tRbs, 'stl': tStl, 'blk': tBlk, 'mvp': tMvp, 'def': tDef};
}
Widget _buildStatsGrid(BuildContext context, List<Map<String, dynamic>> players, Map<String, dynamic> teamTotals) {
Widget _buildStatsGrid(BuildContext context, List<Map<String, dynamic>> players, Map<String, dynamic> teamTotals, double safeSf, double screenWidth) {
double dynamicSpacing = math.max(15 * safeSf, (screenWidth - (180 * safeSf)) / 8);
return Container(
color: Colors.white,
width: double.infinity,
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: screenWidth),
child: DataTable(
columnSpacing: 25 * context.sf,
columnSpacing: dynamicSpacing,
horizontalMargin: 20 * safeSf,
headingRowColor: MaterialStateProperty.all(Colors.grey.shade100),
dataRowHeight: 60 * context.sf,
dataRowHeight: 60 * safeSf,
columns: [
DataColumn(label: const Text('JOGADOR')),
_buildSortableColumn(context, 'J', 'j'),
_buildSortableColumn(context, 'PTS', 'pts'),
_buildSortableColumn(context, 'AST', 'ast'),
_buildSortableColumn(context, 'RBS', 'rbs'),
_buildSortableColumn(context, 'STL', 'stl'),
_buildSortableColumn(context, 'BLK', 'blk'),
_buildSortableColumn(context, 'DEF 🛡️', 'def'),
_buildSortableColumn(context, 'MVP 🏆', 'mvp'),
_buildSortableColumn('J', 'j', safeSf),
_buildSortableColumn('PTS', 'pts', safeSf),
_buildSortableColumn('AST', 'ast', safeSf),
_buildSortableColumn('RBS', 'rbs', safeSf),
_buildSortableColumn('STL', 'stl', safeSf),
_buildSortableColumn('BLK', 'blk', safeSf),
_buildSortableColumn('DEF 🛡️', 'def', safeSf),
_buildSortableColumn('MVP 🏆', 'mvp', safeSf),
],
rows: [
...players.map((player) => DataRow(cells: [
DataCell(Row(children: [CircleAvatar(radius: 15 * context.sf, backgroundColor: Colors.grey.shade200, child: Icon(Icons.person, size: 18 * context.sf)), SizedBox(width: 10 * context.sf), Text(player['name'], style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13 * context.sf))])),
DataCell(
// 👇 TORNEI O NOME CLICÁVEL PARA ABRIR O MAPA
InkWell(
onTap: () => _openPlayerHeatmap(player['name']),
child: Row(children: [
CircleAvatar(radius: 15 * safeSf, backgroundColor: Colors.grey.shade200, child: Icon(Icons.person, size: 18 * safeSf)),
SizedBox(width: 10 * safeSf),
Text(player['name'], style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13 * safeSf, color: Colors.blue.shade700))
]),
)
),
DataCell(Center(child: Text(player['j'].toString()))),
_buildStatCell(context, player['pts'], isHighlight: true),
_buildStatCell(context, player['ast']),
_buildStatCell(context, player['rbs']),
_buildStatCell(context, player['stl']),
_buildStatCell(context, player['blk']),
_buildStatCell(context, player['def'], isBlue: true),
_buildStatCell(context, player['mvp'], isGold: true),
_buildStatCell(player['pts'], safeSf, isHighlight: true),
_buildStatCell(player['ast'], safeSf),
_buildStatCell(player['rbs'], safeSf),
_buildStatCell(player['stl'], safeSf),
_buildStatCell(player['blk'], safeSf),
_buildStatCell(player['def'], safeSf, isBlue: true),
_buildStatCell(player['mvp'], safeSf, isGold: true),
])),
DataRow(
color: MaterialStateProperty.all(Colors.grey.shade50),
cells: [
DataCell(Text('TOTAL EQUIPA', style: TextStyle(fontWeight: FontWeight.w900, color: Colors.black, fontSize: 12 * context.sf))),
DataCell(Text('TOTAL EQUIPA', style: TextStyle(fontWeight: FontWeight.w900, color: Colors.black, fontSize: 12 * safeSf))),
DataCell(Center(child: Text(teamTotals['j'].toString(), style: const TextStyle(fontWeight: FontWeight.bold)))),
_buildStatCell(context, teamTotals['pts'], isHighlight: true),
_buildStatCell(context, teamTotals['ast']),
_buildStatCell(context, teamTotals['rbs']),
_buildStatCell(context, teamTotals['stl']),
_buildStatCell(context, teamTotals['blk']),
_buildStatCell(context, teamTotals['def'], isBlue: true),
_buildStatCell(context, teamTotals['mvp'], isGold: true),
_buildStatCell(teamTotals['pts'], safeSf, isHighlight: true),
_buildStatCell(teamTotals['ast'], safeSf),
_buildStatCell(teamTotals['rbs'], safeSf),
_buildStatCell(teamTotals['stl'], safeSf),
_buildStatCell(teamTotals['blk'], safeSf),
_buildStatCell(teamTotals['def'], safeSf, isBlue: true),
_buildStatCell(teamTotals['mvp'], safeSf, isGold: true),
]
)
],
),
),
),
),
);
}
DataColumn _buildSortableColumn(BuildContext context, String title, String sortKey) {
// (Outras funções de build continuam igual...)
DataColumn _buildSortableColumn(String title, String sortKey, double safeSf) {
return DataColumn(label: InkWell(
onTap: () => setState(() {
if (_sortColumn == sortKey) _isAscending = !_isAscending;
else { _sortColumn = sortKey; _isAscending = false; }
}),
child: Row(children: [
Text(title, style: TextStyle(fontSize: 12 * context.sf, fontWeight: FontWeight.bold)),
if (_sortColumn == sortKey) Icon(_isAscending ? Icons.arrow_drop_up : Icons.arrow_drop_down, size: 18 * context.sf, color: const Color(0xFFE74C3C)),
]),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: TextStyle(fontSize: 12 * safeSf, fontWeight: FontWeight.bold)),
if (_sortColumn == sortKey) Icon(_isAscending ? Icons.arrow_drop_up : Icons.arrow_drop_down, size: 18 * safeSf, color: const Color(0xFFE74C3C)),
]
),
));
}
DataCell _buildStatCell(BuildContext context, int value, {bool isHighlight = false, bool isGold = false, bool isBlue = false}) {
DataCell _buildStatCell(int value, double safeSf, {bool isHighlight = false, bool isGold = false, bool isBlue = false}) {
return DataCell(Center(child: Container(
padding: EdgeInsets.symmetric(horizontal: 8 * context.sf, vertical: 4 * context.sf),
padding: EdgeInsets.symmetric(horizontal: 8 * safeSf, vertical: 4 * safeSf),
decoration: BoxDecoration(color: isGold && value > 0 ? Colors.amber.withOpacity(0.2) : (isBlue && value > 0 ? Colors.blue.withOpacity(0.1) : Colors.transparent), borderRadius: BorderRadius.circular(6)),
child: Text(value == 0 ? "-" : value.toString(), style: TextStyle(
fontWeight: (isHighlight || isGold || isBlue) ? FontWeight.w900 : FontWeight.w600,
fontSize: 14 * context.sf, color: isGold && value > 0 ? Colors.orange.shade900 : (isBlue && value > 0 ? Colors.blue.shade800 : (isHighlight ? Colors.green.shade700 : Colors.black87))
fontSize: 14 * safeSf, color: isGold && value > 0 ? Colors.orange.shade900 : (isBlue && value > 0 ? Colors.blue.shade800 : (isHighlight ? Colors.green.shade700 : Colors.black87))
)),
)));
}
void _showTeamSelector(BuildContext context) {
void _showTeamSelector(BuildContext context, double safeSf) {
showModalBottomSheet(context: context, builder: (context) => StreamBuilder<List<Map<String, dynamic>>>(
stream: _teamController.teamsStream,
builder: (context, snapshot) {
final teams = snapshot.data ?? [];
return ListView.builder(itemCount: teams.length, itemBuilder: (context, i) => ListTile(
title: Text(teams[i]['name']),
title: Text(teams[i]['name'], style: TextStyle(fontSize: 15 * safeSf)),
onTap: () { setState(() { _selectedTeamId = teams[i]['id']; _selectedTeamName = teams[i]['name']; }); Navigator.pop(context); },
));
},

View File

@@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
import 'package:playmaker/screens/team_stats_page.dart';
import '../controllers/team_controller.dart';
import '../models/team_model.dart';
import '../utils/size_extension.dart'; // 👇 IMPORTANTE: O TEU NOVO SUPERPODER
import '../utils/size_extension.dart';
import 'dart:math' as math;
class TeamsPage extends StatefulWidget {
const TeamsPage({super.key});
@@ -121,7 +122,6 @@ class _TeamsPageState extends State<TeamsPage> {
@override
Widget build(BuildContext context) {
// 🔥 OLHA QUE LIMPEZA: Já não precisamos de calcular nada aqui!
return Scaffold(
backgroundColor: const Color(0xFFF5F7FA),
appBar: AppBar(
@@ -142,7 +142,7 @@ class _TeamsPageState extends State<TeamsPage> {
],
),
floatingActionButton: FloatingActionButton(
heroTag: 'add_team_btn', // 👇 A MÁGICA ESTÁ AQUI!
heroTag: 'add_team_btn',
backgroundColor: const Color(0xFFE74C3C),
child: Icon(Icons.add, color: Colors.white, size: 24 * context.sf),
onPressed: () => _showCreateDialog(context),
@@ -151,30 +151,33 @@ class _TeamsPageState extends State<TeamsPage> {
}
Widget _buildSearchBar() {
final double safeSf = math.min(context.sf, 1.15); // Travão para a barra não ficar com margens gigantes
return Padding(
padding: EdgeInsets.all(16.0 * context.sf),
padding: EdgeInsets.all(16.0 * safeSf),
child: TextField(
controller: _searchController,
onChanged: (v) => setState(() => _searchQuery = v.toLowerCase()),
style: TextStyle(fontSize: 16 * context.sf),
style: TextStyle(fontSize: 16 * safeSf),
decoration: InputDecoration(
hintText: 'Pesquisar equipa...',
hintStyle: TextStyle(fontSize: 16 * context.sf),
prefixIcon: Icon(Icons.search, color: const Color(0xFFE74C3C), size: 22 * context.sf),
hintStyle: TextStyle(fontSize: 16 * safeSf),
prefixIcon: Icon(Icons.search, color: const Color(0xFFE74C3C), size: 22 * safeSf),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(15 * context.sf), borderSide: BorderSide.none),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(15 * safeSf), borderSide: BorderSide.none),
),
),
);
}
Widget _buildTeamsList() {
final double safeSf = math.min(context.sf, 1.15);
return StreamBuilder<List<Map<String, dynamic>>>(
stream: controller.teamsStream,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator());
if (!snapshot.hasData || snapshot.data!.isEmpty) return Center(child: Text("Nenhuma equipa encontrada.", style: TextStyle(fontSize: 16 * context.sf)));
if (!snapshot.hasData || snapshot.data!.isEmpty) return Center(child: Text("Nenhuma equipa encontrada.", style: TextStyle(fontSize: 16 * safeSf)));
var data = List<Map<String, dynamic>>.from(snapshot.data!);
@@ -191,7 +194,7 @@ class _TeamsPageState extends State<TeamsPage> {
});
return ListView.builder(
padding: EdgeInsets.symmetric(horizontal: 16 * context.sf),
padding: EdgeInsets.symmetric(horizontal: 16 * safeSf), // Margem perfeitamente alinhada
itemCount: data.length,
itemBuilder: (context, index) {
final team = Team.fromMap(data[index]);
@@ -224,68 +227,70 @@ class TeamCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final double safeSf = math.min(context.sf, 1.15); // O verdadeiro salvador do tablet
return Card(
color: Colors.white, elevation: 3, margin: EdgeInsets.only(bottom: 12 * context.sf),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * context.sf)),
color: Colors.white, elevation: 3, margin: EdgeInsets.only(bottom: 12 * safeSf),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * safeSf)),
child: ListTile(
contentPadding: EdgeInsets.symmetric(horizontal: 16 * context.sf, vertical: 8 * context.sf),
contentPadding: EdgeInsets.symmetric(horizontal: 16 * safeSf, vertical: 8 * safeSf),
leading: Stack(
clipBehavior: Clip.none,
children: [
CircleAvatar(
radius: 28 * context.sf, backgroundColor: Colors.grey[200],
radius: 28 * safeSf, backgroundColor: Colors.grey[200],
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: 24 * context.sf)) : null,
child: (team.imageUrl.isEmpty || !team.imageUrl.startsWith('http')) ? Text(team.imageUrl.isEmpty ? "🏀" : team.imageUrl, style: TextStyle(fontSize: 24 * safeSf)) : null,
),
Positioned(
left: -15 * context.sf, top: -10 * context.sf,
left: -15 * safeSf, top: -10 * safeSf,
child: IconButton(
icon: Icon(team.isFavorite ? Icons.star : Icons.star_border, color: team.isFavorite ? Colors.amber : Colors.black.withOpacity(0.1), size: 28 * context.sf, shadows: [Shadow(color: Colors.black.withOpacity(team.isFavorite ? 0.3 : 0.1), blurRadius: 4 * context.sf)]),
icon: Icon(team.isFavorite ? Icons.star : Icons.star_border, color: team.isFavorite ? Colors.amber : Colors.black.withOpacity(0.1), size: 28 * safeSf, shadows: [Shadow(color: Colors.black.withOpacity(team.isFavorite ? 0.3 : 0.1), blurRadius: 4 * safeSf)]),
onPressed: onFavoriteTap,
),
),
],
),
title: Text(team.name, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16 * context.sf), overflow: TextOverflow.ellipsis),
title: Text(team.name, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16 * safeSf), overflow: TextOverflow.ellipsis),
subtitle: Padding(
padding: EdgeInsets.only(top: 6.0 * context.sf),
padding: EdgeInsets.only(top: 6.0 * safeSf),
child: Row(
children: [
Icon(Icons.groups_outlined, size: 16 * context.sf, color: Colors.grey),
SizedBox(width: 4 * context.sf),
Icon(Icons.groups_outlined, size: 16 * safeSf, color: Colors.grey),
SizedBox(width: 4 * safeSf),
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 ? Colors.green[700] : Colors.orange, fontWeight: FontWeight.bold, fontSize: 13 * context.sf));
return Text("$count Jogs.", style: TextStyle(color: count > 0 ? Colors.green[700] : Colors.orange, fontWeight: FontWeight.bold, fontSize: 13 * safeSf));
},
),
SizedBox(width: 8 * context.sf),
Expanded(child: Text("| ${team.season}", style: TextStyle(color: Colors.grey, fontSize: 13 * context.sf), overflow: TextOverflow.ellipsis)),
SizedBox(width: 8 * safeSf),
Expanded(child: Text("| ${team.season}", style: TextStyle(color: Colors.grey, fontSize: 13 * safeSf), overflow: TextOverflow.ellipsis)),
],
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(tooltip: 'Ver Estatísticas', icon: Icon(Icons.bar_chart_rounded, color: Colors.blue, size: 24 * context.sf), onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (context) => TeamStatsPage(team: team)))),
IconButton(tooltip: 'Eliminar Equipa', icon: Icon(Icons.delete_outline, color: const Color(0xFFE74C3C), size: 24 * context.sf), onPressed: () => _confirmDelete(context)),
IconButton(tooltip: 'Ver Estatísticas', icon: Icon(Icons.bar_chart_rounded, color: Colors.blue, size: 24 * safeSf), onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (context) => TeamStatsPage(team: team)))),
IconButton(tooltip: 'Eliminar Equipa', icon: Icon(Icons.delete_outline, color: const Color(0xFFE74C3C), size: 24 * safeSf), onPressed: () => _confirmDelete(context, safeSf)),
],
),
),
);
}
void _confirmDelete(BuildContext context) {
void _confirmDelete(BuildContext context, double safeSf) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Eliminar Equipa?', style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold)),
content: Text('Tens a certeza que queres eliminar "${team.name}"?', style: TextStyle(fontSize: 14 * context.sf)),
title: Text('Eliminar Equipa?', style: TextStyle(fontSize: 18 * safeSf, fontWeight: FontWeight.bold)),
content: Text('Tens a certeza que queres eliminar "${team.name}"?', style: TextStyle(fontSize: 14 * safeSf)),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancelar', style: TextStyle(fontSize: 14 * context.sf))),
TextButton(onPressed: () { controller.deleteTeam(team.id); Navigator.pop(context); }, child: Text('Eliminar', style: TextStyle(color: Colors.red, fontSize: 14 * context.sf))),
TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancelar', style: TextStyle(fontSize: 14 * safeSf))),
TextButton(onPressed: () { controller.deleteTeam(team.id); Navigator.pop(context); }, child: Text('Eliminar', style: TextStyle(color: Colors.red, fontSize: 14 * safeSf))),
],
),
);
@@ -308,32 +313,37 @@ class _CreateTeamDialogState extends State<CreateTeamDialog> {
@override
Widget build(BuildContext context) {
final double safeSf = math.min(context.sf, 1.15);
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * context.sf)),
title: Text('Nova Equipa', style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * safeSf)),
title: Text('Nova Equipa', style: TextStyle(fontSize: 18 * safeSf, fontWeight: FontWeight.bold)),
content: SingleChildScrollView(
child: Container(
constraints: BoxConstraints(maxWidth: 450 * safeSf), // O popup pode ter um travão para não cobrir a tela toda, fica mais bonito
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: _nameController, style: TextStyle(fontSize: 14 * context.sf), decoration: InputDecoration(labelText: 'Nome da Equipa', labelStyle: TextStyle(fontSize: 14 * context.sf)), textCapitalization: TextCapitalization.words),
SizedBox(height: 15 * context.sf),
TextField(controller: _nameController, style: TextStyle(fontSize: 14 * safeSf), decoration: InputDecoration(labelText: 'Nome da Equipa', labelStyle: TextStyle(fontSize: 14 * safeSf)), textCapitalization: TextCapitalization.words),
SizedBox(height: 15 * safeSf),
DropdownButtonFormField<String>(
value: _selectedSeason, decoration: InputDecoration(labelText: 'Temporada', labelStyle: TextStyle(fontSize: 14 * context.sf)),
style: TextStyle(fontSize: 14 * context.sf, color: Colors.black87),
value: _selectedSeason, decoration: InputDecoration(labelText: 'Temporada', labelStyle: TextStyle(fontSize: 14 * safeSf)),
style: TextStyle(fontSize: 14 * safeSf, color: Colors.black87),
items: ['2023/24', '2024/25', '2025/26'].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
onChanged: (val) => setState(() => _selectedSeason = val!),
),
SizedBox(height: 15 * context.sf),
TextField(controller: _imageController, style: TextStyle(fontSize: 14 * context.sf), decoration: InputDecoration(labelText: 'URL Imagem ou Emoji', labelStyle: TextStyle(fontSize: 14 * context.sf), hintText: 'Ex: 🏀 ou https://...', hintStyle: TextStyle(fontSize: 14 * context.sf))),
SizedBox(height: 15 * safeSf),
TextField(controller: _imageController, style: TextStyle(fontSize: 14 * safeSf), decoration: InputDecoration(labelText: 'URL Imagem ou Emoji', labelStyle: TextStyle(fontSize: 14 * safeSf), hintText: 'Ex: 🏀 ou https://...', hintStyle: TextStyle(fontSize: 14 * safeSf))),
],
),
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancelar', style: TextStyle(fontSize: 14 * context.sf))),
TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancelar', style: TextStyle(fontSize: 14 * safeSf))),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFFE74C3C), padding: EdgeInsets.symmetric(horizontal: 16 * context.sf, vertical: 10 * context.sf)),
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFFE74C3C), padding: EdgeInsets.symmetric(horizontal: 16 * safeSf, vertical: 10 * safeSf)),
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 * context.sf)),
child: Text('Criar', style: TextStyle(color: Colors.white, fontSize: 14 * safeSf)),
),
],
);

View File

@@ -1,18 +1,21 @@
import 'package:flutter/material.dart';
import 'package:playmaker/controllers/login_controller.dart';
import 'package:playmaker/pages/RegisterPage.dart';
import '../utils/size_extension.dart'; // 👇 O NOSSO SUPERPODER!
import '../utils/size_extension.dart';
import 'dart:math' as math; // 👇 IMPORTANTE PARA O TRAVÃO NO TABLET!
class BasketTrackHeader extends StatelessWidget {
const BasketTrackHeader({super.key});
@override
Widget build(BuildContext context) {
final double safeSf = math.min(context.sf, 1.15); // TRAVÃO DE MÃO
return Column(
children: [
SizedBox(
width: 200 * context.sf, // Ajusta o tamanho da imagem suavemente
height: 200 * context.sf,
width: 200 * safeSf,
height: 200 * safeSf,
child: Image.asset(
'assets/playmaker-logos.png',
fit: BoxFit.contain,
@@ -21,16 +24,16 @@ class BasketTrackHeader extends StatelessWidget {
Text(
'BasketTrack',
style: TextStyle(
fontSize: 36 * context.sf,
fontSize: 36 * safeSf,
fontWeight: FontWeight.bold,
color: Colors.grey[900],
),
),
SizedBox(height: 6 * context.sf),
SizedBox(height: 6 * safeSf),
Text(
'Gere as tuas equipas e estatísticas',
style: TextStyle(
fontSize: 16 * context.sf,
fontSize: 16 * safeSf,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
@@ -48,40 +51,42 @@ class LoginFormFields extends StatelessWidget {
@override
Widget build(BuildContext context) {
final double safeSf = math.min(context.sf, 1.15);
return Column(
children: [
TextField(
controller: controller.emailController,
style: TextStyle(fontSize: 15 * context.sf),
style: TextStyle(fontSize: 15 * safeSf),
decoration: InputDecoration(
labelText: 'E-mail',
labelStyle: TextStyle(fontSize: 15 * context.sf),
prefixIcon: Icon(Icons.email_outlined, size: 22 * context.sf),
labelStyle: TextStyle(fontSize: 15 * safeSf),
prefixIcon: Icon(Icons.email_outlined, size: 22 * safeSf),
errorText: controller.emailError,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12 * context.sf)),
contentPadding: EdgeInsets.symmetric(vertical: 18 * context.sf, horizontal: 16 * context.sf),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12 * safeSf)),
contentPadding: EdgeInsets.symmetric(vertical: 18 * safeSf, horizontal: 16 * safeSf),
),
keyboardType: TextInputType.emailAddress,
),
SizedBox(height: 20 * context.sf),
SizedBox(height: 20 * safeSf),
TextField(
controller: controller.passwordController,
obscureText: controller.obscurePassword,
style: TextStyle(fontSize: 15 * context.sf),
style: TextStyle(fontSize: 15 * safeSf),
decoration: InputDecoration(
labelText: 'Palavra-passe',
labelStyle: TextStyle(fontSize: 15 * context.sf),
prefixIcon: Icon(Icons.lock_outlined, size: 22 * context.sf),
labelStyle: TextStyle(fontSize: 15 * safeSf),
prefixIcon: Icon(Icons.lock_outlined, size: 22 * safeSf),
errorText: controller.passwordError,
suffixIcon: IconButton(
icon: Icon(
controller.obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined,
size: 22 * context.sf
size: 22 * safeSf
),
onPressed: controller.togglePasswordVisibility,
),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12 * context.sf)),
contentPadding: EdgeInsets.symmetric(vertical: 18 * context.sf, horizontal: 16 * context.sf),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12 * safeSf)),
contentPadding: EdgeInsets.symmetric(vertical: 18 * safeSf, horizontal: 16 * safeSf),
),
),
],
@@ -97,9 +102,11 @@ class LoginButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final double safeSf = math.min(context.sf, 1.15);
return SizedBox(
width: double.infinity,
height: 58 * context.sf,
height: 58 * safeSf,
child: ElevatedButton(
onPressed: controller.isLoading ? null : () async {
final success = await controller.login();
@@ -108,15 +115,15 @@ class LoginButton extends StatelessWidget {
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFE74C3C),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14 * context.sf)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14 * safeSf)),
elevation: 3,
),
child: controller.isLoading
? SizedBox(
width: 28 * context.sf, height: 28 * context.sf,
width: 28 * safeSf, height: 28 * safeSf,
child: const CircularProgressIndicator(strokeWidth: 3, valueColor: AlwaysStoppedAnimation<Color>(Colors.white)),
)
: Text('Entrar', style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold)),
: Text('Entrar', style: TextStyle(fontSize: 18 * safeSf, fontWeight: FontWeight.bold)),
),
);
}
@@ -127,19 +134,21 @@ class CreateAccountButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final double safeSf = math.min(context.sf, 1.15);
return SizedBox(
width: double.infinity,
height: 58 * context.sf,
height: 58 * safeSf,
child: OutlinedButton(
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (context) => const RegisterPage()));
},
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFE74C3C),
side: BorderSide(color: const Color(0xFFE74C3C), width: 2 * context.sf),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14 * context.sf)),
side: BorderSide(color: const Color(0xFFE74C3C), width: 2 * safeSf),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14 * safeSf)),
),
child: Text('Criar Conta', style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold)),
child: Text('Criar Conta', style: TextStyle(fontSize: 18 * safeSf, fontWeight: FontWeight.bold)),
),
);
}

View File

@@ -1,24 +1,27 @@
import 'package:flutter/material.dart';
import '../controllers/register_controller.dart';
import '../utils/size_extension.dart'; // 👇 O NOSSO SUPERPODER!
import '../utils/size_extension.dart';
import 'dart:math' as math; // 👇 IMPORTANTE
class RegisterHeader extends StatelessWidget {
const RegisterHeader({super.key});
@override
Widget build(BuildContext context) {
final double safeSf = math.min(context.sf, 1.15); // TRAVÃO
return Column(
children: [
Icon(Icons.person_add_outlined, size: 100 * context.sf, color: const Color(0xFFE74C3C)),
SizedBox(height: 10 * context.sf),
Icon(Icons.person_add_outlined, size: 100 * safeSf, color: const Color(0xFFE74C3C)),
SizedBox(height: 10 * safeSf),
Text(
'Nova Conta',
style: TextStyle(fontSize: 36 * context.sf, fontWeight: FontWeight.bold, color: Colors.grey[900]),
style: TextStyle(fontSize: 36 * safeSf, fontWeight: FontWeight.bold, color: Colors.grey[900]),
),
SizedBox(height: 5 * context.sf),
SizedBox(height: 5 * safeSf),
Text(
'Cria o teu perfil no BasketTrack',
style: TextStyle(fontSize: 16 * context.sf, color: Colors.grey[600], fontWeight: FontWeight.w500),
style: TextStyle(fontSize: 16 * safeSf, color: Colors.grey[600], fontWeight: FontWeight.w500),
textAlign: TextAlign.center,
),
],
@@ -39,72 +42,77 @@ class _RegisterFormFieldsState extends State<RegisterFormFields> {
@override
Widget build(BuildContext context) {
return Form(
final double safeSf = math.min(context.sf, 1.15); // TRAVÃO
return Container(
constraints: BoxConstraints(maxWidth: 450 * safeSf), // LIMITA A LARGURA NO TABLET
child: Form(
key: widget.controller.formKey,
child: Column(
children: [
TextFormField(
controller: widget.controller.nameController,
style: TextStyle(fontSize: 15 * context.sf),
style: TextStyle(fontSize: 15 * safeSf),
decoration: InputDecoration(
labelText: 'Nome Completo',
labelStyle: TextStyle(fontSize: 15 * context.sf),
prefixIcon: Icon(Icons.person_outline, size: 22 * context.sf),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12 * context.sf)),
contentPadding: EdgeInsets.symmetric(vertical: 18 * context.sf, horizontal: 16 * context.sf),
labelStyle: TextStyle(fontSize: 15 * safeSf),
prefixIcon: Icon(Icons.person_outline, size: 22 * safeSf),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12 * safeSf)),
contentPadding: EdgeInsets.symmetric(vertical: 18 * safeSf, horizontal: 16 * safeSf),
),
),
SizedBox(height: 20 * context.sf),
SizedBox(height: 20 * safeSf),
TextFormField(
controller: widget.controller.emailController,
validator: widget.controller.validateEmail,
style: TextStyle(fontSize: 15 * context.sf),
style: TextStyle(fontSize: 15 * safeSf),
decoration: InputDecoration(
labelText: 'E-mail',
labelStyle: TextStyle(fontSize: 15 * context.sf),
prefixIcon: Icon(Icons.email_outlined, size: 22 * context.sf),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12 * context.sf)),
contentPadding: EdgeInsets.symmetric(vertical: 18 * context.sf, horizontal: 16 * context.sf),
labelStyle: TextStyle(fontSize: 15 * safeSf),
prefixIcon: Icon(Icons.email_outlined, size: 22 * safeSf),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12 * safeSf)),
contentPadding: EdgeInsets.symmetric(vertical: 18 * safeSf, horizontal: 16 * safeSf),
),
keyboardType: TextInputType.emailAddress,
),
SizedBox(height: 20 * context.sf),
SizedBox(height: 20 * safeSf),
TextFormField(
controller: widget.controller.passwordController,
obscureText: _obscurePassword,
validator: widget.controller.validatePassword,
style: TextStyle(fontSize: 15 * context.sf),
style: TextStyle(fontSize: 15 * safeSf),
decoration: InputDecoration(
labelText: 'Palavra-passe',
labelStyle: TextStyle(fontSize: 15 * context.sf),
prefixIcon: Icon(Icons.lock_outlined, size: 22 * context.sf),
labelStyle: TextStyle(fontSize: 15 * safeSf),
prefixIcon: Icon(Icons.lock_outlined, size: 22 * safeSf),
suffixIcon: IconButton(
icon: Icon(_obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 22 * context.sf),
icon: Icon(_obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 22 * safeSf),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12 * context.sf)),
contentPadding: EdgeInsets.symmetric(vertical: 18 * context.sf, horizontal: 16 * context.sf),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12 * safeSf)),
contentPadding: EdgeInsets.symmetric(vertical: 18 * safeSf, horizontal: 16 * safeSf),
),
),
SizedBox(height: 20 * context.sf),
SizedBox(height: 20 * safeSf),
TextFormField(
controller: widget.controller.confirmPasswordController,
obscureText: _obscurePassword,
validator: widget.controller.validateConfirmPassword,
style: TextStyle(fontSize: 15 * context.sf),
style: TextStyle(fontSize: 15 * safeSf),
decoration: InputDecoration(
labelText: 'Confirmar Palavra-passe',
labelStyle: TextStyle(fontSize: 15 * context.sf),
prefixIcon: Icon(Icons.lock_clock_outlined, size: 22 * context.sf),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12 * context.sf)),
contentPadding: EdgeInsets.symmetric(vertical: 18 * context.sf, horizontal: 16 * context.sf),
labelStyle: TextStyle(fontSize: 15 * safeSf),
prefixIcon: Icon(Icons.lock_clock_outlined, size: 22 * safeSf),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12 * safeSf)),
contentPadding: EdgeInsets.symmetric(vertical: 18 * safeSf, horizontal: 16 * safeSf),
),
),
],
),
),
);
}
}
@@ -115,23 +123,25 @@ class RegisterButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
height: 58 * context.sf,
final double safeSf = math.min(context.sf, 1.15); // TRAVÃO
return Container(
constraints: BoxConstraints(maxWidth: 450 * safeSf), // LIMITA LARGURA
height: 58 * safeSf,
child: ElevatedButton(
onPressed: controller.isLoading ? null : () => controller.signUp(context),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFE74C3C),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14 * context.sf)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14 * safeSf)),
elevation: 3,
),
child: controller.isLoading
? SizedBox(
width: 28 * context.sf, height: 28 * context.sf,
width: 28 * safeSf, height: 28 * safeSf,
child: const CircularProgressIndicator(strokeWidth: 3, valueColor: AlwaysStoppedAnimation<Color>(Colors.white)),
)
: Text('Criar Conta', style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold)),
: Text('Criar Conta', style: TextStyle(fontSize: 18 * safeSf, fontWeight: FontWeight.bold)),
),
);
}

View File

@@ -2,12 +2,13 @@ import 'package:flutter/material.dart';
import 'package:playmaker/screens/team_stats_page.dart';
import '../models/team_model.dart';
import '../controllers/team_controller.dart';
import 'dart:math' as math; // 👇 IMPORTANTE PARA O TRAVÃO DE MÃO
class TeamCard extends StatelessWidget {
final Team team;
final TeamController controller;
final VoidCallback onFavoriteTap;
final double sf; // <-- Variável de escala
final double sf; // <-- Variável de escala original
const TeamCard({
super.key,
@@ -19,20 +20,24 @@ class TeamCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 👇 O SEGREDO ESTÁ AQUI: TRAVÃO DE MÃO PARA TABLETS 👇
// O sf pode crescer, mas NUNCA vai ser maior que 1.15!
final double safeSf = math.min(sf, 1.15);
return Card(
color: Colors.white,
elevation: 3,
margin: EdgeInsets.only(bottom: 12 * sf),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * sf)),
margin: EdgeInsets.only(bottom: 12 * safeSf),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * safeSf)),
child: ListTile(
contentPadding: EdgeInsets.symmetric(horizontal: 16 * sf, vertical: 8 * sf),
contentPadding: EdgeInsets.symmetric(horizontal: 16 * safeSf, vertical: 8 * safeSf),
// --- 1. IMAGEM + FAVORITO ---
leading: Stack(
clipBehavior: Clip.none,
children: [
CircleAvatar(
radius: 28 * sf,
radius: 28 * safeSf,
backgroundColor: Colors.grey[200],
backgroundImage: (team.imageUrl.isNotEmpty && team.imageUrl.startsWith('http'))
? NetworkImage(team.imageUrl)
@@ -40,22 +45,22 @@ class TeamCard extends StatelessWidget {
child: (team.imageUrl.isEmpty || !team.imageUrl.startsWith('http'))
? Text(
team.imageUrl.isEmpty ? "🏀" : team.imageUrl,
style: TextStyle(fontSize: 24 * sf),
style: TextStyle(fontSize: 24 * safeSf),
)
: null,
),
Positioned(
left: -15 * sf,
top: -10 * sf,
left: -15 * safeSf,
top: -10 * safeSf,
child: IconButton(
icon: Icon(
team.isFavorite ? Icons.star : Icons.star_border,
color: team.isFavorite ? Colors.amber : Colors.black.withOpacity(0.1),
size: 28 * sf,
size: 28 * safeSf,
shadows: [
Shadow(
color: Colors.black.withOpacity(team.isFavorite ? 0.3 : 0.1),
blurRadius: 4 * sf,
blurRadius: 4 * safeSf,
),
],
),
@@ -68,40 +73,39 @@ class TeamCard extends StatelessWidget {
// --- 2. TÍTULO ---
title: Text(
team.name,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16 * sf),
overflow: TextOverflow.ellipsis, // Previne overflows em nomes longos
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16 * safeSf),
overflow: TextOverflow.ellipsis,
),
// --- 3. SUBTÍTULO (Contagem + Época em TEMPO REAL) ---
subtitle: Padding(
padding: EdgeInsets.only(top: 6.0 * sf),
padding: EdgeInsets.only(top: 6.0 * safeSf),
child: Row(
children: [
Icon(Icons.groups_outlined, size: 16 * sf, color: Colors.grey),
SizedBox(width: 4 * sf),
Icon(Icons.groups_outlined, size: 16 * safeSf, color: Colors.grey),
SizedBox(width: 4 * safeSf),
// 👇 A CORREÇÃO ESTÁ AQUI: StreamBuilder em vez de FutureBuilder 👇
StreamBuilder<int>(
stream: controller.getPlayerCountStream(team.id),
initialData: 0,
builder: (context, snapshot) {
final count = snapshot.data ?? 0;
return Text(
"$count Jogs.", // Abreviado para poupar espaço
"$count Jogs.",
style: TextStyle(
color: count > 0 ? Colors.green[700] : Colors.orange,
fontWeight: FontWeight.bold,
fontSize: 13 * sf,
fontSize: 13 * safeSf,
),
);
},
),
SizedBox(width: 8 * sf),
Expanded( // Garante que a temporada se adapta se faltar espaço
SizedBox(width: 8 * safeSf),
Expanded(
child: Text(
"| ${team.season}",
style: TextStyle(color: Colors.grey, fontSize: 13 * sf),
style: TextStyle(color: Colors.grey, fontSize: 13 * safeSf),
overflow: TextOverflow.ellipsis,
),
),
@@ -111,11 +115,11 @@ class TeamCard extends StatelessWidget {
// --- 4. BOTÕES (Estatísticas e Apagar) ---
trailing: Row(
mainAxisSize: MainAxisSize.min, // <-- ISTO RESOLVE O OVERFLOW DAS RISCAS AMARELAS
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Ver Estatísticas',
icon: Icon(Icons.bar_chart_rounded, color: Colors.blue, size: 24 * sf),
icon: Icon(Icons.bar_chart_rounded, color: Colors.blue, size: 24 * safeSf),
onPressed: () {
Navigator.push(
context,
@@ -127,8 +131,8 @@ class TeamCard extends StatelessWidget {
),
IconButton(
tooltip: 'Eliminar Equipa',
icon: Icon(Icons.delete_outline, color: const Color(0xFFE74C3C), size: 24 * sf),
onPressed: () => _confirmDelete(context),
icon: Icon(Icons.delete_outline, color: const Color(0xFFE74C3C), size: 24 * safeSf),
onPressed: () => _confirmDelete(context, safeSf),
),
],
),
@@ -136,23 +140,23 @@ class TeamCard extends StatelessWidget {
);
}
void _confirmDelete(BuildContext context) {
void _confirmDelete(BuildContext context, double safeSf) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Eliminar Equipa?', style: TextStyle(fontSize: 18 * sf, fontWeight: FontWeight.bold)),
content: Text('Tens a certeza que queres eliminar "${team.name}"?', style: TextStyle(fontSize: 14 * sf)),
title: Text('Eliminar Equipa?', style: TextStyle(fontSize: 18 * safeSf, fontWeight: FontWeight.bold)),
content: Text('Tens a certeza que queres eliminar "${team.name}"?', style: TextStyle(fontSize: 14 * safeSf)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancelar', style: TextStyle(fontSize: 14 * sf)),
child: Text('Cancelar', style: TextStyle(fontSize: 14 * safeSf)),
),
TextButton(
onPressed: () {
controller.deleteTeam(team.id);
Navigator.pop(context);
},
child: Text('Eliminar', style: TextStyle(color: Colors.red, fontSize: 14 * sf)),
child: Text('Eliminar', style: TextStyle(color: Colors.red, fontSize: 14 * safeSf)),
),
],
),
@@ -163,7 +167,7 @@ class TeamCard extends StatelessWidget {
// --- DIALOG DE CRIAÇÃO ---
class CreateTeamDialog extends StatefulWidget {
final Function(String name, String season, String imageUrl) onConfirm;
final double sf; // Recebe a escala
final double sf;
const CreateTeamDialog({super.key, required this.onConfirm, required this.sf});
@@ -178,58 +182,65 @@ class _CreateTeamDialogState extends State<CreateTeamDialog> {
@override
Widget build(BuildContext context) {
// 👇 MESMO TRAVÃO NO POPUP PARA NÃO FICAR GIGANTE 👇
final double safeSf = math.min(widget.sf, 1.15);
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * widget.sf)),
title: Text('Nova Equipa', style: TextStyle(fontSize: 18 * widget.sf, fontWeight: FontWeight.bold)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * safeSf)),
title: Text('Nova Equipa', style: TextStyle(fontSize: 18 * safeSf, fontWeight: FontWeight.bold)),
content: SingleChildScrollView(
child: Container(
// 👇 Limita a largura máxima no tablet para o popup não ficar super esticado!
constraints: BoxConstraints(maxWidth: 450 * safeSf),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _nameController,
style: TextStyle(fontSize: 14 * widget.sf),
style: TextStyle(fontSize: 14 * safeSf),
decoration: InputDecoration(
labelText: 'Nome da Equipa',
labelStyle: TextStyle(fontSize: 14 * widget.sf)
labelStyle: TextStyle(fontSize: 14 * safeSf)
),
textCapitalization: TextCapitalization.words,
),
SizedBox(height: 15 * widget.sf),
SizedBox(height: 15 * safeSf),
DropdownButtonFormField<String>(
value: _selectedSeason,
decoration: InputDecoration(
labelText: 'Temporada',
labelStyle: TextStyle(fontSize: 14 * widget.sf)
labelStyle: TextStyle(fontSize: 14 * safeSf)
),
style: TextStyle(fontSize: 14 * widget.sf, color: Colors.black87),
style: TextStyle(fontSize: 14 * safeSf, color: Colors.black87),
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),
SizedBox(height: 15 * safeSf),
TextField(
controller: _imageController,
style: TextStyle(fontSize: 14 * widget.sf),
style: TextStyle(fontSize: 14 * safeSf),
decoration: InputDecoration(
labelText: 'URL Imagem ou Emoji',
labelStyle: TextStyle(fontSize: 14 * widget.sf),
labelStyle: TextStyle(fontSize: 14 * safeSf),
hintText: 'Ex: 🏀 ou https://...',
hintStyle: TextStyle(fontSize: 14 * widget.sf)
hintStyle: TextStyle(fontSize: 14 * safeSf)
),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancelar', style: TextStyle(fontSize: 14 * widget.sf))
child: Text('Cancelar', style: TextStyle(fontSize: 14 * safeSf))
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFE74C3C),
padding: EdgeInsets.symmetric(horizontal: 16 * widget.sf, vertical: 10 * widget.sf)
padding: EdgeInsets.symmetric(horizontal: 16 * safeSf, vertical: 10 * safeSf)
),
onPressed: () {
if (_nameController.text.trim().isNotEmpty) {
@@ -241,7 +252,7 @@ class _CreateTeamDialogState extends State<CreateTeamDialog> {
Navigator.pop(context);
}
},
child: Text('Criar', style: TextStyle(color: Colors.white, fontSize: 14 * widget.sf)),
child: Text('Criar', style: TextStyle(color: Colors.white, fontSize: 14 * safeSf)),
),
],
);