From 0369b5376cd31e2893119772bb4d0372889c548a Mon Sep 17 00:00:00 2001 From: 230404 <230404@epvc.pt> Date: Fri, 13 Mar 2026 18:08:15 +0000 Subject: [PATCH] melhorar o sensor de calor --- lib/controllers/placar_controller.dart | 270 +++++------ lib/grafico de pizza/grafico.dart | 167 +++---- lib/pages/PlacarPage.dart | 628 +++++++++++++------------ lib/pages/gamePage.dart | 104 ++-- lib/pages/home.dart | 114 ++--- lib/pages/status_page.dart | 220 ++++++--- lib/pages/teamPage.dart | 110 +++-- lib/widgets/login_widgets.dart | 61 ++- lib/widgets/register_widgets.dart | 154 +++--- lib/widgets/team_widgets.dart | 151 +++--- 10 files changed, 1053 insertions(+), 926 deletions(-) diff --git a/lib/controllers/placar_controller.dart b/lib/controllers/placar_controller.dart index 0cc2aa4..3f68086 100644 --- a/lib/controllers/placar_controller.dart +++ b/lib/controllers/placar_controller.dart @@ -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 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 court, List bench, String prefix, {required bool isMyTeam}) { @@ -194,17 +174,12 @@ class PlacarController { } else { timer.cancel(); isRunning = false; - if (currentQuarter < 4) { - currentQuarter++; - duration = const Duration(minutes: 10); - myFouls = 0; - opponentFouls = 0; - myTimeoutsUsed = 0; - opponentTimeoutsUsed = 0; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Período $currentQuarter iniciado. Faltas e Timeouts resetados!'), backgroundColor: Colors.blue)); - } else { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('FIM DO JOGO! Clica em Guardar para fechar a partida.'), backgroundColor: Colors.red)); - } + if (currentQuarter < 4) { + currentQuarter++; + duration = const Duration(minutes: 10); + 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(); } -void registerShotLocation(BuildContext context, Offset position, Size size) { + // ============================================================== + // 🎯 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 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 myTeamUpdate = {}; - Map 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(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 { + 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!); + } } - - 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; - } else { - myTeamUpdate['draws'] = (myTeamUpdate['draws'] ?? 0) + 1; - oppTeamUpdate['draws'] = (oppTeamUpdate['draws'] ?? 0) + 1; - } - - await supabase.from('teams').update({ - 'wins': myTeamUpdate['wins'], 'losses': myTeamUpdate['losses'], 'draws': myTeamUpdate['draws'] - }).eq('id', myTeamDbId!); - - await supabase.from('teams').update({ - 'wins': oppTeamUpdate['wins'], 'losses': oppTeamUpdate['losses'], 'draws': oppTeamUpdate['draws'] - }).eq('id', oppTeamDbId!); - gameWasAlreadyFinished = true; } - // 3. Atualizar as Estatísticas dos Jogadores + // Salvar Estatísticas Gerais List> 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> 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(); @@ -494,4 +456,4 @@ void registerShotLocation(BuildContext context, Offset position, Size size) { void dispose() { timer?.cancel(); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/lib/grafico de pizza/grafico.dart b/lib/grafico de pizza/grafico.dart index 41a5e45..d8ae84e 100644 --- a/lib/grafico de pizza/grafico.dart +++ b/lib/grafico de pizza/grafico.dart @@ -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; @@ -59,30 +60,25 @@ class _PieChartCardState extends State with SingleTickerProviderSt Widget build(BuildContext context) { final data = PieChartData(victories: widget.victories, defeats: widget.defeats, draws: widget.draws); - return AnimatedBuilder( + return AnimatedBuilder( 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,86 +86,89 @@ class _PieChartCardState extends State 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, drawPercentage: data.drawPercentage, - sf: widget.sf, + sf: widget.sf, ), ), - SizedBox(width: cw * 0.05), + + SizedBox(width: cw * 0.08), + + // 2. Lado Direito: Números Dinâmicos Expanded( - flex: 1, - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - 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), - ], + child: FittedBox( + alignment: Alignment.centerLeft, + fit: BoxFit.scaleDown, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _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 + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.stars, color: Colors.greenAccent, size: ch * 0.075), + const SizedBox(width: 10), + Text('WIN RATE: ${(data.victoryPercentage * 100).toStringAsFixed(1)}%', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w900, + letterSpacing: 1.0, + fontSize: ch * 0.06 ), - SizedBox(width: cw * 0.02), - Text( - 'WIN RATE: ${(data.victoryPercentage * 100).toStringAsFixed(1)}%', - style: TextStyle( - fontSize: ch * 0.05, - fontWeight: FontWeight.bold, - color: Colors.white - ) - ), - ], - ), + ), + ], ), ), ), @@ -183,34 +182,38 @@ class _PieChartCardState extends State 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) + ); } } \ No newline at end of file diff --git a/lib/pages/PlacarPage.dart b/lib/pages/PlacarPage.dart index 940d2a4..cf924ce 100644 --- a/lib/pages/PlacarPage.dart +++ b/lib/pages/PlacarPage.dart @@ -1,325 +1,367 @@ - import 'package:flutter/material.dart'; - import 'package:flutter/services.dart'; - import 'package:playmaker/controllers/placar_controller.dart'; -import 'package:playmaker/utils/size_extension.dart'; - import 'package:playmaker/widgets/placar_widgets.dart'; - import 'dart:math' as math; +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; - class PlacarPage extends StatefulWidget { - final String gameId, myTeam, opponentTeam; - const PlacarPage({super.key, required this.gameId, required this.myTeam, required this.opponentTeam}); +class PlacarPage extends StatefulWidget { + final String gameId, myTeam, opponentTeam; + const PlacarPage({super.key, required this.gameId, required this.myTeam, required this.opponentTeam}); - @override - State createState() => _PlacarPageState(); + @override + State createState() => _PlacarPageState(); +} + +class _PlacarPageState extends State { + late PlacarController _controller; + + @override + void initState() { + super.initState(); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeRight, + DeviceOrientation.landscapeLeft, + ]); + + _controller = PlacarController( + gameId: widget.gameId, + myTeam: widget.myTeam, + opponentTeam: widget.opponentTeam, + onUpdate: () { + if (mounted) setState(() {}); + } + ); + _controller.loadPlayers(); } - class _PlacarPageState extends State { - late PlacarController _controller; + @override + void dispose() { + _controller.dispose(); + SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + super.dispose(); + } - @override - void initState() { - super.initState(); - SystemChrome.setPreferredOrientations([ - DeviceOrientation.landscapeRight, - DeviceOrientation.landscapeLeft, - ]); - - _controller = PlacarController( - gameId: widget.gameId, - myTeam: widget.myTeam, - opponentTeam: widget.opponentTeam, - onUpdate: () { - if (mounted) setState(() {}); - } - ); - _controller.loadPlayers(); + Widget _buildFloatingFoulBtn(String label, Color color, String action, IconData icon, double left, double right, double top, double sf) { + return Positioned( + top: top, left: left > 0 ? left : null, right: right > 0 ? right : null, + child: Draggable( + data: action, + feedback: Material(color: Colors.transparent, child: CircleAvatar(radius: 30 * sf, backgroundColor: color.withOpacity(0.8), child: Icon(icon, color: Colors.white, size: 30 * sf))), + child: Column( + children: [ + CircleAvatar(radius: 27 * sf, backgroundColor: color, child: Icon(icon, color: Colors.white, size: 28 * sf)), + SizedBox(height: 5 * sf), + Text(label, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12 * sf)), + ], + ), + ), + ); + } + + Widget _buildCornerBtn({required String heroTag, required IconData icon, required Color color, required VoidCallback onTap, required double size, bool isLoading = false}) { + return SizedBox( + width: size, height: size, + child: FloatingActionButton( + heroTag: heroTag, backgroundColor: color, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14 * (size / 50))), + elevation: 5, onPressed: isLoading ? null : onTap, + child: isLoading ? SizedBox(width: size*0.45, height: size*0.45, child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2.5)) : Icon(icon, color: Colors.white, size: size * 0.55), + ), + ); + } + + @override + Widget build(BuildContext context) { + final double wScreen = MediaQuery.of(context).size.width; + final double hScreen = MediaQuery.of(context).size.height; + + // Calcula o tamanho normal + double sf = math.min(wScreen / 1150, hScreen / 720); + + // 👇 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: Text("PREPARANDO O PAVILHÃO...", style: TextStyle(color: Colors.white24, fontSize: 45 * sf, fontWeight: FontWeight.bold)))); } - @override - void dispose() { - _controller.dispose(); - SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); - 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, - child: Draggable( - data: action, - feedback: Material( - color: Colors.transparent, - child: CircleAvatar( - radius: 30 * sf, - backgroundColor: color.withOpacity(0.8), - child: Icon(icon, color: Colors.white, size: 30 * sf) - ), - ), - child: Column( + return Scaffold( + backgroundColor: const Color(0xFF266174), + body: SafeArea( + top: false, bottom: false, + child: IgnorePointer( + ignoring: _controller.isSaving, + child: Stack( children: [ - CircleAvatar( - radius: 27 * sf, - backgroundColor: color, - child: Icon(icon, color: Colors.white, size: 28 * sf), - ), - SizedBox(height: 5 * sf), - Text(label, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12 * sf)), - ], - ), - ), - ); - } - - // --- 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, - child: FloatingActionButton( - heroTag: heroTag, - backgroundColor: color, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14 * (size / 50))), - elevation: 5, - onPressed: isLoading ? null : onTap, - child: isLoading - ? SizedBox(width: size*0.45, height: size*0.45, child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2.5)) - : Icon(icon, color: Colors.white, size: size * 0.55), - ), - ); - } - - @override - Widget build(BuildContext context) { - 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); - - final double cornerBtnSize = 48 * sf; // Tamanho ideal (Nem 38 nem 55) - - 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 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)); - }, + // ========================================== + // --- 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), + image: const DecorationImage(image: AssetImage('assets/campo.png'), fit: BoxFit.fill), ), - ], - ), - ), - ); - } + child: LayoutBuilder( + builder: (context, constraints) { + final w = constraints.maxWidth; + final h = constraints.maxHeight; - return Scaffold( - backgroundColor: const Color(0xFF266174), - body: SafeArea( - top: false, - bottom: false, - // 👇 A MÁGICA DO IGNORE POINTER COMEÇA AQUI 👇 - child: IgnorePointer( - ignoring: _controller.isSaving, // Se estiver a gravar, ignora os toques! - child: Stack( - children: [ - // --- 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)), - child: LayoutBuilder( - builder: (context, constraints) { - final w = constraints.maxWidth; - final h = constraints.maxHeight; - - return Stack( - children: [ - GestureDetector( + return Stack( + children: [ + 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) - ), - )).toList(), - ), + child: Stack( + children: _controller.matchShots.map((shot) => Positioned( + 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(), ), ), + ), - // --- JOGADORES --- - if (!_controller.isSelectingShotLocation) ...[ - Positioned(top: h * 0.25, left: w * 0.02, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[0], isOpponent: false, sf: sf)), - Positioned(top: h * 0.68, left: w * 0.02, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[1], isOpponent: false, sf: sf)), - Positioned(top: h * 0.45, left: w * 0.25, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[2], isOpponent: false, sf: sf)), - Positioned(top: h * 0.15, left: w * 0.20, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[3], isOpponent: false, sf: sf)), - Positioned(top: h * 0.80, left: w * 0.20, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[4], isOpponent: false, sf: sf)), - - Positioned(top: h * 0.25, right: w * 0.02, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[0], isOpponent: true, sf: sf)), - Positioned(top: h * 0.68, right: w * 0.02, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[1], isOpponent: true, sf: sf)), - Positioned(top: h * 0.45, right: w * 0.25, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[2], isOpponent: true, sf: sf)), - Positioned(top: h * 0.15, right: w * 0.20, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[3], isOpponent: true, sf: sf)), - Positioned(top: h * 0.80, right: w * 0.20, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[4], isOpponent: true, sf: sf)), - ], - - // --- BOTÕES DE FALTAS --- - if (!_controller.isSelectingShotLocation) ...[ - _buildFloatingFoulBtn("FALTA +", Colors.orange, "add_foul", Icons.sports, w * 0.39, 0.0, h * 0.31, sf), - _buildFloatingFoulBtn("FALTA -", Colors.redAccent, "sub_foul", Icons.block, 0.0, w * 0.39, h * 0.31, sf), - ], - - // --- BOTÃO PLAY/PAUSE --- - if (!_controller.isSelectingShotLocation) - Positioned( - top: (h * 0.32) + (40 * sf), - left: 0, right: 0, - child: Center( - child: GestureDetector( - onTap: () => _controller.toggleTimer(context), - child: CircleAvatar( - radius: 68 * sf, - backgroundColor: Colors.grey.withOpacity(0.5), - child: Icon(_controller.isRunning ? Icons.pause : Icons.play_arrow, color: Colors.white, size: 58 * sf) - ), - ), - ), - ), - // --- PLACAR NO TOPO --- - Positioned(top: 0, left: 0, right: 0, child: Center(child: TopScoreboard(controller: _controller, sf: sf))), + // --- JOGADORES --- + if (!_controller.isSelectingShotLocation) ...[ + Positioned(top: h * 0.25, left: w * 0.02, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[0], isOpponent: false, sf: sf)), + Positioned(top: h * 0.68, left: w * 0.02, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[1], isOpponent: false, sf: sf)), + Positioned(top: h * 0.45, left: w * 0.25, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[2], isOpponent: false, sf: sf)), + Positioned(top: h * 0.15, left: w * 0.20, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[3], isOpponent: false, sf: sf)), + Positioned(top: h * 0.80, left: w * 0.20, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[4], isOpponent: false, sf: sf)), - // --- 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)), - ), - ), - ), + Positioned(top: h * 0.25, right: w * 0.02, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[0], isOpponent: true, sf: sf)), + Positioned(top: h * 0.68, right: w * 0.02, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[1], isOpponent: true, sf: sf)), + Positioned(top: h * 0.45, right: w * 0.25, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[2], isOpponent: true, sf: sf)), + Positioned(top: h * 0.15, right: w * 0.20, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[3], isOpponent: true, sf: sf)), + Positioned(top: h * 0.80, right: w * 0.20, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[4], isOpponent: true, sf: sf)), ], - ); - }, - ), + + // --- BOTÕES DE FALTAS --- + if (!_controller.isSelectingShotLocation) ...[ + _buildFloatingFoulBtn("FALTA +", Colors.orange, "add_foul", Icons.sports, w * 0.39, 0.0, h * 0.31, sf), + _buildFloatingFoulBtn("FALTA -", Colors.redAccent, "sub_foul", Icons.block, 0.0, w * 0.39, h * 0.31, sf), + ], + + // --- BOTÃO PLAY/PAUSE --- + if (!_controller.isSelectingShotLocation) + Positioned( + top: (h * 0.36) + (40 * sf), + left: 0, right: 0, + child: Center( + child: GestureDetector( + onTap: () => _controller.toggleTimer(context), + child: CircleAvatar( + radius: 68 * sf, + backgroundColor: Colors.grey.withOpacity(0.5), + child: Icon(_controller.isRunning ? Icons.pause : Icons.play_arrow, color: Colors.white, size: 58 * sf) + ) + ) + ) + ), + + Positioned(top: 0, left: 0, right: 0, child: Center(child: TopScoreboard(controller: _controller, sf: sf))), + ], + ); + }, + ), + ), + + // ========================================== + // --- 2. O RODAPÉ (BOTÕES DE JOGO) --- + // ========================================== + if (!_controller.isSelectingShotLocation) + Positioned( + bottom: 60 * sf, + left: 0, + right: 0, + child: ActionButtonsPanel(controller: _controller, sf: sf) ), - // --- BOTÕES LATERAIS --- - // Topo Esquerdo: Guardar e Sair (Botão Único) - 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); - } - } - ), - ), + // ========================================== + // --- 3. BOTÕES LATERAIS --- + // ========================================== + + 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); })), - // Base Esquerda: Banco Casa + TIMEOUT DA CASA - Positioned( - bottom: 55 * sf, left: 12 * sf, + 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))), + ], + ), + ), + ), + ); + } +} + +// ============================================================== +// 🏀 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 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(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( + 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( + 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 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: [ - 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) - ), + 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 + )), ], ), - ), - - // 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) - ), - ], - ), - ), - - // 👇 EFEITO VISUAL (Ecrã escurece para mostrar que está a carregar) 👇 - if (_controller.isSaving) - Positioned.fill( - child: Container( - color: Colors.black.withOpacity(0.4), - ), - ), - + ) ], ), ), ), - ); - } - } \ No newline at end of file + ), + ); + } +} diff --git a/lib/pages/gamePage.dart b/lib/pages/gamePage.dart index 9dc3b26..7a0537c 100644 --- a/lib/pages/gamePage.dart +++ b/lib/pages/gamePage.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:playmaker/pages/PlacarPage.dart'; +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 { @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: 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), - ], + 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 * 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 { } } }, - 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>>( stream: widget.teamController.teamsStream, builder: (context, snapshot) { @@ -156,9 +164,9 @@ class _CreateGameDialogManualState extends State { 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 { 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 { 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 { } } +// (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}); diff --git a/lib/pages/home.dart b/lib/pages/home.dart index 43b7bf6..e87f08f 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -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 { @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 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 { 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 { 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 { ); } - 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>>( 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 { 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 { ); } - 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>>( stream: _selectedTeamId != null @@ -125,44 +126,44 @@ class _HomeScreenState extends State { 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 { 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>>( stream: _supabase.from('games').stream(primaryKey: ['id']) @@ -206,7 +207,7 @@ class _HomeScreenState extends State { 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 { 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 { 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 { 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 { 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 { ), 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 { ); } - 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 ), diff --git a/lib/pages/status_page.dart b/lib/pages/status_page.dart index 8d3b380..fb7a60b 100644 --- a/lib/pages/status_page.dart +++ b/lib/pages/status_page.dart @@ -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 { String _sortColumn = 'pts'; bool _isAscending = false; + // 👇 NOVA FUNÇÃO: BUSCA OS LANÇAMENTOS DO JOGADOR NO SUPABASE E ABRE O MAPA + Future _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 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 { 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 { ), ), + // --- 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>>( 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 { } 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 { 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 { ); } + // (Lógica de _aggregateStats e _calculateTeamTotals continua igual...) List> _aggregateStats(List stats, List games, List members) { Map> 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 { 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> players, Map teamTotals) { + Widget _buildStatsGrid(BuildContext context, List> players, Map 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: DataTable( - columnSpacing: 25 * context.sf, - headingRowColor: MaterialStateProperty.all(Colors.grey.shade100), - dataRowHeight: 60 * context.sf, - 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'), - ], - 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(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), - ])), - 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(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), - ] - ) - ], + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: screenWidth), + child: DataTable( + columnSpacing: dynamicSpacing, + horizontalMargin: 20 * safeSf, + headingRowColor: MaterialStateProperty.all(Colors.grey.shade100), + dataRowHeight: 60 * safeSf, + columns: [ + DataColumn(label: const Text('JOGADOR')), + _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( + // 👇 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(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 * safeSf))), + DataCell(Center(child: Text(teamTotals['j'].toString(), style: const TextStyle(fontWeight: FontWeight.bold)))), + _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>>( 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); }, )); }, diff --git a/lib/pages/teamPage.dart b/lib/pages/teamPage.dart index 5d7c2bd..aa67d6c 100644 --- a/lib/pages/teamPage.dart +++ b/lib/pages/teamPage.dart @@ -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 { @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 { ], ), 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 { } 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>>( 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>.from(snapshot.data!); @@ -191,7 +194,7 @@ class _TeamsPageState extends State { }); 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( 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 { @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: 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), - DropdownButtonFormField( - value: _selectedSeason, decoration: InputDecoration(labelText: 'Temporada', labelStyle: TextStyle(fontSize: 14 * context.sf)), - style: TextStyle(fontSize: 14 * context.sf, 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))), - ], + 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 * safeSf), decoration: InputDecoration(labelText: 'Nome da Equipa', labelStyle: TextStyle(fontSize: 14 * safeSf)), textCapitalization: TextCapitalization.words), + SizedBox(height: 15 * safeSf), + DropdownButtonFormField( + 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 * 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)), ), ], ); diff --git a/lib/widgets/login_widgets.dart b/lib/widgets/login_widgets.dart index 1028679..744b0f8 100644 --- a/lib/widgets/login_widgets.dart +++ b/lib/widgets/login_widgets.dart @@ -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(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)), ), ); } diff --git a/lib/widgets/register_widgets.dart b/lib/widgets/register_widgets.dart index da3528d..061caab 100644 --- a/lib/widgets/register_widgets.dart +++ b/lib/widgets/register_widgets.dart @@ -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,71 +42,76 @@ class _RegisterFormFieldsState extends State { @override Widget build(BuildContext context) { - return Form( - key: widget.controller.formKey, - child: Column( - children: [ - TextFormField( - controller: widget.controller.nameController, - style: TextStyle(fontSize: 15 * context.sf), - 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), - ), - ), - SizedBox(height: 20 * context.sf), + final double safeSf = math.min(context.sf, 1.15); // TRAVÃO - TextFormField( - controller: widget.controller.emailController, - validator: widget.controller.validateEmail, - style: TextStyle(fontSize: 15 * context.sf), - 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), - ), - keyboardType: TextInputType.emailAddress, - ), - SizedBox(height: 20 * context.sf), - - TextFormField( - controller: widget.controller.passwordController, - obscureText: _obscurePassword, - validator: widget.controller.validatePassword, - style: TextStyle(fontSize: 15 * context.sf), - decoration: InputDecoration( - labelText: 'Palavra-passe', - labelStyle: TextStyle(fontSize: 15 * context.sf), - prefixIcon: Icon(Icons.lock_outlined, size: 22 * context.sf), - suffixIcon: IconButton( - icon: Icon(_obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 22 * context.sf), - onPressed: () => setState(() => _obscurePassword = !_obscurePassword), + 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 * safeSf), + decoration: InputDecoration( + labelText: 'Nome Completo', + 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), ), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12 * context.sf)), - contentPadding: EdgeInsets.symmetric(vertical: 18 * context.sf, horizontal: 16 * context.sf), ), - ), - 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), - 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), + TextFormField( + controller: widget.controller.emailController, + validator: widget.controller.validateEmail, + style: TextStyle(fontSize: 15 * safeSf), + decoration: InputDecoration( + labelText: 'E-mail', + 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 * safeSf), + + TextFormField( + controller: widget.controller.passwordController, + obscureText: _obscurePassword, + validator: widget.controller.validatePassword, + style: TextStyle(fontSize: 15 * safeSf), + decoration: InputDecoration( + labelText: 'Palavra-passe', + 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 * safeSf), + onPressed: () => setState(() => _obscurePassword = !_obscurePassword), + ), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12 * safeSf)), + contentPadding: EdgeInsets.symmetric(vertical: 18 * safeSf, horizontal: 16 * safeSf), + ), + ), + SizedBox(height: 20 * safeSf), + + TextFormField( + controller: widget.controller.confirmPasswordController, + obscureText: _obscurePassword, + validator: widget.controller.validateConfirmPassword, + style: TextStyle(fontSize: 15 * safeSf), + decoration: InputDecoration( + labelText: 'Confirmar Palavra-passe', + 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(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)), ), ); } diff --git a/lib/widgets/team_widgets.dart b/lib/widgets/team_widgets.dart index c579164..a1626c3 100644 --- a/lib/widgets/team_widgets.dart +++ b/lib/widgets/team_widgets.dart @@ -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( 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 { @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: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: _nameController, - style: TextStyle(fontSize: 14 * widget.sf), - decoration: InputDecoration( - labelText: 'Nome da Equipa', - labelStyle: TextStyle(fontSize: 14 * widget.sf) + 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 * safeSf), + decoration: InputDecoration( + labelText: 'Nome da Equipa', + labelStyle: TextStyle(fontSize: 14 * safeSf) + ), + textCapitalization: TextCapitalization.words, ), - textCapitalization: TextCapitalization.words, - ), - SizedBox(height: 15 * widget.sf), - DropdownButtonFormField( - value: _selectedSeason, - decoration: InputDecoration( - labelText: 'Temporada', - labelStyle: TextStyle(fontSize: 14 * widget.sf) + SizedBox(height: 15 * safeSf), + DropdownButtonFormField( + 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!), ), - style: TextStyle(fontSize: 14 * widget.sf, 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), - TextField( - controller: _imageController, - style: TextStyle(fontSize: 14 * widget.sf), - decoration: InputDecoration( - labelText: 'URL Imagem ou Emoji', - labelStyle: TextStyle(fontSize: 14 * widget.sf), - hintText: 'Ex: 🏀 ou https://...', - hintStyle: TextStyle(fontSize: 14 * widget.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 * 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 { 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)), ), ], );