From 00fee3079262f35edba818c1473a5b0f9143755a Mon Sep 17 00:00:00 2001 From: 230404 <230404@epvc.pt> Date: Sun, 22 Mar 2026 01:40:29 +0000 Subject: [PATCH] sabado --- android/app/src/main/AndroidManifest.xml | 10 + ios/Runner/Info.plist | 7 + lib/controllers/game_controller.dart | 41 +- lib/controllers/placar_controller.dart | 355 +++--- lib/controllers/stats_controller.dart | 158 --- lib/controllers/team_controller.dart | 57 +- lib/models/game_model.dart | 69 +- lib/models/person_model.dart | 35 +- lib/models/team_model.dart | 17 +- lib/pages/PlacarPage.dart | 1081 ++++------------- lib/pages/home.dart | 66 +- lib/pages/settings_screen.dart | 248 +++- lib/pages/teamPage.dart | 200 ++- lib/screens/team_stats_page.dart | 549 ++++++--- lib/widgets/placar_widgets.dart | 626 +++------- lib/widgets/stats_widgets.dart | 3 +- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 + pubspec.lock | 258 +++- pubspec.yaml | 5 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 23 files changed, 1717 insertions(+), 2081 deletions(-) delete mode 100644 lib/controllers/stats_controller.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 114ef60..14ca4f1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,18 @@ + + + + + + + UIApplicationSupportsIndirectInputEvents + + NSPhotoLibraryUsageDescription + A PlayMaker precisa de aceder à tua galeria para poderes escolher uma foto de perfil. + NSCameraUsageDescription + A PlayMaker precisa de aceder à câmara para poderes tirar uma foto de perfil. + + diff --git a/lib/controllers/game_controller.dart b/lib/controllers/game_controller.dart index 75eadcc..a15f571 100644 --- a/lib/controllers/game_controller.dart +++ b/lib/controllers/game_controller.dart @@ -4,51 +4,44 @@ import '../models/game_model.dart'; class GameController { final _supabase = Supabase.instance.client; - // 👇 Atalho para apanhar o ID do utilizador logado String get myUserId => _supabase.auth.currentUser?.id ?? ''; - // 1. LER JOGOS (Stream em Tempo Real da tabela original) + // LER JOGOS Stream> get gamesStream { return _supabase .from('games') .stream(primaryKey: ['id']) - .eq('user_id', myUserId) // 🔒 SEGURANÇA: Ouve apenas os jogos deste utilizador + .eq('user_id', myUserId) .asyncMap((event) async { - // Lê diretamente da tabela "games" e já não da "games_with_logos" final data = await _supabase .from('games') .select() - .eq('user_id', myUserId) // 🔒 SEGURANÇA + .eq('user_id', myUserId) .order('game_date', ascending: false); + // O Game.fromMap agora faz o trabalho sujo todo! return data.map((json) => Game.fromMap(json)).toList(); }); } - // ========================================================================= - // 👇 LER JOGOS COM FILTROS DE EQUIPA E TEMPORADA - // ========================================================================= + // LER JOGOS COM FILTROS Stream> getFilteredGames({required String teamFilter, required String seasonFilter}) { return _supabase .from('games') .stream(primaryKey: ['id']) - .eq('user_id', myUserId) // 🔒 SEGURANÇA + .eq('user_id', myUserId) .asyncMap((event) async { - // 1. Começamos a query na tabela principal "games" - var query = _supabase.from('games').select().eq('user_id', myUserId); // 🔒 SEGURANÇA + var query = _supabase.from('games').select().eq('user_id', myUserId); - // 2. Se a temporada não for "Todas", aplicamos o filtro AQUI if (seasonFilter != 'Todas') { query = query.eq('season', seasonFilter); } - // 3. Executamos a query e ordenamos pela data final data = await query.order('game_date', ascending: false); List games = data.map((json) => Game.fromMap(json)).toList(); - // 4. Filtramos a equipa em memória if (teamFilter != 'Todas') { games = games.where((g) => g.myTeam == teamFilter || g.opponentTeam == teamFilter).toList(); } @@ -57,11 +50,11 @@ class GameController { }); } - // 2. CRIAR JOGO + // CRIAR JOGO Future createGame(String myTeam, String opponent, String season) async { try { final response = await _supabase.from('games').insert({ - 'user_id': myUserId, // 🔒 CARIMBA O JOGO COM O ID DO TREINADOR + 'user_id': myUserId, 'my_team': myTeam, 'opponent_team': opponent, 'season': season, @@ -69,16 +62,24 @@ class GameController { 'opponent_score': 0, 'status': 'Decorrer', 'game_date': DateTime.now().toIso8601String(), + // 👇 Preenchemos logo com os valores iniciais da tua Base de Dados + 'remaining_seconds': 600, // Assume 10 minutos (600s) + 'my_timeouts': 0, + 'opp_timeouts': 0, + 'current_quarter': 1, + 'top_pts_name': '---', + 'top_ast_name': '---', + 'top_rbs_name': '---', + 'top_def_name': '---', + 'mvp_name': '---', }).select().single(); - return response['id']; + return response['id']?.toString(); } catch (e) { print("Erro ao criar jogo: $e"); return null; } } - void dispose() { - // Não é necessário fechar streams do Supabase manualmente aqui - } + void dispose() {} } \ No newline at end of file diff --git a/lib/controllers/placar_controller.dart b/lib/controllers/placar_controller.dart index 37bf39f..70f9eeb 100644 --- a/lib/controllers/placar_controller.dart +++ b/lib/controllers/placar_controller.dart @@ -1,12 +1,15 @@ import 'dart:async'; import 'dart:math' as math; +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class ShotRecord { final double relativeX; final double relativeY; final bool isMake; + final String playerId; final String playerName; final String? zone; final int? points; @@ -15,28 +18,39 @@ class ShotRecord { required this.relativeX, required this.relativeY, required this.isMake, + required this.playerId, required this.playerName, this.zone, this.points, }); + + // 👇 Para o Auto-Save converter em Texto + Map toJson() => { + 'relativeX': relativeX, 'relativeY': relativeY, 'isMake': isMake, + 'playerId': playerId, 'playerName': playerName, 'zone': zone, 'points': points, + }; + + // 👇 Para o Auto-Save ler do Texto + factory ShotRecord.fromJson(Map json) => ShotRecord( + relativeX: json['relativeX'], relativeY: json['relativeY'], isMake: json['isMake'], + playerId: json['playerId'], playerName: json['playerName'], zone: json['zone'], points: json['points'], + ); } -class PlacarController { +// 👇 AGORA É UM CHANGENOTIFIER (Gestor de Estado Profissional) 👇 +class PlacarController extends ChangeNotifier { final String gameId; final String myTeam; final String opponentTeam; - final VoidCallback onUpdate; PlacarController({ required this.gameId, required this.myTeam, required this.opponentTeam, - required this.onUpdate }); bool isLoading = true; bool isSaving = false; - bool gameWasAlreadyFinished = false; int myScore = 0; @@ -55,19 +69,20 @@ class PlacarController { List oppCourt = []; List oppBench = []; + Map playerNames = {}; Map playerNumbers = {}; Map> playerStats = {}; - Map playerDbIds = {}; bool showMyBench = false; bool showOppBench = false; bool isSelectingShotLocation = false; String? pendingAction; - String? pendingPlayer; + String? pendingPlayerId; List matchShots = []; - Duration duration = const Duration(minutes: 10); + // 👇 O CRONÓMETRO AGORA TEM VIDA PRÓPRIA (ValueNotifier) PARA NÃO ENCRAVAR A APP 👇 + ValueNotifier durationNotifier = ValueNotifier(const Duration(minutes: 10)); Timer? timer; bool isRunning = false; @@ -81,16 +96,9 @@ class PlacarController { try { await Future.delayed(const Duration(milliseconds: 1500)); - myCourt.clear(); - myBench.clear(); - oppCourt.clear(); - oppBench.clear(); - playerStats.clear(); - playerNumbers.clear(); - playerDbIds.clear(); - matchShots.clear(); // Limpa as bolas do último jogo - myFouls = 0; - opponentFouls = 0; + myCourt.clear(); myBench.clear(); oppCourt.clear(); oppBench.clear(); + playerNames.clear(); playerStats.clear(); playerNumbers.clear(); + matchShots.clear(); myFouls = 0; opponentFouls = 0; final gameResponse = await supabase.from('games').select().eq('id', gameId).single(); @@ -98,7 +106,7 @@ class PlacarController { opponentScore = int.tryParse(gameResponse['opponent_score']?.toString() ?? '0') ?? 0; int totalSeconds = int.tryParse(gameResponse['remaining_seconds']?.toString() ?? '600') ?? 600; - duration = Duration(seconds: totalSeconds); + durationNotifier.value = Duration(seconds: totalSeconds); myTimeoutsUsed = int.tryParse(gameResponse['my_timeouts']?.toString() ?? '0') ?? 0; opponentTimeoutsUsed = int.tryParse(gameResponse['opp_timeouts']?.toString() ?? '0') ?? 0; @@ -128,7 +136,7 @@ class PlacarController { if (savedStats.containsKey(dbId)) { var s = savedStats[dbId]; - playerStats[name] = { + playerStats[dbId] = { "pts": s['pts'] ?? 0, "rbs": s['rbs'] ?? 0, "ast": s['ast'] ?? 0, "stl": s['stl'] ?? 0, "tov": s['tov'] ?? 0, "blk": s['blk'] ?? 0, "fls": s['fls'] ?? 0, "fgm": s['fgm'] ?? 0, "fga": s['fga'] ?? 0, @@ -147,7 +155,7 @@ class PlacarController { if (savedStats.containsKey(dbId)) { var s = savedStats[dbId]; - playerStats[name] = { + playerStats[dbId] = { "pts": s['pts'] ?? 0, "rbs": s['rbs'] ?? 0, "ast": s['ast'] ?? 0, "stl": s['stl'] ?? 0, "tov": s['tov'] ?? 0, "blk": s['blk'] ?? 0, "fls": s['fls'] ?? 0, "fgm": s['fgm'] ?? 0, "fga": s['fga'] ?? 0, @@ -158,44 +166,46 @@ class PlacarController { } _padTeam(oppCourt, oppBench, "Adversário", isMyTeam: false); - // 👇 CARREGA AS BOLINHAS ANTIGAS (MAPA DE CALOR DO JOGO ATUAL) 👇 final shotsResponse = await supabase.from('shot_locations').select().eq('game_id', gameId); for (var shotData in shotsResponse) { matchShots.add(ShotRecord( relativeX: double.parse(shotData['relative_x'].toString()), relativeY: double.parse(shotData['relative_y'].toString()), isMake: shotData['is_make'] == true, + playerId: shotData['member_id'].toString(), playerName: shotData['player_name'].toString(), zone: shotData['zone']?.toString(), points: shotData['points'] != null ? int.parse(shotData['points'].toString()) : null, )); } + // 👇 AUTO-SAVE: SE O JOGO FOI ABAIXO A MEIO, RECUPERA TUDO AQUI! 👇 + await _loadLocalBackup(); + isLoading = false; - onUpdate(); + notifyListeners(); // Substitui o antigo onUpdate! } catch (e) { debugPrint("Erro ao retomar jogo: $e"); - _padTeam(myCourt, myBench, "Falha", isMyTeam: true); - _padTeam(oppCourt, oppBench, "Falha Opp", isMyTeam: false); isLoading = false; - onUpdate(); + notifyListeners(); } } void _registerPlayer({required String name, required String number, String? dbId, required bool isMyTeam, required bool isCourt}) { - if (playerNumbers.containsKey(name)) name = "$name (Opp)"; - playerNumbers[name] = number; - if (dbId != null) playerDbIds[name] = dbId; + String id = dbId ?? "fake_${DateTime.now().millisecondsSinceEpoch}_${math.Random().nextInt(9999)}"; - playerStats[name] = { + playerNames[id] = name; + playerNumbers[id] = number; + + playerStats[id] = { "pts": 0, "rbs": 0, "ast": 0, "stl": 0, "tov": 0, "blk": 0, "fls": 0, "fgm": 0, "fga": 0, "ftm": 0, "fta": 0, "orb": 0, "drb": 0 }; if (isMyTeam) { - if (isCourt) myCourt.add(name); else myBench.add(name); + if (isCourt) myCourt.add(id); else myBench.add(id); } else { - if (isCourt) oppCourt.add(name); else oppBench.add(name); + if (isCourt) oppCourt.add(id); else oppBench.add(id); } } @@ -205,33 +215,80 @@ class PlacarController { } } + // ========================================================================= + // 👇 AS DUAS FUNÇÕES MÁGICAS DO AUTO-SAVE 👇 + // ========================================================================= + Future _saveLocalBackup() async { + try { + final prefs = await SharedPreferences.getInstance(); + final backupData = { + 'myScore': myScore, 'opponentScore': opponentScore, + 'myFouls': myFouls, 'opponentFouls': opponentFouls, + 'currentQuarter': currentQuarter, 'duration': durationNotifier.value.inSeconds, + 'myTimeoutsUsed': myTimeoutsUsed, 'opponentTimeoutsUsed': opponentTimeoutsUsed, + 'playerStats': playerStats, + 'myCourt': myCourt, 'myBench': myBench, 'oppCourt': oppCourt, 'oppBench': oppBench, + 'matchShots': matchShots.map((s) => s.toJson()).toList(), + }; + await prefs.setString('backup_$gameId', jsonEncode(backupData)); + } catch (e) { + debugPrint("Erro no Auto-Save: $e"); + } + } + + Future _loadLocalBackup() async { + try { + final prefs = await SharedPreferences.getInstance(); + final String? backupString = prefs.getString('backup_$gameId'); + + if (backupString != null) { + final data = jsonDecode(backupString); + + myScore = data['myScore']; opponentScore = data['opponentScore']; + myFouls = data['myFouls']; opponentFouls = data['opponentFouls']; + currentQuarter = data['currentQuarter']; durationNotifier.value = Duration(seconds: data['duration']); + myTimeoutsUsed = data['myTimeoutsUsed']; opponentTimeoutsUsed = data['opponentTimeoutsUsed']; + + myCourt = List.from(data['myCourt']); myBench = List.from(data['myBench']); + oppCourt = List.from(data['oppCourt']); oppBench = List.from(data['oppBench']); + + Map decodedStats = data['playerStats']; + playerStats = decodedStats.map((k, v) => MapEntry(k, Map.from(v))); + + List decodedShots = data['matchShots']; + matchShots = decodedShots.map((s) => ShotRecord.fromJson(s)).toList(); + + debugPrint("🔄 AUTO-SAVE RECUPERADO COM SUCESSO!"); + } + } catch (e) { + debugPrint("Erro ao carregar Auto-Save: $e"); + } + } + void toggleTimer(BuildContext context) { if (isRunning) { timer?.cancel(); + _saveLocalBackup(); // Grava no telemóvel quando pausa! } else { timer = Timer.periodic(const Duration(seconds: 1), (timer) { - if (duration.inSeconds > 0) { - duration -= const Duration(seconds: 1); + if (durationNotifier.value.inSeconds > 0) { + durationNotifier.value -= const Duration(seconds: 1); // 👈 Só o relógio atualiza, a app não pisca! } else { timer.cancel(); isRunning = false; if (currentQuarter < 4) { currentQuarter++; - duration = const Duration(minutes: 10); - myFouls = 0; - opponentFouls = 0; - myTimeoutsUsed = 0; - opponentTimeoutsUsed = 0; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Período $currentQuarter iniciado. Faltas e Timeouts resetados!'), backgroundColor: Colors.blue)); - } else { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('FIM DO JOGO! Clica em Guardar para fechar a partida.'), backgroundColor: Colors.red)); - } + durationNotifier.value = const Duration(minutes: 10); + myFouls = 0; opponentFouls = 0; + myTimeoutsUsed = 0; opponentTimeoutsUsed = 0; + _saveLocalBackup(); // Grava mudança de período + } + notifyListeners(); // Aqui sim, redesenhamos o ecrã para mudar o Quarto } - onUpdate(); }); } isRunning = !isRunning; - onUpdate(); + notifyListeners(); } void useTimeout(bool isOpponent) { @@ -242,14 +299,14 @@ class PlacarController { } isRunning = false; timer?.cancel(); - onUpdate(); + _saveLocalBackup(); + notifyListeners(); } - String formatTime() => "${duration.inMinutes.toString().padLeft(2, '0')}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}"; - void handleActionDrag(BuildContext context, String action, String playerData) { - String name = playerData.replaceAll("player_my_", "").replaceAll("player_opp_", ""); - final stats = playerStats[name]!; + String playerId = playerData.replaceAll("player_my_", "").replaceAll("player_opp_", ""); + final stats = playerStats[playerId]!; + final name = playerNames[playerId]!; if (stats["fls"]! >= 5 && action != "sub_foul") { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('🛑 $name atingiu 5 faltas e está expulso!'), backgroundColor: Colors.red)); @@ -258,91 +315,61 @@ class PlacarController { if (action == "add_pts_2" || action == "add_pts_3" || action == "miss_2" || action == "miss_3") { pendingAction = action; - pendingPlayer = playerData; + pendingPlayerId = playerData; isSelectingShotLocation = true; } else { commitStat(action, playerData); } - onUpdate(); + notifyListeners(); } - void handleSubbing(BuildContext context, String action, String courtPlayerName, bool isOpponent) { + void handleSubbing(BuildContext context, String action, String courtPlayerId, bool isOpponent) { if (action.startsWith("bench_my_") && !isOpponent) { - String benchPlayer = action.replaceAll("bench_my_", ""); - if (playerStats[benchPlayer]!["fls"]! >= 5) return; - int courtIndex = myCourt.indexOf(courtPlayerName); - int benchIndex = myBench.indexOf(benchPlayer); - myCourt[courtIndex] = benchPlayer; - myBench[benchIndex] = courtPlayerName; + String benchPlayerId = action.replaceAll("bench_my_", ""); + if (playerStats[benchPlayerId]!["fls"]! >= 5) return; + int courtIndex = myCourt.indexOf(courtPlayerId); + int benchIndex = myBench.indexOf(benchPlayerId); + myCourt[courtIndex] = benchPlayerId; + myBench[benchIndex] = courtPlayerId; showMyBench = false; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Sai $courtPlayerName, Entra $benchPlayer'))); } if (action.startsWith("bench_opp_") && isOpponent) { - String benchPlayer = action.replaceAll("bench_opp_", ""); - if (playerStats[benchPlayer]!["fls"]! >= 5) return; - int courtIndex = oppCourt.indexOf(courtPlayerName); - int benchIndex = oppBench.indexOf(benchPlayer); - oppCourt[courtIndex] = benchPlayer; - oppBench[benchIndex] = courtPlayerName; + String benchPlayerId = action.replaceAll("bench_opp_", ""); + if (playerStats[benchPlayerId]!["fls"]! >= 5) return; + int courtIndex = oppCourt.indexOf(courtPlayerId); + int benchIndex = oppBench.indexOf(benchPlayerId); + oppCourt[courtIndex] = benchPlayerId; + oppBench[benchIndex] = courtPlayerId; showOppBench = false; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Sai $courtPlayerName, Entra $benchPlayer'))); } - onUpdate(); + _saveLocalBackup(); + notifyListeners(); } - // ========================================================================= - // 👇 REGISTA PONTOS VINDO DO POP-UP AMARELO (E MARCA A BOLINHA) - // ========================================================================= void registerShotFromPopup(BuildContext context, String action, String targetPlayer, String zone, int points, double relativeX, double relativeY) { - // 💡 AVISO AMIGÁVEL REMOVIDO. Agora podes marcar pontos mesmo com o tempo parado! - - String name = targetPlayer.replaceAll("player_my_", "").replaceAll("player_opp_", ""); + String playerId = targetPlayer.replaceAll("player_my_", "").replaceAll("player_opp_", ""); bool isMyTeam = targetPlayer.startsWith("player_my_"); bool isMake = action.startsWith("add_"); + String name = playerNames[playerId]!; - // 1. ATUALIZA A ESTATÍSTICA DO JOGADOR - if (playerStats.containsKey(name)) { - playerStats[name]!['fga'] = playerStats[name]!['fga']! + 1; + if (playerStats.containsKey(playerId)) { + playerStats[playerId]!['fga'] = playerStats[playerId]!['fga']! + 1; if (isMake) { - playerStats[name]!['fgm'] = playerStats[name]!['fgm']! + 1; - playerStats[name]!['pts'] = playerStats[name]!['pts']! + points; - - // 2. ATUALIZA O PLACAR DA EQUIPA - if (isMyTeam) { - myScore += points; - } else { - opponentScore += points; - } + playerStats[playerId]!['fgm'] = playerStats[playerId]!['fgm']! + 1; + playerStats[playerId]!['pts'] = playerStats[playerId]!['pts']! + points; + if (isMyTeam) myScore += points; else opponentScore += points; } } - // 3. CRIA A BOLINHA PARA APARECER NO CAMPO - matchShots.add(ShotRecord( - relativeX: relativeX, - relativeY: relativeY, - isMake: isMake, - playerName: name, - zone: zone, - points: points, - )); + matchShots.add(ShotRecord(relativeX: relativeX, relativeY: relativeY, isMake: isMake, playerId: playerId, playerName: name, zone: zone, points: points)); - // 4. MANDA UMA MENSAGEM NO ECRÃ - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(isMake ? '🔥 $name MARCOU de $zone!' : '❌ $name FALHOU de $zone!'), - backgroundColor: isMake ? Colors.green : Colors.red, - duration: const Duration(seconds: 2), - ) - ); - - // 5. ATUALIZA O ECRÃ - onUpdate(); + _saveLocalBackup(); // 👈 Grava logo para não perder o cesto! + notifyListeners(); } - // MANTIDO PARA CASO USES A MARCAÇÃO CLÁSSICA DIRETAMENTE NO CAMPO ESCURO void registerShotLocation(BuildContext context, Offset position, Size size) { - if (pendingAction == null || pendingPlayer == null) return; + if (pendingAction == null || pendingPlayerId == null) return; bool is3Pt = pendingAction!.contains("_3"); bool is2Pt = pendingAction!.contains("_2"); @@ -355,21 +382,15 @@ class PlacarController { bool isMake = pendingAction!.startsWith("add_pts_"); double relX = position.dx / size.width; double relY = position.dy / size.height; - String name = pendingPlayer!.replaceAll("player_my_", "").replaceAll("player_opp_", ""); + String pId = pendingPlayerId!.replaceAll("player_my_", "").replaceAll("player_opp_", ""); - matchShots.add(ShotRecord( - relativeX: relX, - relativeY: relY, - isMake: isMake, - playerName: name - )); + matchShots.add(ShotRecord(relativeX: relX, relativeY: relY, isMake: isMake, playerId: pId, playerName: playerNames[pId]!)); - commitStat(pendingAction!, pendingPlayer!); + commitStat(pendingAction!, pendingPlayerId!); - isSelectingShotLocation = false; - pendingAction = null; - pendingPlayer = null; - onUpdate(); + isSelectingShotLocation = false; pendingAction = null; pendingPlayerId = null; + _saveLocalBackup(); // 👈 Grava logo + notifyListeners(); } bool _validateShotZone(Offset position, Size size, bool is3Pt) { @@ -400,13 +421,13 @@ class PlacarController { } void cancelShotLocation() { - isSelectingShotLocation = false; pendingAction = null; pendingPlayer = null; onUpdate(); + isSelectingShotLocation = false; pendingAction = null; pendingPlayerId = null; notifyListeners(); } void commitStat(String action, String playerData) { bool isOpponent = playerData.startsWith("player_opp_"); - String name = playerData.replaceAll("player_my_", "").replaceAll("player_opp_", ""); - final stats = playerStats[name]!; + String playerId = playerData.replaceAll("player_my_", "").replaceAll("player_opp_", ""); + final stats = playerStats[playerId]!; if (action.startsWith("add_pts_")) { int pts = int.parse(action.split("_").last); @@ -445,15 +466,16 @@ class PlacarController { if (stats["fls"]! > 0) stats["fls"] = stats["fls"]! - 1; if (isOpponent) { if (opponentFouls > 0) opponentFouls--; } else { if (myFouls > 0) myFouls--; } } + _saveLocalBackup(); // 👈 Grava na memória! } Future saveGameStats(BuildContext context) async { final supabase = Supabase.instance.client; isSaving = true; - onUpdate(); + notifyListeners(); try { - bool isGameFinishedNow = currentQuarter >= 4 && duration.inSeconds == 0; + bool isGameFinishedNow = currentQuarter >= 4 && durationNotifier.value.inSeconds == 0; String newStatus = isGameFinishedNow ? 'Terminado' : 'Pausado'; String topPtsName = '---'; int maxPts = -1; @@ -462,7 +484,7 @@ class PlacarController { String topDefName = '---'; int maxDef = -1; String mvpName = '---'; int maxMvpScore = -1; - playerStats.forEach((playerName, stats) { + playerStats.forEach((playerId, stats) { int pts = stats['pts'] ?? 0; int ast = stats['ast'] ?? 0; int rbs = stats['rbs'] ?? 0; @@ -471,18 +493,20 @@ class PlacarController { int defScore = stl + blk; int mvpScore = pts + ast + rbs + defScore; + + String pName = playerNames[playerId] ?? '---'; - if (pts > maxPts && pts > 0) { maxPts = pts; topPtsName = '$playerName ($pts)'; } - if (ast > maxAst && ast > 0) { maxAst = ast; topAstName = '$playerName ($ast)'; } - if (rbs > maxRbs && rbs > 0) { maxRbs = rbs; topRbsName = '$playerName ($rbs)'; } - if (defScore > maxDef && defScore > 0) { maxDef = defScore; topDefName = '$playerName ($defScore)'; } - if (mvpScore > maxMvpScore && mvpScore > 0) { maxMvpScore = mvpScore; mvpName = playerName; } + if (pts > maxPts && pts > 0) { maxPts = pts; topPtsName = '$pName ($pts)'; } + if (ast > maxAst && ast > 0) { maxAst = ast; topAstName = '$pName ($ast)'; } + if (rbs > maxRbs && rbs > 0) { maxRbs = rbs; topRbsName = '$pName ($rbs)'; } + if (defScore > maxDef && defScore > 0) { maxDef = defScore; topDefName = '$pName ($defScore)'; } + if (mvpScore > maxMvpScore && mvpScore > 0) { maxMvpScore = mvpScore; mvpName = pName; } }); await supabase.from('games').update({ 'my_score': myScore, 'opponent_score': opponentScore, - 'remaining_seconds': duration.inSeconds, + 'remaining_seconds': durationNotifier.value.inSeconds, 'my_timeouts': myTimeoutsUsed, 'opp_timeouts': opponentTimeoutsUsed, 'current_quarter': currentQuarter, @@ -495,9 +519,7 @@ class PlacarController { }).eq('id', gameId); if (isGameFinishedNow && !gameWasAlreadyFinished && myTeamDbId != null && oppTeamDbId != null) { - final teamsData = await supabase.from('teams').select('id, wins, losses, draws').inFilter('id', [myTeamDbId, oppTeamDbId]); - Map myTeamUpdate = {}; Map oppTeamUpdate = {}; @@ -507,84 +529,65 @@ class PlacarController { } if (myScore > opponentScore) { - myTeamUpdate['wins'] = (myTeamUpdate['wins'] ?? 0) + 1; - oppTeamUpdate['losses'] = (oppTeamUpdate['losses'] ?? 0) + 1; + myTeamUpdate['wins'] = (myTeamUpdate['wins'] ?? 0) + 1; oppTeamUpdate['losses'] = (oppTeamUpdate['losses'] ?? 0) + 1; } else if (myScore < opponentScore) { - myTeamUpdate['losses'] = (myTeamUpdate['losses'] ?? 0) + 1; - oppTeamUpdate['wins'] = (oppTeamUpdate['wins'] ?? 0) + 1; + myTeamUpdate['losses'] = (myTeamUpdate['losses'] ?? 0) + 1; oppTeamUpdate['wins'] = (oppTeamUpdate['wins'] ?? 0) + 1; } else { - myTeamUpdate['draws'] = (myTeamUpdate['draws'] ?? 0) + 1; - oppTeamUpdate['draws'] = (oppTeamUpdate['draws'] ?? 0) + 1; + myTeamUpdate['draws'] = (myTeamUpdate['draws'] ?? 0) + 1; oppTeamUpdate['draws'] = (oppTeamUpdate['draws'] ?? 0) + 1; } - await supabase.from('teams').update({ - 'wins': myTeamUpdate['wins'], 'losses': myTeamUpdate['losses'], 'draws': myTeamUpdate['draws'] - }).eq('id', myTeamDbId!); - - await supabase.from('teams').update({ - 'wins': oppTeamUpdate['wins'], 'losses': oppTeamUpdate['losses'], 'draws': oppTeamUpdate['draws'] - }).eq('id', oppTeamDbId!); + await supabase.from('teams').update({'wins': myTeamUpdate['wins'], 'losses': myTeamUpdate['losses'], 'draws': myTeamUpdate['draws']}).eq('id', myTeamDbId!); + await supabase.from('teams').update({'wins': oppTeamUpdate['wins'], 'losses': oppTeamUpdate['losses'], 'draws': oppTeamUpdate['draws']}).eq('id', oppTeamDbId!); gameWasAlreadyFinished = true; } List> batchStats = []; - playerStats.forEach((playerName, stats) { - String? memberDbId = playerDbIds[playerName]; - if (memberDbId != null && stats.values.any((val) => val > 0)) { - bool isMyTeamPlayer = myCourt.contains(playerName) || myBench.contains(playerName); + playerStats.forEach((playerId, stats) { + if (!playerId.startsWith("fake_") && stats.values.any((val) => val > 0)) { + bool isMyTeamPlayer = myCourt.contains(playerId) || myBench.contains(playerId); batchStats.add({ - 'game_id': gameId, 'member_id': memberDbId, 'team_id': isMyTeamPlayer ? myTeamDbId! : oppTeamDbId!, + 'game_id': gameId, 'member_id': playerId, 'team_id': isMyTeamPlayer ? myTeamDbId! : oppTeamDbId!, 'pts': stats['pts'], 'rbs': stats['rbs'], 'ast': stats['ast'], 'stl': stats['stl'], 'blk': stats['blk'], 'tov': stats['tov'], 'fls': stats['fls'], 'fgm': stats['fgm'], 'fga': stats['fga'], 'ftm': stats['ftm'], 'fta': stats['fta'], 'orb': stats['orb'], 'drb': stats['drb'], }); } }); await supabase.from('player_stats').delete().eq('game_id', gameId); - if (batchStats.isNotEmpty) { - await supabase.from('player_stats').insert(batchStats); - } + if (batchStats.isNotEmpty) await supabase.from('player_stats').insert(batchStats); - // 👇 6. GUARDA AS BOLINHAS (MAPA DE CALOR) NO SUPABASE 👇 List> batchShots = []; for (var shot in matchShots) { - String? memberDbId = playerDbIds[shot.playerName]; - if (memberDbId != null) { + if (!shot.playerId.startsWith("fake_")) { batchShots.add({ - 'game_id': gameId, - 'member_id': memberDbId, - 'player_name': shot.playerName, - 'relative_x': shot.relativeX, - 'relative_y': shot.relativeY, - 'is_make': shot.isMake, - 'zone': shot.zone ?? 'Desconhecida', - 'points': shot.points ?? (shot.isMake ? 2 : 0), + 'game_id': gameId, 'member_id': shot.playerId, 'player_name': shot.playerName, + 'relative_x': shot.relativeX, 'relative_y': shot.relativeY, 'is_make': shot.isMake, + 'zone': shot.zone ?? 'Desconhecida', 'points': shot.points ?? (shot.isMake ? 2 : 0), }); } } - // Apaga os antigos (para não duplicar) e guarda os novos! await supabase.from('shot_locations').delete().eq('game_id', gameId); - if (batchShots.isNotEmpty) { - await supabase.from('shot_locations').insert(batchShots); - } + if (batchShots.isNotEmpty) await supabase.from('shot_locations').insert(batchShots); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Estatísticas, Mapa de Calor e Resultados guardados com Sucesso!'), backgroundColor: Colors.green)); - } + // 👇 SE O SUPABASE GUARDOU COM SUCESSO, LIMPA A MEMÓRIA DO TELEMÓVEL! 👇 + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('backup_$gameId'); + + if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Estatísticas, Mapa de Calor e Resultados guardados com Sucesso!'), backgroundColor: Colors.green)); } catch (e) { debugPrint("Erro ao gravar estatísticas: $e"); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Erro ao guardar: $e'), backgroundColor: Colors.red)); - } + if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Erro ao guardar: $e'), backgroundColor: Colors.red)); } finally { isSaving = false; - onUpdate(); + notifyListeners(); } } + @override void dispose() { timer?.cancel(); + super.dispose(); } } \ No newline at end of file diff --git a/lib/controllers/stats_controller.dart b/lib/controllers/stats_controller.dart deleted file mode 100644 index aa3a955..0000000 --- a/lib/controllers/stats_controller.dart +++ /dev/null @@ -1,158 +0,0 @@ -/*import 'package:flutter/material.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; -import '../models/person_model.dart'; - -class StatsController { - final SupabaseClient _supabase = Supabase.instance.client; - - // 1. LER - Stream> getMembers(String teamId) { - return _supabase - .from('members') - .stream(primaryKey: ['id']) - .eq('team_id', teamId) - .order('name', ascending: true) - .map((data) => data.map((json) => Person.fromMap(json)).toList()); - } - - // 2. APAGAR - Future deletePerson(String personId) async { - try { - await _supabase.from('members').delete().eq('id', personId); - } catch (e) { - debugPrint("Erro ao eliminar: $e"); - } - } - - // 3. DIÁLOGOS - void showAddPersonDialog(BuildContext context, String teamId) { - _showForm(context, teamId: teamId); - } - - void showEditPersonDialog(BuildContext context, String teamId, Person person) { - _showForm(context, teamId: teamId, person: person); - } - - // --- O POPUP ESTÁ AQUI --- - void _showForm(BuildContext context, {required String teamId, Person? person}) { - final isEdit = person != null; - final nameCtrl = TextEditingController(text: person?.name ?? ''); - final numCtrl = TextEditingController(text: person?.number ?? ''); - - // Define o valor inicial - String selectedType = person?.type ?? 'Jogador'; - - showDialog( - context: context, - builder: (ctx) => StatefulBuilder( - builder: (ctx, setState) => AlertDialog( - title: Text(isEdit ? "Editar" : "Adicionar"), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // NOME - TextField( - controller: nameCtrl, - decoration: const InputDecoration(labelText: "Nome"), - textCapitalization: TextCapitalization.sentences, - ), - const SizedBox(height: 10), - - // FUNÇÃO - DropdownButtonFormField( - value: selectedType, - decoration: const InputDecoration(labelText: "Função"), - items: ["Jogador", "Treinador"] - .map((e) => DropdownMenuItem(value: e, child: Text(e))) - .toList(), - onChanged: (v) { - if (v != null) setState(() => selectedType = v); - }, - ), - - // NÚMERO (Só aparece se for Jogador) - if (selectedType == "Jogador") ...[ - const SizedBox(height: 10), - TextField( - controller: numCtrl, - decoration: const InputDecoration(labelText: "Número da Camisola"), - keyboardType: TextInputType.text, // Aceita texto para evitar erros - ), - ], - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text("Cancelar") - ), - ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF00C853)), - onPressed: () async { - print("--- 1. CLICOU EM GUARDAR ---"); - - // Validação Simples - if (nameCtrl.text.trim().isEmpty) { - print("ERRO: Nome vazio"); - return; - } - - // Lógica do Número: - // Se for Treinador -> envia NULL - // Se for Jogador e estiver vazio -> envia NULL - // Se tiver texto -> envia o Texto - String? numeroFinal; - if (selectedType == "Treinador") { - numeroFinal = null; - } else { - numeroFinal = numCtrl.text.trim().isEmpty ? null : numCtrl.text.trim(); - } - - print("--- 2. DADOS A ENVIAR ---"); - print("Nome: ${nameCtrl.text}"); - print("Tipo: $selectedType"); - print("Número: $numeroFinal"); - - try { - if (isEdit) { - await _supabase.from('members').update({ - 'name': nameCtrl.text.trim(), - 'type': selectedType, - 'number': numeroFinal, - }).eq('id', person!.id); - } else { - await _supabase.from('members').insert({ - 'team_id': teamId, // Verifica se este teamId é válido! - 'name': nameCtrl.text.trim(), - 'type': selectedType, - 'number': numeroFinal, - }); - } - - print("--- 3. SUCESSO! FECHANDO DIÁLOGO ---"); - if (ctx.mounted) Navigator.pop(ctx); - - } catch (e) { - print("--- X. ERRO AO GUARDAR ---"); - print(e.toString()); - - // MOSTRA O ERRO NO TELEMÓVEL - if (ctx.mounted) { - ScaffoldMessenger.of(ctx).showSnackBar( - SnackBar( - content: Text("Erro: $e"), - backgroundColor: Colors.red, - duration: const Duration(seconds: 4), - ), - ); - } - } - }, - child: const Text("Guardar", style: TextStyle(color: Colors.white)), - ) - ], - ), - ), - ); - } -}*/ \ No newline at end of file diff --git a/lib/controllers/team_controller.dart b/lib/controllers/team_controller.dart index 3d27f08..6978f3a 100644 --- a/lib/controllers/team_controller.dart +++ b/lib/controllers/team_controller.dart @@ -1,7 +1,7 @@ +import 'dart:io'; import 'package:supabase_flutter/supabase_flutter.dart'; class TeamController { - // Instância do cliente Supabase final _supabase = Supabase.instance.client; // 1. STREAM (Realtime) @@ -13,18 +13,39 @@ class TeamController { .map((data) => List>.from(data)); } - // 2. CRIAR - Future createTeam(String name, String season, String? imageUrl) async { + // 2. CRIAR (Agora aceita um File e faz o Upload!) + Future createTeam(String name, String season, File? imageFile) async { try { + String? uploadedImageUrl; + + // Se o utilizador escolheu uma imagem, fazemos o upload primeiro + if (imageFile != null) { + // Criar um nome único para o ficheiro (ex: id do user + timestamp) + final userId = _supabase.auth.currentUser?.id ?? 'default'; + final fileName = '${userId}_${DateTime.now().millisecondsSinceEpoch}.png'; + final storagePath = 'teams/$fileName'; + + // Faz o upload para o bucket 'avatars' (podes usar o mesmo ou criar um chamado 'teams_logos') + await _supabase.storage.from('avatars').upload( + storagePath, + imageFile, + fileOptions: const FileOptions(cacheControl: '3600', upsert: true) + ); + + // Vai buscar o URL público + uploadedImageUrl = _supabase.storage.from('avatars').getPublicUrl(storagePath); + } + + // Agora insere a equipa na base de dados (com ou sem URL) await _supabase.from('teams').insert({ 'name': name, 'season': season, - 'image_url': imageUrl, + 'image_url': uploadedImageUrl ?? '', // Se não houver foto, guarda vazio 'is_favorite': false, }); print("✅ Equipa guardada no Supabase!"); } catch (e) { - print("❌ Erro ao criar: $e"); + print("❌ Erro ao criar equipa: $e"); } } @@ -42,7 +63,7 @@ class TeamController { try { await _supabase .from('teams') - .update({'is_favorite': !currentStatus}) // Inverte o valor + .update({'is_favorite': !currentStatus}) .eq('id', teamId); } catch (e) { print("❌ Erro ao favoritar: $e"); @@ -52,28 +73,20 @@ class TeamController { // 5. CONTAR JOGADORES (LEITURA ÚNICA) Future getPlayerCount(String teamId) async { try { - final count = await _supabase - .from('members') - .count() - .eq('team_id', teamId); + final count = await _supabase.from('members').count().eq('team_id', teamId); return count; } catch (e) { - print("Erro ao contar jogadores: $e"); return 0; } } - // 👇 6. A FUNÇÃO QUE RESOLVE O ERRO (EM TEMPO REAL) 👇 - Stream getPlayerCountStream(String teamId) { - return _supabase - .from('members') - .stream(primaryKey: ['id']) - .eq('team_id', teamId) - .map((membros) => membros - .where((membro) => membro['type'] == 'Jogador') - .length); + // 6. CONTAR JOGADORES (STREAM EM TEMPO REAL) +Future>> getTeamsWithStats() async { + final data = await _supabase + .from('teams_with_stats') // Lemos da View que criámos! + .select('*') + .order('name', ascending: true); + return List>.from(data); } - - // Mantemos o dispose vazio para não quebrar a chamada na TeamsPage void dispose() {} } \ No newline at end of file diff --git a/lib/models/game_model.dart b/lib/models/game_model.dart index 64f2f79..593b1f4 100644 --- a/lib/models/game_model.dart +++ b/lib/models/game_model.dart @@ -1,38 +1,71 @@ class Game { final String id; + final String userId; final String myTeam; final String opponentTeam; - final String? myTeamLogo; // URL da imagem - final String? opponentTeamLogo; // URL da imagem - final String myScore; + final String myScore; final String opponentScore; - final String status; final String season; + final String status; + final DateTime gameDate; + + // Novos campos que estão na tua base de dados + final int remainingSeconds; + final int myTimeouts; + final int oppTimeouts; + final int currentQuarter; + final String topPtsName; + final String topAstName; + final String topRbsName; + final String topDefName; + final String mvpName; Game({ required this.id, + required this.userId, required this.myTeam, required this.opponentTeam, - this.myTeamLogo, - this.opponentTeamLogo, required this.myScore, required this.opponentScore, - required this.status, required this.season, + required this.status, + required this.gameDate, + required this.remainingSeconds, + required this.myTimeouts, + required this.oppTimeouts, + required this.currentQuarter, + required this.topPtsName, + required this.topAstName, + required this.topRbsName, + required this.topDefName, + required this.mvpName, }); - // No seu factory, certifique-se de mapear os campos da tabela (ou de um JOIN) - factory Game.fromMap(Map map) { + // 👇 A MÁGICA ACONTECE AQUI: Lemos os dados e protegemos os NULLs + factory Game.fromMap(Map json) { return Game( - id: map['id'], - myTeam: map['my_team_name'], - opponentTeam: map['opponent_team_name'], - myTeamLogo: map['my_team_logo'], // Certifique-se que o Supabase retorna isto - opponentTeamLogo: map['opponent_team_logo'], - myScore: map['my_score'].toString(), - opponentScore: map['opponent_score'].toString(), - status: map['status'], - season: map['season'], + id: json['id']?.toString() ?? '', + userId: json['user_id']?.toString() ?? '', + myTeam: json['my_team']?.toString() ?? 'Minha Equipa', + opponentTeam: json['opponent_team']?.toString() ?? 'Adversário', + myScore: (json['my_score'] ?? 0).toString(), // Protege NULL e converte Int4 para String + opponentScore: (json['opponent_score'] ?? 0).toString(), + season: json['season']?.toString() ?? '---', + status: json['status']?.toString() ?? 'Decorrer', + gameDate: json['game_date'] != null ? DateTime.tryParse(json['game_date']) ?? DateTime.now() : DateTime.now(), + + // Proteção para os Inteiros (se for NULL, assume 0) + remainingSeconds: json['remaining_seconds'] as int? ?? 600, // 600s = 10 minutos + myTimeouts: json['my_timeouts'] as int? ?? 0, + oppTimeouts: json['opp_timeouts'] as int? ?? 0, + currentQuarter: json['current_quarter'] as int? ?? 1, + + // Proteção para os Nomes (se for NULL, assume '---') + topPtsName: json['top_pts_name']?.toString() ?? '---', + topAstName: json['top_ast_name']?.toString() ?? '---', + topRbsName: json['top_rbs_name']?.toString() ?? '---', + topDefName: json['top_def_name']?.toString() ?? '---', + mvpName: json['mvp_name']?.toString() ?? '---', ); } } \ No newline at end of file diff --git a/lib/models/person_model.dart b/lib/models/person_model.dart index 840bce7..b222328 100644 --- a/lib/models/person_model.dart +++ b/lib/models/person_model.dart @@ -3,24 +3,43 @@ class Person { final String teamId; final String name; final String type; // 'Jogador' ou 'Treinador' - final String number; + final String? number; // O número é opcional (Treinadores não têm) + + // 👇 A NOVA PROPRIEDADE AQUI! + final String? imageUrl; Person({ required this.id, required this.teamId, required this.name, required this.type, - required this.number, + this.number, + this.imageUrl, // 👇 ADICIONADO AO CONSTRUTOR }); - // Converte o JSON do Supabase para o objeto Person + // Lê os dados do Supabase e converte para a classe Person factory Person.fromMap(Map map) { return Person( - id: map['id'] ?? '', - teamId: map['team_id'] ?? '', - name: map['name'] ?? '', - type: map['type'] ?? 'Jogador', - number: map['number']?.toString() ?? '', + id: map['id']?.toString() ?? '', + teamId: map['team_id']?.toString() ?? '', + name: map['name']?.toString() ?? 'Desconhecido', + type: map['type']?.toString() ?? 'Jogador', + number: map['number']?.toString(), + + // 👇 AGORA ELE JÁ SABE LER O LINK DA IMAGEM DA TUA BASE DE DADOS! + imageUrl: map['image_url']?.toString(), ); } + + // Prepara os dados para enviar para o Supabase (se necessário) + Map toMap() { + return { + 'id': id, + 'team_id': teamId, + 'name': name, + 'type': type, + 'number': number, + 'image_url': imageUrl, // 👇 TAMBÉM GUARDA A IMAGEM + }; + } } \ No newline at end of file diff --git a/lib/models/team_model.dart b/lib/models/team_model.dart index 3a23600..9cd709e 100644 --- a/lib/models/team_model.dart +++ b/lib/models/team_model.dart @@ -4,26 +4,33 @@ class Team { final String season; final String imageUrl; final bool isFavorite; + final String createdAt; + final int playerCount; // 👇 NOVA VARIÁVEL AQUI Team({ required this.id, required this.name, required this.season, required this.imageUrl, - this.isFavorite = false + required this.isFavorite, + required this.createdAt, + this.playerCount = 0, // 👇 VALOR POR DEFEITO }); - // Mapeia o JSON que vem do Supabase (id costuma ser UUID ou String) factory Team.fromMap(Map map) { return Team( id: map['id']?.toString() ?? '', - name: map['name'] ?? '', - season: map['season'] ?? '', - imageUrl: map['image_url'] ?? '', + name: map['name']?.toString() ?? 'Sem Nome', + season: map['season']?.toString() ?? '', + imageUrl: map['image_url']?.toString() ?? '', isFavorite: map['is_favorite'] ?? false, + createdAt: map['created_at']?.toString() ?? '', + // 👇 AGORA ELE LÊ A CONTAGEM DA TUA NOVA VIEW! + playerCount: map['player_count'] != null ? int.tryParse(map['player_count'].toString()) ?? 0 : 0, ); } + Map toMap() { return { 'name': name, diff --git a/lib/pages/PlacarPage.dart b/lib/pages/PlacarPage.dart index 4a943c4..f61ae14 100644 --- a/lib/pages/PlacarPage.dart +++ b/lib/pages/PlacarPage.dart @@ -1,462 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:playmaker/controllers/placar_controller.dart'; -import 'package:playmaker/utils/size_extension.dart'; -import 'package:playmaker/classe/theme.dart'; import 'dart:math' as math; -import 'package:playmaker/zone_map_dialog.dart'; +import '../utils/size_extension.dart'; +import '../classe/theme.dart'; +import '../controllers/placar_controller.dart'; +import '../widgets/placar_widgets.dart'; -// ============================================================================ -// 1. PLACAR SUPERIOR (CRONÓMETRO E RESULTADO) - TAMANHO REDUZIDO -// ============================================================================ -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( - // 👇 Reduzido padding vertical e horizontal - padding: EdgeInsets.symmetric(vertical: 6 * sf, horizontal: 20 * sf), - decoration: BoxDecoration( - color: AppTheme.placarDarkSurface, - borderRadius: BorderRadius.only( - bottomLeft: Radius.circular(22 * sf), - bottomRight: Radius.circular(22 * sf) - ), - border: Border.all(color: Colors.white, width: 2.0 * sf), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _buildTeamSection(controller.myTeam, controller.myScore, controller.myFouls, controller.myTimeoutsUsed, AppTheme.myTeamBlue, false, sf), - SizedBox(width: 20 * sf), // 👇 Reduzido espaçamento central - Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: EdgeInsets.symmetric(horizontal: 14 * sf, vertical: 4 * sf), // 👇 Reduzido - decoration: BoxDecoration( - color: AppTheme.placarTimerBg, - borderRadius: BorderRadius.circular(9 * sf) - ), - child: Text( - controller.formatTime(), - style: TextStyle(color: Colors.white, fontSize: 24 * sf, fontWeight: FontWeight.w900, fontFamily: 'monospace', letterSpacing: 1.5 * sf) // 👇 Fonte reduzida - ), - ), - SizedBox(height: 4 * sf), - Text( - "PERÍODO ${controller.currentQuarter}", - style: TextStyle(color: AppTheme.warningAmber, fontSize: 12 * sf, fontWeight: FontWeight.w900) // 👇 Fonte reduzida - ), - ], - ), - SizedBox(width: 20 * sf), // 👇 Reduzido espaçamento central - _buildTeamSection(controller.opponentTeam, controller.opponentScore, controller.opponentFouls, controller.opponentTimeoutsUsed, AppTheme.oppTeamRed, true, sf), - ], - ), - ); - } - - Widget _buildTeamSection(String name, int score, int fouls, int timeouts, Color color, bool isOpp, double sf) { - int displayFouls = fouls > 5 ? 5 : fouls; - - final timeoutIndicators = Row( - mainAxisSize: MainAxisSize.min, - children: List.generate(3, (index) => Container( - margin: EdgeInsets.symmetric(horizontal: 2.5 * sf), // 👇 Reduzido - width: 10 * sf, height: 10 * sf, // 👇 Bolas de timeout menores - decoration: BoxDecoration( - shape: BoxShape.circle, - color: index < timeouts ? AppTheme.warningAmber : Colors.grey.shade600, - border: Border.all(color: Colors.white54, width: 1.0 * sf) - ), - )), - ); - - List content = [ - Column( - children: [ - _scoreBox(score, color, sf), - SizedBox(height: 5 * sf), // 👇 Reduzido - timeoutIndicators - ] - ), - SizedBox(width: 12 * sf), // 👇 Reduzido - Column( - crossAxisAlignment: isOpp ? CrossAxisAlignment.start : CrossAxisAlignment.end, - children: [ - Text( - name.toUpperCase(), - style: TextStyle(color: Colors.white, fontSize: 16 * sf, fontWeight: FontWeight.w900, letterSpacing: 1.0 * sf) // 👇 Fonte reduzida - ), - SizedBox(height: 3 * sf), // 👇 Reduzido - Text( - "FALTAS: $displayFouls", - style: TextStyle(color: displayFouls >= 5 ? AppTheme.actionMiss : AppTheme.warningAmber, fontSize: 11 * sf, fontWeight: FontWeight.bold) // 👇 Fonte reduzida - ), - ], - ) - ]; - - return Row(crossAxisAlignment: CrossAxisAlignment.center, children: isOpp ? content : content.reversed.toList()); - } - - Widget _scoreBox(int score, Color color, double sf) => Container( - width: 45 * sf, height: 35 * sf, // 👇 Caixa de pontuação menor - alignment: Alignment.center, - decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(6 * sf)), - child: Text(score.toString(), style: TextStyle(color: Colors.white, fontSize: 20 * sf, fontWeight: FontWeight.w900)), // 👇 Fonte reduzida - ); -} - -// ============================================================================ -// 2. BANCO DE SUPLENTES (DRAG & DROP) - TAMANHO REDUZIDO -// ============================================================================ -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 ? AppTheme.oppTeamRed : AppTheme.myTeamBlue; - final prefix = isOpponent ? "bench_opp_" : "bench_my_"; - - return Column( - mainAxisSize: MainAxisSize.min, - children: bench.map((playerName) { - final num = controller.playerNumbers[playerName] ?? "0"; - final int fouls = controller.playerStats[playerName]?["fls"] ?? 0; - final bool isFouledOut = fouls >= 5; - - Widget avatarUI = Container( - margin: EdgeInsets.only(bottom: 5 * sf), // 👇 Reduzido - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 1.5 * sf), // 👇 Reduzido - boxShadow: [BoxShadow(color: Colors.black45, blurRadius: 4 * sf, offset: Offset(0, 2.0 * sf))] - ), - child: CircleAvatar( - radius: 18 * sf, // 👇 Avatar do banco menor - backgroundColor: isFouledOut ? Colors.grey.shade800 : teamColor, - child: Text( - num, - style: TextStyle( - color: isFouledOut ? Colors.red.shade300 : Colors.white, - fontSize: 14 * sf, // 👇 Fonte reduzida - 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: AppTheme.actionMiss)), - child: avatarUI - ); - } - - return Draggable( - data: "$prefix$playerName", - feedback: Material( - color: Colors.transparent, - child: CircleAvatar( - radius: 22 * sf, // 👇 Avatar ao arrastar menor - backgroundColor: teamColor, - child: Text(num, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16 * sf)) - ) - ), - childWhenDragging: Opacity(opacity: 0.5, child: SizedBox(width: 36 * sf, height: 36 * sf)), // 👇 Placeholder menor - child: avatarUI, - ); - }).toList(), - ); - } -} - -// ============================================================================ -// 3. CARTÃO DO JOGADOR NO CAMPO - TAMANHO REDUZIDO -// ============================================================================ -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 ? AppTheme.oppTeamRed : AppTheme.myTeamBlue; - final stats = controller.playerStats[name]!; - final number = controller.playerNumbers[name]!; - final prefix = isOpponent ? "player_opp_" : "player_my_"; - - return Draggable( - data: "$prefix$name", - feedback: Material( - color: Colors.transparent, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), // 👇 Reduzido - decoration: BoxDecoration(color: teamColor.withOpacity(0.9), borderRadius: BorderRadius.circular(6)), - child: Text(name, style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), - ), - ), - childWhenDragging: Opacity(opacity: 0.5, child: _playerCardUI(number, name, stats, teamColor, false, false, sf)), - child: DragTarget( - onAcceptWithDetails: (details) { - final action = details.data; - - if (action == "add_pts_2" || action == "add_pts_3" || action == "miss_2" || action == "miss_3") { - bool isMake = action.startsWith("add_"); - bool is3Pt = action.endsWith("_3"); - - showDialog( - context: context, - builder: (ctx) => ZoneMapDialog( - playerName: name, - isMake: isMake, - is3PointAction: is3Pt, - onZoneSelected: (zone, points, relX, relY) { - Navigator.pop(ctx); - controller.registerShotFromPopup(context, action, "$prefix$name", zone, points, relX, relY); - }, - ), - ); - } - else 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) { - bool isSubbing = candidateData.any((data) => data != null && (data.startsWith("bench_my_") || data.startsWith("bench_opp_"))); - bool isActionHover = candidateData.any((data) => data != null && (data.startsWith("add_") || data.startsWith("sub_") || data.startsWith("miss_"))); - return _playerCardUI(number, name, stats, teamColor, isSubbing, isActionHover, sf); - }, - ), - ); - } - - Widget _playerCardUI(String number, String name, Map stats, Color teamColor, bool isSubbing, bool isActionHover, double sf) { - bool isFouledOut = stats["fls"]! >= 5; - Color bgColor = isFouledOut ? Colors.red.shade100 : Colors.white; - Color borderColor = isFouledOut ? AppTheme.actionMiss : Colors.transparent; - - if (isSubbing) { - bgColor = Colors.blue.shade50; borderColor = AppTheme.myTeamBlue; - } else if (isActionHover && !isFouledOut) { - bgColor = Colors.orange.shade50; borderColor = AppTheme.actionPoints; - } - - 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 Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), // 👇 Reduzido padding do cartão - decoration: BoxDecoration( - color: bgColor, borderRadius: BorderRadius.circular(8), border: Border.all(color: borderColor, width: 1.5), // 👇 Bordas reduzidas - boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 4, offset: Offset(0, 2))], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(6 * sf), - child: IntrinsicHeight( - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Container( - padding: EdgeInsets.symmetric(horizontal: 10 * sf), // 👇 Padding do número menor - color: isFouledOut ? Colors.grey[700] : teamColor, - alignment: Alignment.center, - child: Text(number, style: TextStyle(color: Colors.white, fontSize: 18 * sf, fontWeight: FontWeight.bold)), // 👇 Fonte do número menor - ), - Padding( - padding: EdgeInsets.symmetric(horizontal: 8 * sf, vertical: 4 * sf), // 👇 Padding dos stats menor - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - displayName, - style: TextStyle(fontSize: 14 * sf, fontWeight: FontWeight.bold, color: isFouledOut ? AppTheme.actionMiss : Colors.black87, decoration: isFouledOut ? TextDecoration.lineThrough : TextDecoration.none) // 👇 Nome menor - ), - SizedBox(height: 1.5 * sf), // 👇 Espaçamento menor - Text( - "${stats["pts"]} Pts | FG: $fgm/$fga ($fgPercent%)", - style: TextStyle(fontSize: 10 * sf, color: isFouledOut ? AppTheme.actionMiss : Colors.grey[700], fontWeight: FontWeight.w600) // 👇 Stats menores - ), - Text( - "${stats["ast"]} Ast | ${stats["orb"]! + stats["drb"]!} Rbs | ${stats["fls"]} Fls", - style: TextStyle(fontSize: 10 * sf, color: isFouledOut ? AppTheme.actionMiss : Colors.grey[500], fontWeight: FontWeight.w600) // 👇 Stats menores - ), - ], - ), - ), - ], - ), - ), - ), - ); - } -} - -// ============================================================================ -// 4. PAINEL DE BOTÕES DE AÇÃO - TAMANHO REDUZIDO -// ============================================================================ -class ActionButtonsPanel extends StatelessWidget { - final PlacarController controller; - final double sf; - - const ActionButtonsPanel({super.key, required this.controller, required this.sf}); - -@override - Widget build(BuildContext context) { - // 👇👇 Ajuste para o "Meio-Termo" ideal 👇👇 - final double baseSize = 58 * sf; // Aumentado de 50 para 58 - final double feedSize = 73 * sf; // Aumentado de 65 para 73 - final double gap = 5 * sf; - - return Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - _columnBtn([ - _dragAndTargetBtn("M1", AppTheme.actionMiss, "miss_1", baseSize, feedSize, sf), - _dragAndTargetBtn("1", AppTheme.actionPoints, "add_pts_1", baseSize, feedSize, sf), - _dragAndTargetBtn("1", AppTheme.actionPoints, "sub_pts_1", baseSize, feedSize, sf, isX: true), - _dragAndTargetBtn("STL", AppTheme.actionSteal, "add_stl", baseSize, feedSize, sf), - ], gap), - SizedBox(width: gap * 1), - _columnBtn([ - _dragAndTargetBtn("M2", AppTheme.actionMiss, "miss_2", baseSize, feedSize, sf), - _dragAndTargetBtn("2", AppTheme.actionPoints, "add_pts_2", baseSize, feedSize, sf), - _dragAndTargetBtn("2", AppTheme.actionPoints, "sub_pts_2", baseSize, feedSize, sf, isX: true), - _dragAndTargetBtn("AST", AppTheme.actionAssist, "add_ast", baseSize, feedSize, sf), - ], gap), - SizedBox(width: gap * 1), - _columnBtn([ - _dragAndTargetBtn("M3", AppTheme.actionMiss, "miss_3", baseSize, feedSize, sf), - _dragAndTargetBtn("3", AppTheme.actionPoints, "add_pts_3", baseSize, feedSize, sf), - _dragAndTargetBtn("3", AppTheme.actionPoints, "sub_pts_3", baseSize, feedSize, sf, isX: true), - _dragAndTargetBtn("TOV", AppTheme.actionMiss, "add_tov", baseSize, feedSize, sf), - ], gap), - SizedBox(width: gap * 1), - _columnBtn([ - _dragAndTargetBtn("ORB", AppTheme.actionRebound, "add_orb", baseSize, feedSize, sf, icon: Icons.sports_basketball), - _dragAndTargetBtn("DRB", AppTheme.actionRebound, "add_drb", baseSize, feedSize, sf, icon: Icons.sports_basketball), - _dragAndTargetBtn("BLK", AppTheme.actionBlock, "add_blk", baseSize, feedSize, sf, icon: Icons.front_hand), - ], gap), - ], - ); - } - - Widget _columnBtn(List children, double gap) { - return Column( - mainAxisSize: MainAxisSize.min, - children: children.map((c) => Padding(padding: EdgeInsets.only(bottom: gap), child: c)).toList() - ); - } - - Widget _dragAndTargetBtn(String label, Color color, String actionData, double baseSize, double feedSize, double sf, {IconData? icon, bool isX = false}) { - return Draggable( - data: actionData, - feedback: _circle(label, color, icon, true, baseSize, feedSize, sf, isX: isX), - childWhenDragging: Opacity( - opacity: 0.5, - child: _circle(label, color, icon, false, baseSize, feedSize, sf, isX: isX) - ), - child: DragTarget( - onAcceptWithDetails: (details) {}, - builder: (context, candidateData, rejectedData) { - bool isHovered = candidateData.any((data) => data != null && data.startsWith("player_")); - return Transform.scale( - scale: isHovered ? 1.15 : 1.0, - child: Container( - decoration: isHovered ? BoxDecoration(shape: BoxShape.circle, boxShadow: [BoxShadow(color: Colors.white, blurRadius: 10 * sf, spreadRadius: 3 * sf)]) : null, - child: _circle(label, color, icon, false, baseSize, feedSize, sf, isX: isX) - ), - ); - } - ), - ); - } - - Widget _circle(String label, Color color, IconData? icon, bool isFeed, double baseSize, double feedSize, double sf, {bool isX = false}) { - double size = isFeed ? feedSize : baseSize; - Widget content; - bool isPointBtn = label == "1" || label == "2" || label == "3" || label == "M1" || label == "M2" || label == "M3"; - bool isBlkBtn = label == "BLK"; - - if (isPointBtn) { - content = Stack( - alignment: Alignment.center, - children: [ - Container(width: size * 0.75, height: size * 0.75, decoration: const BoxDecoration(color: Colors.black, shape: BoxShape.circle)), - Icon(Icons.sports_basketball, color: color, size: size * 0.9), - Stack( - children: [ - Text(label, style: TextStyle(fontSize: size * 0.38, fontWeight: FontWeight.w900, foreground: Paint()..style = PaintingStyle.stroke..strokeWidth = size * 0.05..color = Colors.white, decoration: TextDecoration.none)), - Text(label, style: TextStyle(fontSize: size * 0.38, fontWeight: FontWeight.w900, color: Colors.black, decoration: TextDecoration.none)), - ], - ), - ], - ); - } else if (isBlkBtn) { - content = Stack( - alignment: Alignment.center, - children: [ - Icon(Icons.front_hand, color: const Color.fromARGB(207, 56, 52, 52), size: size * 0.75), - Stack( - alignment: Alignment.center, - children: [ - Text(label, style: TextStyle(fontSize: size * 0.28, fontWeight: FontWeight.w900, foreground: Paint()..style = PaintingStyle.stroke..strokeWidth = size * 0.05..color = Colors.black, decoration: TextDecoration.none)), - Text(label, style: TextStyle(fontSize: size * 0.28, fontWeight: FontWeight.w900, color: Colors.white, decoration: TextDecoration.none)), - ], - ), - ], - ); - } else if (icon != null) { - content = Icon(icon, color: Colors.white, size: size * 0.5); - } else { - content = Text(label, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: size * 0.35, decoration: TextDecoration.none)); - } - - return Stack( - clipBehavior: Clip.none, - alignment: Alignment.bottomRight, - children: [ - Container( - width: size, height: size, - decoration: (isPointBtn || isBlkBtn) - ? const BoxDecoration(color: Colors.transparent) - : BoxDecoration(gradient: RadialGradient(colors: [color.withOpacity(0.7), color], radius: 0.8), shape: BoxShape.circle, boxShadow: [BoxShadow(color: Colors.black38, blurRadius: 6 * sf, offset: Offset(0, 3 * sf))]), - alignment: Alignment.center, - child: content, - ), - if (isX) Positioned(top: 0, right: 0, child: Container(decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle), child: Icon(Icons.cancel, color: Colors.red, size: size * 0.4))), - ], - ); - } -} - -// ============================================================================ -// 5. PÁGINA DO PLACAR -// ============================================================================ class PlacarPage extends StatefulWidget { final String gameId, myTeam, opponentTeam; @@ -477,29 +27,30 @@ class _PlacarPageState extends State { @override void initState() { super.initState(); + // Força o ecrã deitado SystemChrome.setPreferredOrientations([ DeviceOrientation.landscapeRight, DeviceOrientation.landscapeLeft, ]); + // 👇 Inicia o controlador SEM o callback onUpdate (o AnimatedBuilder resolve isso) _controller = PlacarController( gameId: widget.gameId, myTeam: widget.myTeam, opponentTeam: widget.opponentTeam, - onUpdate: () { - if (mounted) setState(() {}); - } ); _controller.loadPlayers(); } @override void dispose() { + // 👇 Importante limpar o controller para parar timers em background _controller.dispose(); SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); super.dispose(); } + // Helper para botões de falta (Draggable) Widget _buildFloatingFoulBtn(String label, Color color, String action, IconData icon, double left, double right, double top, double sf) { return Positioned( top: top, @@ -510,52 +61,60 @@ class _PlacarPageState extends State { feedback: Material( color: Colors.transparent, child: CircleAvatar( - radius: 25 * sf, // 👇 Botão flutuante de falta menor + radius: 30 * sf, backgroundColor: color.withOpacity(0.8), - child: Icon(icon, color: Colors.white, size: 25 * sf) + child: Icon(icon, color: Colors.white, size: 30 * sf) ), ), child: Column( children: [ CircleAvatar( - radius: 22 * sf, // 👇 Botão flutuante menor + radius: 27 * sf, backgroundColor: color, - child: Icon(icon, color: Colors.white, size: 22 * sf), + child: Icon(icon, color: Colors.white, size: 28 * sf), ), SizedBox(height: 5 * sf), - Text(label, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 10 * sf)), // 👇 Texto menor + 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}) { + // Helper para botões de canto + 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(10 * (size / 40))), // 👇 Curvatura ajustada ao novo tamanho + backgroundColor: onTap == null ? Colors.grey : 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.0)) + ? 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), ), ); } void _showHeatmap(BuildContext context) { + Map> statsByNames = {}; + _controller.playerStats.forEach((id, stats) { + String nomeDoJogador = _controller.playerNames[id] ?? 'Desconhecido'; + statsByNames[nomeDoJogador] = stats; + }); + showDialog( context: context, builder: (ctx) => HeatmapDialog( shots: _controller.matchShots, myTeamName: _controller.myTeam, oppTeamName: _controller.opponentTeam, - myPlayers: [..._controller.myCourt, ..._controller.myBench], - oppPlayers: [..._controller.oppCourt, ..._controller.oppBench], + myPlayers: [..._controller.myCourt, ..._controller.myBench].map((id) => _controller.playerNames[id]!).toList(), + oppPlayers: [..._controller.oppCourt, ..._controller.oppBench].map((id) => _controller.playerNames[id]!).toList(), + playerStats: statsByNames, ), ); } @@ -564,422 +123,190 @@ class _PlacarPageState extends State { Widget build(BuildContext context) { final double wScreen = MediaQuery.of(context).size.width; final double hScreen = MediaQuery.of(context).size.height; - - // 👇👇 DICA EXTRA APLICADA AQUI: Aumentei o divisor base para que o sf seja menor por defeito - final double sf = math.min(wScreen / 1250, hScreen / 800); - final double cornerBtnSize = 40 * sf; // 👇 Botões dos cantos (Salvar, Mapa, Banco) menores + final double sf = math.min(wScreen / 1150, hScreen / 720); + final double cornerBtnSize = 48 * sf; - if (_controller.isLoading) { - return Scaffold( - backgroundColor: AppTheme.placarDarkSurface, - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text("PREPARANDO O PAVILHÃO", style: TextStyle(color: Colors.white24, fontSize: 45 * sf, fontWeight: FontWeight.bold, letterSpacing: 2)), - SizedBox(height: 35 * sf), - StreamBuilder( - stream: Stream.periodic(const Duration(seconds: 3)), - builder: (context, snapshot) { - List frases = [ - "O Treinador está a desenhar a tática...", - "A encher as bolas com ar de campeão...", - "O árbitro está a testar o apito...", - "A verificar se o cesto está nivelado...", - "Os jogadores estão a terminar o aquecimento..." - ]; - String frase = frases[DateTime.now().second % frases.length]; - return Text(frase, style: TextStyle(color: AppTheme.actionPoints.withOpacity(0.7), fontSize: 26 * sf, fontStyle: FontStyle.italic)); - }, - ), - ], - ), - ), - ); - } - - return Scaffold( - backgroundColor: AppTheme.placarBackground, - body: SafeArea( - top: false, - bottom: false, - child: IgnorePointer( - ignoring: _controller.isSaving, - child: Stack( - children: [ - Container( - margin: EdgeInsets.only(left: 55 * sf, right: 55 * sf, bottom: 45 * sf), // 👇 Margens reduzidas para dar mais espaço ao campo - decoration: BoxDecoration(border: Border.all(color: Colors.white, width: 2.5)), - child: LayoutBuilder( - builder: (context, constraints) { - final w = constraints.maxWidth; - final h = constraints.maxHeight; - - return Stack( - children: [ - GestureDetector( - onTapDown: (details) { - if (_controller.isSelectingShotLocation) { - _controller.registerShotLocation(context, details.localPosition, Size(w, h)); - } - }, - child: Container( - decoration: const BoxDecoration( - image: DecorationImage( - image: AssetImage('assets/campo.png'), - fit: BoxFit.fill, - ), - ), - ), - ), - - if (!_controller.isSelectingShotLocation) ...[ - Positioned(top: h * 0.25, left: w * 0.02, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[0], isOpponent: false, sf: sf)), - Positioned(top: h * 0.68, left: w * 0.02, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[1], isOpponent: false, sf: sf)), - Positioned(top: h * 0.45, left: w * 0.25, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[2], isOpponent: false, sf: sf)), - Positioned(top: h * 0.15, left: w * 0.20, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[3], isOpponent: false, sf: sf)), - Positioned(top: h * 0.80, left: w * 0.20, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[4], isOpponent: false, sf: sf)), - - Positioned(top: h * 0.25, right: w * 0.02, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[0], isOpponent: true, sf: sf)), - Positioned(top: h * 0.68, right: w * 0.02, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[1], isOpponent: true, sf: sf)), - Positioned(top: h * 0.45, right: w * 0.25, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[2], isOpponent: true, sf: sf)), - Positioned(top: h * 0.15, right: w * 0.20, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[3], isOpponent: true, sf: sf)), - Positioned(top: h * 0.80, right: w * 0.20, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[4], isOpponent: true, sf: sf)), - ], - - if (!_controller.isSelectingShotLocation) ...[ - _buildFloatingFoulBtn("FALTA +", AppTheme.actionPoints, "add_foul", Icons.sports, w * 0.39, 0.0, h * 0.31, sf), - _buildFloatingFoulBtn("FALTA -", AppTheme.actionMiss, "sub_foul", Icons.block, 0.0, w * 0.39, h * 0.31, sf), - ], - - if (!_controller.isSelectingShotLocation) - Positioned( - top: (h * 0.38) + (30 * sf), // 👇 Ajustado posição - left: 0, right: 0, - child: Center( - child: GestureDetector( - onTap: () => _controller.toggleTimer(context), - child: CircleAvatar( - radius: 60 * sf, // 👇 Botão de play/pause menor - backgroundColor: Colors.grey.withOpacity(0.5), - child: Icon(_controller.isRunning ? Icons.pause : Icons.play_arrow, color: Colors.white, size: 50 * sf) - ), - ), - ), - ), - - Positioned(top: 0, left: 0, right: 0, child: Center(child: TopScoreboard(controller: _controller, sf: sf))), - - if (!_controller.isSelectingShotLocation) Positioned(bottom: -5 * sf, left: 0, right: 0, child: ActionButtonsPanel(controller: _controller, sf: sf)), - - if (_controller.isSelectingShotLocation) - Positioned( - top: h * 0.4, left: 0, right: 0, - child: Center( - child: Container( - padding: EdgeInsets.symmetric(horizontal: 25 * sf, vertical: 12 * sf), // 👇 Aviso menor - decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8 * 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: 20 * sf, fontWeight: FontWeight.bold)), // 👇 Fonte menor - ), - ), - ), - ], - ); - }, - ), - ), - - Positioned( - top: 40 * sf, left: 8 * sf, - child: _buildCornerBtn( - heroTag: 'btn_save_exit', - icon: Icons.save_alt, - color: AppTheme.oppTeamRed, - size: cornerBtnSize, - isLoading: _controller.isSaving, - onTap: () async { - await _controller.saveGameStats(context); - if (context.mounted) { - Navigator.pop(context); - } - } - ), - ), - - Positioned( - top: 40 * sf, right: 8 * sf, - child: _buildCornerBtn( - heroTag: 'btn_heatmap', - icon: Icons.local_fire_department, - color: Colors.orange.shade800, - size: cornerBtnSize, - onTap: () => _showHeatmap(context), - ), - ), - - Positioned( - bottom: 45 * sf, left: 8 * sf, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_controller.showMyBench) BenchPlayersList(controller: _controller, isOpponent: false, sf: sf), - SizedBox(height: 8 * sf), - _buildCornerBtn(heroTag: 'btn_sub_home', icon: Icons.swap_horiz, color: AppTheme.myTeamBlue, size: cornerBtnSize, onTap: () { _controller.showMyBench = !_controller.showMyBench; _controller.onUpdate(); }), - SizedBox(height: 8 * sf), - _buildCornerBtn( - heroTag: 'btn_to_home', - icon: Icons.timer, - color: _controller.myTimeoutsUsed >= 3 ? Colors.grey : AppTheme.myTeamBlue, - size: cornerBtnSize, - onTap: _controller.myTimeoutsUsed >= 3 - ? () => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: const Text('🛑 A equipa da casa já usou os 3 Timeouts deste período!'), backgroundColor: AppTheme.actionMiss)) - : () => _controller.useTimeout(false) - ), - ], - ), - ), - - Positioned( - bottom: 45 * sf, right: 8 * sf, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_controller.showOppBench) BenchPlayersList(controller: _controller, isOpponent: true, sf: sf), - SizedBox(height: 8 * sf), - _buildCornerBtn(heroTag: 'btn_sub_away', icon: Icons.swap_horiz, color: AppTheme.oppTeamRed, size: cornerBtnSize, onTap: () { _controller.showOppBench = !_controller.showOppBench; _controller.onUpdate(); }), - SizedBox(height: 8 * sf), - _buildCornerBtn( - heroTag: 'btn_to_away', - icon: Icons.timer, - color: _controller.opponentTimeoutsUsed >= 3 ? Colors.grey : AppTheme.oppTeamRed, - size: cornerBtnSize, - onTap: _controller.opponentTimeoutsUsed >= 3 - ? () => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: const Text('🛑 A equipa visitante já usou os 3 Timeouts deste período!'), backgroundColor: AppTheme.actionMiss)) - : () => _controller.useTimeout(true) - ), - ], - ), - ), - - if (_controller.isSaving) - Positioned.fill( - child: Container( - color: Colors.black.withOpacity(0.4), - ), - ), - ], - ), - ), - ), - ); - } -} - -// ============================================================================ -// MAPA DE CALOR -// ============================================================================ -class HeatmapDialog extends StatefulWidget { - final List shots; - final String myTeamName; - final String oppTeamName; - final List myPlayers; - final List oppPlayers; - - const HeatmapDialog({ - super.key, - required this.shots, - required this.myTeamName, - required this.oppTeamName, - required this.myPlayers, - required this.oppPlayers, - }); - - @override - State createState() => _HeatmapDialogState(); -} - -class _HeatmapDialogState extends State { - String _selectedTeam = 'Ambas as Equipas'; - String _selectedPlayer = 'Todos os Jogadores'; - - @override - Widget build(BuildContext context) { - final Color headerColor = const Color(0xFFE88F15); - final Color yellowBackground = const Color(0xFFDFAB00); - - final double screenHeight = MediaQuery.of(context).size.height; - final double dialogHeight = screenHeight * 0.95; - final double dialogWidth = dialogHeight * 1.0; - - List filteredShots = widget.shots.where((shot) { - if (_selectedTeam == widget.myTeamName && !widget.myPlayers.contains(shot.playerName)) return false; - if (_selectedTeam == widget.oppTeamName && !widget.oppPlayers.contains(shot.playerName)) return false; - if (_selectedPlayer != 'Todos os Jogadores' && shot.playerName != _selectedPlayer) return false; - return true; - }).toList(); - - List teamOptions = ['Ambas as Equipas', widget.myTeamName, widget.oppTeamName]; - List playerOptions = ['Todos os Jogadores']; - Set activePlayers = widget.shots.map((s) => s.playerName).toSet(); - - if (_selectedTeam == 'Ambas as Equipas') { - playerOptions.addAll(activePlayers); - } else if (_selectedTeam == widget.myTeamName) { - playerOptions.addAll(activePlayers.where((p) => widget.myPlayers.contains(p))); - } else if (_selectedTeam == widget.oppTeamName) { - playerOptions.addAll(activePlayers.where((p) => widget.oppPlayers.contains(p))); - } - - if (!playerOptions.contains(_selectedPlayer)) { - _selectedPlayer = 'Todos os Jogadores'; - } - - return Dialog( - backgroundColor: yellowBackground, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - clipBehavior: Clip.antiAlias, - insetPadding: const EdgeInsets.all(10), - child: SizedBox( - height: dialogHeight, - width: dialogWidth, - child: Column( - children: [ - Container( - height: 50, - color: headerColor, - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( + // 👇 O AnimatedBuilder ouve o controller e redesenha o necessário de forma eficiente + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + if (_controller.isLoading) { + return Scaffold( + backgroundColor: AppTheme.placarDarkSurface, + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text("📊 Mapa de Calor", style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), - const Spacer(), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration(color: Colors.black12, borderRadius: BorderRadius.circular(6)), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _selectedTeam, - dropdownColor: headerColor, - icon: const Icon(Icons.arrow_drop_down, color: Colors.white), - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13), - items: teamOptions.map((String team) { - return DropdownMenuItem(value: team, child: Text(team)); - }).toList(), - onChanged: (String? newValue) { - setState(() { _selectedTeam = newValue!; }); - }, - ), - ), - ), - const SizedBox(width: 10), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration(color: Colors.black12, borderRadius: BorderRadius.circular(6)), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _selectedPlayer, - dropdownColor: headerColor, - icon: const Icon(Icons.arrow_drop_down, color: Colors.white), - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13), - items: playerOptions.map((String player) { - return DropdownMenuItem(value: player, child: Text(player)); - }).toList(), - onChanged: (String? newValue) { - setState(() { _selectedPlayer = newValue!; }); - }, - ), - ), - ), - const SizedBox(width: 15), - InkWell( - onTap: () => Navigator.pop(context), - child: Container( - padding: const EdgeInsets.all(6), - decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle), - child: Icon(Icons.close, color: headerColor, size: 18), - ), - ), + Text("PREPARANDO O PAVILHÃO", style: TextStyle(color: Colors.white24, fontSize: 45 * sf, fontWeight: FontWeight.bold, letterSpacing: 2)), + SizedBox(height: 35 * sf), + const CircularProgressIndicator(color: Colors.orangeAccent), ], ), ), - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - return Stack( - children: [ - CustomPaint( - size: Size(constraints.maxWidth, constraints.maxHeight), - painter: HeatmapCourtPainter(), - ), - ...filteredShots.map((shot) => Positioned( - left: (shot.relativeX * constraints.maxWidth) - 8, - top: (shot.relativeY * constraints.maxHeight) - 8, - child: CircleAvatar( - radius: 8, - backgroundColor: shot.isMake ? Colors.green : Colors.red, - child: Icon(shot.isMake ? Icons.check : Icons.close, size: 10, color: Colors.white) - ), - )), - ], - ); - }, + ); + } + + return Scaffold( + backgroundColor: AppTheme.placarBackground, + body: SafeArea( + top: false, bottom: false, + child: IgnorePointer( + ignoring: _controller.isSaving, + child: Stack( + children: [ + // --- ÁREA DO 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( + 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, + ), + ), + ), + ), + + // JOGADORES EM CAMPO + if (!_controller.isSelectingShotLocation && _controller.myCourt.length >= 5 && _controller.oppCourt.length >= 5) ...[ + Positioned(top: h * 0.25, left: w * 0.02, child: PlayerCourtCard(controller: _controller, playerId: _controller.myCourt[0], isOpponent: false, sf: sf)), + Positioned(top: h * 0.68, left: w * 0.02, child: PlayerCourtCard(controller: _controller, playerId: _controller.myCourt[1], isOpponent: false, sf: sf)), + Positioned(top: h * 0.45, left: w * 0.25, child: PlayerCourtCard(controller: _controller, playerId: _controller.myCourt[2], isOpponent: false, sf: sf)), + Positioned(top: h * 0.15, left: w * 0.20, child: PlayerCourtCard(controller: _controller, playerId: _controller.myCourt[3], isOpponent: false, sf: sf)), + Positioned(top: h * 0.80, left: w * 0.20, child: PlayerCourtCard(controller: _controller, playerId: _controller.myCourt[4], isOpponent: false, sf: sf)), + + Positioned(top: h * 0.25, right: w * 0.02, child: PlayerCourtCard(controller: _controller, playerId: _controller.oppCourt[0], isOpponent: true, sf: sf)), + Positioned(top: h * 0.68, right: w * 0.02, child: PlayerCourtCard(controller: _controller, playerId: _controller.oppCourt[1], isOpponent: true, sf: sf)), + Positioned(top: h * 0.45, right: w * 0.25, child: PlayerCourtCard(controller: _controller, playerId: _controller.oppCourt[2], isOpponent: true, sf: sf)), + Positioned(top: h * 0.15, right: w * 0.20, child: PlayerCourtCard(controller: _controller, playerId: _controller.oppCourt[3], isOpponent: true, sf: sf)), + Positioned(top: h * 0.80, right: w * 0.20, child: PlayerCourtCard(controller: _controller, playerId: _controller.oppCourt[4], isOpponent: true, sf: sf)), + ], + + // BOTÕES DE FALTA RÁPIDA + if (!_controller.isSelectingShotLocation) ...[ + _buildFloatingFoulBtn("FALTA +", AppTheme.actionPoints, "add_foul", Icons.sports, w * 0.39, 0.0, h * 0.31, sf), + _buildFloatingFoulBtn("FALTA -", AppTheme.actionMiss, "sub_foul", Icons.block, 0.0, w * 0.39, h * 0.31, sf), + ], + + // CRONÓMETRO CENTRAL / 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) + ), + ), + ), + ), + + Positioned(top: 0, left: 0, right: 0, child: Center(child: TopScoreboard(controller: _controller, sf: sf))), + + if (!_controller.isSelectingShotLocation) Positioned(bottom: -10 * sf, left: 0, right: 0, child: ActionButtonsPanel(controller: _controller, sf: sf)), + + if (_controller.isSelectingShotLocation) + Positioned( + top: h * 0.4, left: 0, right: 0, + child: Center( + child: Container( + padding: EdgeInsets.symmetric(horizontal: 35 * sf, vertical: 18 * sf), + decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(11 * sf), border: Border.all(color: Colors.white, width: 1.5 * sf)), + child: Text("TOQUE NO CAMPO PARA MARCAR O LOCAL", style: TextStyle(color: Colors.white, fontSize: 22 * sf, fontWeight: FontWeight.bold)), + ), + ), + ), + ], + ); + }, + ), + ), + + // BOTÕES LATERAIS (SAVE & HEATMAP) + Positioned( + top: 50 * sf, left: 12 * sf, + child: _buildCornerBtn( + heroTag: 'btn_save_exit', icon: Icons.save_alt, color: AppTheme.oppTeamRed, size: cornerBtnSize, isLoading: _controller.isSaving, + onTap: () async { + await _controller.saveGameStats(context); + if (context.mounted) Navigator.pop(context); + } + ), + ), + Positioned( + top: 50 * sf, right: 12 * sf, + child: _buildCornerBtn(heroTag: 'btn_heatmap', icon: Icons.local_fire_department, color: Colors.orange.shade800, size: cornerBtnSize, onTap: () => _showHeatmap(context)), + ), + + // CONTROLOS EQUIPA CASA (ESQUERDA) + Positioned( + bottom: 55 * sf, left: 12 * sf, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_controller.showMyBench) BenchPlayersList(controller: _controller, isOpponent: false, sf: sf), + SizedBox(height: 12 * sf), + _buildCornerBtn(heroTag: 'btn_sub_home', icon: Icons.swap_horiz, color: AppTheme.myTeamBlue, size: cornerBtnSize, onTap: () { _controller.showMyBench = !_controller.showMyBench; _controller.notifyListeners(); }), + SizedBox(height: 12 * sf), + _buildCornerBtn( + heroTag: 'btn_to_home', + icon: Icons.timer, + color: AppTheme.myTeamBlue, + size: cornerBtnSize, + onTap: _controller.myTimeoutsUsed >= 3 ? null : () => _controller.useTimeout(false) + ), + ], + ), + ), + + // CONTROLOS EQUIPA VISITANTE (DIREITA) + Positioned( + bottom: 55 * sf, right: 12 * sf, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_controller.showOppBench) BenchPlayersList(controller: _controller, isOpponent: true, sf: sf), + SizedBox(height: 12 * sf), + _buildCornerBtn(heroTag: 'btn_sub_away', icon: Icons.swap_horiz, color: AppTheme.oppTeamRed, size: cornerBtnSize, onTap: () { _controller.showOppBench = !_controller.showOppBench; _controller.notifyListeners(); }), + SizedBox(height: 12 * sf), + _buildCornerBtn( + heroTag: 'btn_to_away', + icon: Icons.timer, + color: AppTheme.oppTeamRed, + size: cornerBtnSize, + onTap: _controller.opponentTimeoutsUsed >= 3 ? null : () => _controller.useTimeout(true) + ), + ], + ), + ), + + // OVERLAY DE GRAVAÇÃO + if (_controller.isSaving) + Positioned.fill(child: Container(color: Colors.black.withOpacity(0.4), child: const Center(child: CircularProgressIndicator(color: Colors.white)))), + ], ), ), - ], - ), - ), + ), + ); + }, ); } -} - -class HeatmapCourtPainter extends CustomPainter { - @override - void paint(Canvas canvas, Size size) { - final double w = size.width; - final double h = size.height; - final double basketX = w / 2; - - final Paint whiteStroke = Paint()..color = Colors.white..style = PaintingStyle.stroke..strokeWidth = 2.0; - final Paint blackStroke = Paint()..color = Colors.black87..style = PaintingStyle.stroke..strokeWidth = 2.0; - - final double margin = w * 0.10; - final double length = h * 0.35; - final double larguraDoArco = (w / 2) - margin; - final double alturaDoArco = larguraDoArco * 0.30; - final double totalArcoHeight = alturaDoArco * 4; - - canvas.drawLine(Offset(margin, 0), Offset(margin, length), whiteStroke); - canvas.drawLine(Offset(w - margin, 0), Offset(w - margin, length), whiteStroke); - canvas.drawLine(Offset(0, length), Offset(margin, length), whiteStroke); - canvas.drawLine(Offset(w - margin, length), Offset(w, length), whiteStroke); - canvas.drawArc(Rect.fromCenter(center: Offset(basketX, length), width: larguraDoArco * 2, height: totalArcoHeight), 0, math.pi, false, whiteStroke); - - double sXL = basketX + (larguraDoArco * math.cos(math.pi * 0.75)); - double sYL = length + ((totalArcoHeight / 2) * math.sin(math.pi * 0.75)); - double sXR = basketX + (larguraDoArco * math.cos(math.pi * 0.25)); - double sYR = length + ((totalArcoHeight / 2) * math.sin(math.pi * 0.25)); - - canvas.drawLine(Offset(sXL, sYL), Offset(0, h * 0.85), whiteStroke); - canvas.drawLine(Offset(sXR, sYR), Offset(w, h * 0.85), whiteStroke); - - final double pW = w * 0.28; - final double pH = h * 0.38; - canvas.drawRect(Rect.fromLTWH(basketX - pW / 2, 0, pW, pH), blackStroke); - - final double ftR = pW / 2; - canvas.drawArc(Rect.fromCircle(center: Offset(basketX, pH), radius: ftR), 0, math.pi, false, blackStroke); - for (int i = 0; i < 10; i++) { - canvas.drawArc(Rect.fromCircle(center: Offset(basketX, pH), radius: ftR), math.pi + (i * 2 * (math.pi / 20)), math.pi / 20, false, blackStroke); - } - - canvas.drawLine(Offset(basketX - pW / 2, pH), Offset(sXL, sYL), blackStroke); - canvas.drawLine(Offset(basketX + pW / 2, pH), Offset(sXR, sYR), blackStroke); - - canvas.drawArc(Rect.fromCircle(center: Offset(basketX, h), radius: w * 0.12), math.pi, math.pi, false, blackStroke); - canvas.drawCircle(Offset(basketX, h * 0.12), w * 0.02, blackStroke); - canvas.drawLine(Offset(basketX - w * 0.08, h * 0.12 - 5), Offset(basketX + w * 0.08, h * 0.12 - 5), blackStroke); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } \ No newline at end of file diff --git a/lib/pages/home.dart b/lib/pages/home.dart index afd09ed..5d87b46 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -8,7 +8,6 @@ import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:playmaker/pages/status_page.dart'; import '../utils/size_extension.dart'; import 'settings_screen.dart'; -// 👇 Importa o ficheiro onde meteste o StatCard e o SportGrid // import 'home_widgets.dart'; class HomeScreen extends StatefulWidget { @@ -29,6 +28,37 @@ class _HomeScreenState extends State { int _teamDraws = 0; final _supabase = Supabase.instance.client; + + // 👇 NOVA VARIÁVEL PARA GUARDAR A FOTO + String? _avatarUrl; + + @override + void initState() { + super.initState(); + _loadUserAvatar(); // Vai buscar a foto logo quando a Home abre! + } + + // 👇 FUNÇÃO PARA LER A FOTO DA BASE DE DADOS + Future _loadUserAvatar() async { + final userId = _supabase.auth.currentUser?.id; + if (userId == null) return; + + try { + final data = await _supabase + .from('profiles') + .select('avatar_url') + .eq('id', userId) + .maybeSingle(); + + if (mounted && data != null && data['avatar_url'] != null) { + setState(() { + _avatarUrl = data['avatar_url']; + }); + } + } catch (e) { + print("Erro ao carregar avatar na Home: $e"); + } + } @override Widget build(BuildContext context) { @@ -45,14 +75,31 @@ class _HomeScreenState extends State { title: Text('PlayMaker', style: TextStyle(fontSize: 20 * context.sf)), backgroundColor: AppTheme.primaryRed, foregroundColor: Colors.white, - leading: IconButton( - icon: Icon(Icons.person, size: 24 * context.sf), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const SettingsScreen()), - ); - }, + + // 👇 AQUI ESTÁ A MÁGICA DA TUA FOTO NA APPBAR 👇 + leading: Padding( + padding: EdgeInsets.all(10.0 * context.sf), // Dá um espacinho para não colar aos bordos + child: InkWell( + borderRadius: BorderRadius.circular(100), + onTap: () async { + // O 'await' faz com que a Home espere que tu feches os settings... + await Navigator.push( + context, + MaterialPageRoute(builder: (context) => const SettingsScreen()), + ); + // ... e quando voltas, ele recarrega a foto logo! + _loadUserAvatar(); + }, + child: CircleAvatar( + backgroundColor: Colors.white.withOpacity(0.2), // Fundo suave caso não haja foto + backgroundImage: _avatarUrl != null && _avatarUrl!.isNotEmpty + ? NetworkImage(_avatarUrl!) + : null, + child: _avatarUrl == null || _avatarUrl!.isEmpty + ? Icon(Icons.person, color: Colors.white, size: 20 * context.sf) + : null, // Só mostra o ícone se não houver foto + ), + ), ), ), @@ -196,7 +243,6 @@ class _HomeScreenState extends State { Text('Histórico de Jogos', style: TextStyle(fontSize: 20 * context.sf, fontWeight: FontWeight.bold, color: textColor)), SizedBox(height: 16 * context.sf), - // 👇 AQUI ESTÁ O NOVO CARTÃO VAZIO PARA QUANDO NÃO HÁ EQUIPA 👇 _selectedTeamName == "Selecionar Equipa" ? Container( width: double.infinity, diff --git a/lib/pages/settings_screen.dart b/lib/pages/settings_screen.dart index 20265dd..94de52d 100644 --- a/lib/pages/settings_screen.dart +++ b/lib/pages/settings_screen.dart @@ -1,11 +1,13 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:playmaker/classe/theme.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; -import '../utils/size_extension.dart'; -import 'login.dart'; +import 'package:image_picker/image_picker.dart'; -// 👇 OBRIGATÓRIO IMPORTAR O MAIN.DART PARA LER A VARIÁVEL "themeNotifier" -import '../main.dart'; +import '../utils/size_extension.dart'; +import 'login.dart'; // 👇 Necessário para o redirecionamento do logout +import '../main.dart'; // 👇 OBRIGATÓRIO PARA LER A VARIÁVEL "themeNotifier" class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -16,16 +18,116 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { + // 👇 VARIÁVEIS DE ESTADO PARA FOTO DE PERFIL + File? _localImageFile; + String? _uploadedImageUrl; + bool _isUploadingImage = false; + + final supabase = Supabase.instance.client; + + @override + void initState() { + super.initState(); + _loadUserAvatar(); + } + + // 👇 LÊ A IMAGEM ATUAL DA BASE DE DADOS (Tabela 'profiles') + void _loadUserAvatar() async { + final userId = supabase.auth.currentUser?.id; + if (userId == null) return; + + try { + // ⚠️ NOTA: Ajusta 'profiles' e 'avatar_url' se os nomes na tua BD forem diferentes! + final data = await supabase + .from('profiles') + .select('avatar_url') + .eq('id', userId) + .maybeSingle(); // maybeSingle evita erro se o perfil ainda não existir + + if (mounted && data != null && data['avatar_url'] != null) { + setState(() { + _uploadedImageUrl = data['avatar_url']; + }); + } + } catch (e) { + print("Erro ao carregar avatar: $e"); + } + } + + // ========================================================================= + // 👇 A MÁGICA DE ESCOLHER E FAZER UPLOAD DA FOTO 👇 + // ========================================================================= + Future _handleImageChange() async { + final ImagePicker picker = ImagePicker(); + + // 1. ABRIR GALERIA + final XFile? pickedFile = await picker.pickImage(source: ImageSource.gallery); + if (pickedFile == null || !mounted) return; + + try { + // 2. MOSTRAR IMAGEM LOCAL E ATIVAR LOADING + setState(() { + _localImageFile = File(pickedFile.path); + _isUploadingImage = true; + }); + + final userId = supabase.auth.currentUser?.id; + if (userId == null) throw Exception("Utilizador não autenticado."); + + final String storagePath = '$userId/profile_picture.png'; + + // 3. FAZER UPLOAD (Método direto e seguro!) + await supabase.storage.from('avatars').upload( + storagePath, + _localImageFile!, // Envia o ficheiro File diretamente! + fileOptions: const FileOptions(cacheControl: '3600', upsert: true) + ); + + // 4. OBTER URL PÚBLICO + final String publicUrl = supabase.storage.from('avatars').getPublicUrl(storagePath); + + // 5. ATUALIZAR NA BASE DE DADOS + // ⚠️ NOTA: Garante que a tabela 'profiles' existe e tem o teu user_id + await supabase + .from('profiles') + .upsert({ + 'id': userId, // Garante que atualiza o perfil certo ou cria um novo + 'avatar_url': publicUrl + }); + + // 6. SUCESSO! + if (mounted) { + setState(() { + _uploadedImageUrl = publicUrl; + _isUploadingImage = false; + _localImageFile = null; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Foto atualizada!"), backgroundColor: Colors.green) + ); + } + + } catch (e) { + if (mounted) { + setState(() { + _isUploadingImage = false; + _localImageFile = null; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Erro: $e"), backgroundColor: AppTheme.primaryRed) + ); + } + } + } + @override Widget build(BuildContext context) { - // 👇 CORES DINÂMICAS (A MÁGICA DO MODO ESCURO) final Color primaryRed = AppTheme.primaryRed; final Color bgColor = Theme.of(context).scaffoldBackgroundColor; final Color cardColor = Theme.of(context).cardTheme.color ?? Theme.of(context).colorScheme.surface; final Color textColor = Theme.of(context).colorScheme.onSurface; final Color textLightColor = textColor.withOpacity(0.6); - // 👇 SABER SE A APP ESTÁ ESCURA OU CLARA NESTE EXATO MOMENTO bool isDark = Theme.of(context).brightness == Brightness.dark; return Scaffold( @@ -37,10 +139,7 @@ class _SettingsScreenState extends State { centerTitle: true, title: Text( "Perfil e Definições", - style: TextStyle( - fontSize: 18 * context.sf, - fontWeight: FontWeight.w600, - ), + style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.w600), ), leading: IconButton( icon: const Icon(Icons.arrow_back), @@ -62,20 +161,13 @@ class _SettingsScreenState extends State { borderRadius: BorderRadius.circular(16 * context.sf), border: Border.all(color: Colors.grey.withOpacity(0.1)), boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.04), - blurRadius: 10, - offset: const Offset(0, 4), - ), + BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 10, offset: const Offset(0, 4)), ], ), child: Row( children: [ - CircleAvatar( - radius: 32 * context.sf, - backgroundColor: primaryRed.withOpacity(0.1), - child: Icon(Icons.person, color: primaryRed, size: 32 * context.sf), - ), + // 👇 IMAGEM TAPPABLE AQUI 👇 + _buildTappableProfileAvatar(context, primaryRed), SizedBox(width: 16 * context.sf), Expanded( child: Column( @@ -83,19 +175,12 @@ class _SettingsScreenState extends State { children: [ Text( "Treinador", - style: TextStyle( - fontSize: 18 * context.sf, - fontWeight: FontWeight.bold, - color: textColor, - ), + style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold, color: textColor), ), SizedBox(height: 4 * context.sf), Text( - Supabase.instance.client.auth.currentUser?.email ?? "sem@email.com", - style: TextStyle( - color: textLightColor, - fontSize: 14 * context.sf, - ), + supabase.auth.currentUser?.email ?? "sem@email.com", + style: TextStyle(color: textLightColor, fontSize: 14 * context.sf), ), ], ), @@ -113,11 +198,7 @@ class _SettingsScreenState extends State { padding: EdgeInsets.only(left: 4 * context.sf, bottom: 12 * context.sf), child: Text( "Definições", - style: TextStyle( - color: textLightColor, - fontSize: 14 * context.sf, - fontWeight: FontWeight.bold, - ), + style: TextStyle(color: textLightColor, fontSize: 14 * context.sf, fontWeight: FontWeight.bold), ), ), Container( @@ -126,11 +207,7 @@ class _SettingsScreenState extends State { borderRadius: BorderRadius.circular(16 * context.sf), border: Border.all(color: Colors.grey.withOpacity(0.1)), boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.04), - blurRadius: 10, - offset: const Offset(0, 4), - ), + BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 10, offset: const Offset(0, 4)), ], ), child: ListTile( @@ -148,7 +225,6 @@ class _SettingsScreenState extends State { value: isDark, activeColor: primaryRed, onChanged: (bool value) { - // 👇 CHAMA A VARIÁVEL DO MAIN.DART E ATUALIZA A APP TODA themeNotifier.value = value ? ThemeMode.dark : ThemeMode.light; }, ), @@ -164,11 +240,7 @@ class _SettingsScreenState extends State { padding: EdgeInsets.only(left: 4 * context.sf, bottom: 12 * context.sf), child: Text( "Conta", - style: TextStyle( - color: textLightColor, - fontSize: 14 * context.sf, - fontWeight: FontWeight.bold, - ), + style: TextStyle(color: textLightColor, fontSize: 14 * context.sf, fontWeight: FontWeight.bold), ), ), Container( @@ -177,11 +249,7 @@ class _SettingsScreenState extends State { borderRadius: BorderRadius.circular(16 * context.sf), border: Border.all(color: Colors.grey.withOpacity(0.1)), boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.04), - blurRadius: 10, - offset: const Offset(0, 4), - ), + BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 10, offset: const Offset(0, 4)), ], ), child: ListTile( @@ -189,13 +257,9 @@ class _SettingsScreenState extends State { leading: Icon(Icons.logout_outlined, color: primaryRed, size: 26 * context.sf), title: Text( "Terminar Sessão", - style: TextStyle( - color: primaryRed, - fontWeight: FontWeight.bold, - fontSize: 15 * context.sf, - ), + style: TextStyle(color: primaryRed, fontWeight: FontWeight.bold, fontSize: 15 * context.sf), ), - onTap: () => _confirmLogout(context), // 👇 CHAMA O LOGOUT REAL + onTap: () => _confirmLogout(context), ), ), @@ -207,10 +271,7 @@ class _SettingsScreenState extends State { Center( child: Text( "PlayMaker v1.0.0", - style: TextStyle( - color: textLightColor.withOpacity(0.7), - fontSize: 13 * context.sf, - ), + style: TextStyle(color: textLightColor.withOpacity(0.7), fontSize: 13 * context.sf), ), ), SizedBox(height: 20 * context.sf), @@ -220,28 +281,83 @@ class _SettingsScreenState extends State { ); } - // 👇 FUNÇÃO PARA FAZER LOGOUT + // 👇 O WIDGET DA FOTO DE PERFIL (Protegido com GestureDetector) + Widget _buildTappableProfileAvatar(BuildContext context, Color primaryRed) { + return GestureDetector( + onTap: () { + print("CLIQUEI NA FOTO! A abrir galeria..."); // 👇 Vê na consola se isto aparece + _handleImageChange(); + }, + child: Stack( + alignment: Alignment.center, + children: [ + CircleAvatar( + radius: 36 * context.sf, + backgroundColor: primaryRed.withOpacity(0.1), + backgroundImage: _isUploadingImage && _localImageFile != null + ? FileImage(_localImageFile!) + : (_uploadedImageUrl != null && _uploadedImageUrl!.isNotEmpty + ? NetworkImage(_uploadedImageUrl!) + : null), + child: (_uploadedImageUrl == null && !(_isUploadingImage && _localImageFile != null)) + ? Icon(Icons.person, color: primaryRed, size: 36 * context.sf) + : null, + ), + + // ÍCONE DE LÁPIS + Positioned( + bottom: 0, + right: 0, + child: Container( + padding: EdgeInsets.all(6 * context.sf), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + shape: BoxShape.circle, + border: Border.all(color: Colors.grey.withOpacity(0.2)), + ), + child: Icon(Icons.edit_outlined, color: primaryRed, size: 16 * context.sf), + ), + ), + + // LOADING OVERLAY + if (_isUploadingImage) + Positioned.fill( + child: Container( + decoration: BoxDecoration(color: Colors.black.withOpacity(0.4), shape: BoxShape.circle), + child: const Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 3), + ), + ), + ), + ], + ), + ); + } + + // 👇 FUNÇÃO DE LOGOUT void _confirmLogout(BuildContext context) { showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: Theme.of(context).colorScheme.surface, - title: Text("Terminar Sessão", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16 * context.sf)), + title: Text("Terminar Sessão", style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold)), content: Text("Tens a certeza que queres sair da conta?", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("Cancelar", style: TextStyle(color: Colors.grey))), - TextButton( + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryRed, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: () async { await Supabase.instance.client.auth.signOut(); if (ctx.mounted) { - // Mata a navegação toda para trás e manda para o Login Navigator.of(ctx).pushAndRemoveUntil( MaterialPageRoute(builder: (context) => const LoginPage()), (Route route) => false, ); } }, - child: Text("Sair", style: TextStyle(color: AppTheme.primaryRed, fontWeight: FontWeight.bold)) + child: const Text("Sair", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)) ), ], ), diff --git a/lib/pages/teamPage.dart b/lib/pages/teamPage.dart index 23b3484..881dc2c 100644 --- a/lib/pages/teamPage.dart +++ b/lib/pages/teamPage.dart @@ -1,6 +1,9 @@ +import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:image_cropper/image_cropper.dart'; import 'package:playmaker/screens/team_stats_page.dart'; -import 'package:playmaker/classe/theme.dart'; // 👇 IMPORT DO TEMA +import 'package:playmaker/classe/theme.dart'; import '../controllers/team_controller.dart'; import '../models/team_model.dart'; import '../utils/size_extension.dart'; @@ -162,16 +165,17 @@ class _TeamsPageState extends State { hintStyle: TextStyle(fontSize: 16 * context.sf, color: Colors.grey), prefixIcon: Icon(Icons.search, color: AppTheme.primaryRed, size: 22 * context.sf), filled: true, - fillColor: Theme.of(context).colorScheme.surface, // 👇 Adapta-se ao Dark Mode + fillColor: Theme.of(context).colorScheme.surface, border: OutlineInputBorder(borderRadius: BorderRadius.circular(15 * context.sf), borderSide: BorderSide.none), ), ), ); } + // 👇 AGORA USA FUTUREBUILDER E É MUITO MAIS RÁPIDO 👇 Widget _buildTeamsList() { - return StreamBuilder>>( - stream: controller.teamsStream, + return FutureBuilder>>( + future: controller.getTeamsWithStats(), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) return Center(child: CircularProgressIndicator(color: AppTheme.primaryRed)); if (!snapshot.hasData || snapshot.data!.isEmpty) return Center(child: Text("Nenhuma equipa encontrada.", style: TextStyle(fontSize: 16 * context.sf, color: Theme.of(context).colorScheme.onSurface))); @@ -190,28 +194,45 @@ class _TeamsPageState extends State { else return (b['created_at'] ?? '').toString().compareTo((a['created_at'] ?? '').toString()); }); - return ListView.builder( - padding: EdgeInsets.symmetric(horizontal: 16 * context.sf), - itemCount: data.length, - itemBuilder: (context, index) { - final team = Team.fromMap(data[index]); - return GestureDetector( - onTap: () => Navigator.push(context, MaterialPageRoute(builder: (context) => TeamStatsPage(team: team))), - child: TeamCard( - team: team, - controller: controller, - onFavoriteTap: () => controller.toggleFavorite(team.id, team.isFavorite), - sf: context.sf, - ), - ); - }, + return RefreshIndicator( + color: AppTheme.primaryRed, + onRefresh: () async => setState(() {}), // Puxa para baixo para recarregar + child: ListView.builder( + padding: EdgeInsets.symmetric(horizontal: 16 * context.sf), + itemCount: data.length, + itemBuilder: (context, index) { + final team = Team.fromMap(data[index]); + return GestureDetector( + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (context) => TeamStatsPage(team: team))).then((_) => setState(() {})), + child: TeamCard( + team: team, + controller: controller, + onFavoriteTap: () async { + await controller.toggleFavorite(team.id, team.isFavorite); + setState(() {}); // Atualiza a estrela na hora + }, + onDelete: () => setState(() {}), // Atualiza a lista quando apaga + sf: context.sf, + ), + ); + }, + ), ); }, ); } void _showCreateDialog(BuildContext context) { - showDialog(context: context, builder: (context) => CreateTeamDialog(sf: context.sf, onConfirm: (name, season, imageUrl) => controller.createTeam(name, season, imageUrl))); + showDialog( + context: context, + builder: (context) => CreateTeamDialog( + sf: context.sf, + onConfirm: (name, season, imageFile) async { + await controller.createTeam(name, season, imageFile); + setState(() {}); // 👇 Atualiza a lista quando acaba de criar a equipa! + } + ), + ); } } @@ -220,6 +241,7 @@ class TeamCard extends StatelessWidget { final Team team; final TeamController controller; final VoidCallback onFavoriteTap; + final VoidCallback onDelete; // 👇 Avisa o pai quando é apagado final double sf; const TeamCard({ @@ -227,6 +249,7 @@ class TeamCard extends StatelessWidget { required this.team, required this.controller, required this.onFavoriteTap, + required this.onDelete, required this.sf, }); @@ -259,7 +282,7 @@ class TeamCard extends StatelessWidget { : null, child: (team.imageUrl.isEmpty || !team.imageUrl.startsWith('http')) ? Text( - team.imageUrl.isEmpty ? "🏀" : team.imageUrl, + team.imageUrl.isEmpty ? "🏀" : team.imageUrl, style: TextStyle(fontSize: 24 * sf), ) : null, @@ -272,9 +295,7 @@ class TeamCard extends StatelessWidget { team.isFavorite ? Icons.star : Icons.star_border, color: team.isFavorite ? AppTheme.warningAmber : Theme.of(context).colorScheme.onSurface.withOpacity(0.2), size: 28 * sf, - shadows: [ - Shadow(color: Colors.black.withOpacity(team.isFavorite ? 0.3 : 0.1), blurRadius: 4 * sf), - ], + shadows: [Shadow(color: Colors.black.withOpacity(team.isFavorite ? 0.3 : 0.1), blurRadius: 4 * sf)], ), onPressed: onFavoriteTap, ), @@ -292,21 +313,17 @@ class TeamCard extends StatelessWidget { children: [ Icon(Icons.groups_outlined, size: 16 * sf, color: Colors.grey), SizedBox(width: 4 * sf), - StreamBuilder( - stream: controller.getPlayerCountStream(team.id), - initialData: 0, - builder: (context, snapshot) { - final count = snapshot.data ?? 0; - return Text( - "$count Jogs.", - style: TextStyle( - color: count > 0 ? AppTheme.successGreen : AppTheme.warningAmber, // 👇 Usando cores do tema - fontWeight: FontWeight.bold, - fontSize: 13 * sf, - ), - ); - }, + + // 👇 ESTATÍSTICA MUITO MAIS LEVE. LÊ O VALOR DIRETAMENTE! 👇 + Text( + "${team.playerCount} Jogs.", + style: TextStyle( + color: team.playerCount > 0 ? AppTheme.successGreen : AppTheme.warningAmber, + fontWeight: FontWeight.bold, + fontSize: 13 * sf, + ), ), + SizedBox(width: 8 * sf), Expanded( child: Text("| ${team.season}", style: TextStyle(color: Colors.grey, fontSize: 13 * sf), overflow: TextOverflow.ellipsis), @@ -320,7 +337,7 @@ class TeamCard extends StatelessWidget { IconButton( tooltip: 'Ver Estatísticas', icon: Icon(Icons.bar_chart_rounded, color: Colors.blue, size: 24 * sf), - onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (context) => TeamStatsPage(team: team))), + onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (context) => TeamStatsPage(team: team))).then((_) => onDelete()), // Atualiza se algo mudou ), IconButton( tooltip: 'Eliminar Equipa', @@ -334,23 +351,30 @@ class TeamCard extends StatelessWidget { ); } - void _confirmDelete(BuildContext context, double sf, Color cardColor, Color textColor) { +void _confirmDelete(BuildContext context, double sf, Color cardColor, Color textColor) { showDialog( context: context, - builder: (context) => AlertDialog( + builder: (ctx) => AlertDialog( backgroundColor: cardColor, surfaceTintColor: Colors.transparent, title: Text('Eliminar Equipa?', style: TextStyle(fontSize: 18 * sf, fontWeight: FontWeight.bold, color: textColor)), content: Text('Tens a certeza que queres eliminar "${team.name}"?', style: TextStyle(fontSize: 14 * sf, color: textColor)), actions: [ TextButton( - onPressed: () => Navigator.pop(context), + onPressed: () => Navigator.pop(ctx), child: Text('Cancelar', style: TextStyle(fontSize: 14 * sf, color: Colors.grey)), ), TextButton( onPressed: () { - controller.deleteTeam(team.id); - Navigator.pop(context); + // ⚡ 1. FECHA LOGO O POP-UP! + Navigator.pop(ctx); + // ⚡ 2. AVISA O PAI PARA ESCONDER A EQUIPA DO ECRÃ NA HORA! + onDelete(); + + // 3. APAGA NO FUNDO (Sem o utilizador ficar à espera) + controller.deleteTeam(team.id).catchError((e) { + if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Erro ao eliminar: $e'), backgroundColor: Colors.red)); + }); }, child: Text('Eliminar', style: TextStyle(color: AppTheme.primaryRed, fontSize: 14 * sf)), ), @@ -360,9 +384,9 @@ class TeamCard extends StatelessWidget { } } -// --- DIALOG DE CRIAÇÃO --- +// --- DIALOG DE CRIAÇÃO (COM CROPPER E ESCUDO) --- class CreateTeamDialog extends StatefulWidget { - final Function(String name, String season, String imageUrl) onConfirm; + final Function(String name, String season, File? imageFile) onConfirm; final double sf; const CreateTeamDialog({super.key, required this.onConfirm, required this.sf}); @@ -373,8 +397,48 @@ class CreateTeamDialog extends StatefulWidget { class _CreateTeamDialogState extends State { final TextEditingController _nameController = TextEditingController(); - final TextEditingController _imageController = TextEditingController(); String _selectedSeason = '2024/25'; + + File? _selectedImage; + bool _isLoading = false; + bool _isPickerActive = false; // 👇 ESCUDO ANTI-DUPLO-CLIQUE + + Future _pickImage() async { + if (_isPickerActive) return; + setState(() => _isPickerActive = true); + + try { + final ImagePicker picker = ImagePicker(); + final XFile? pickedFile = await picker.pickImage(source: ImageSource.gallery); + + if (pickedFile != null) { + // 👇 USA O CROPPER QUE CONFIGURASTE PARA AS CARAS + CroppedFile? croppedFile = await ImageCropper().cropImage( + sourcePath: pickedFile.path, + aspectRatio: const CropAspectRatio(ratioX: 1, ratioY: 1), + uiSettings: [ + AndroidUiSettings( + toolbarTitle: 'Recortar Logo', + toolbarColor: AppTheme.primaryRed, + toolbarWidgetColor: Colors.white, + initAspectRatio: CropAspectRatioPreset.square, + lockAspectRatio: true, + hideBottomControls: true, + ), + IOSUiSettings(title: 'Recortar Logo', aspectRatioLockEnabled: true, resetButtonHidden: true), + ], + ); + + if (croppedFile != null && mounted) { + setState(() { + _selectedImage = File(croppedFile.path); + }); + } + } + } finally { + if (mounted) setState(() => _isPickerActive = false); + } + } @override Widget build(BuildContext context) { @@ -386,6 +450,34 @@ class _CreateTeamDialogState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ + GestureDetector( + onTap: _pickImage, + child: Stack( + children: [ + CircleAvatar( + radius: 40 * widget.sf, + backgroundColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.05), + backgroundImage: _selectedImage != null ? FileImage(_selectedImage!) : null, + child: _selectedImage == null + ? Icon(Icons.add_photo_alternate_outlined, size: 30 * widget.sf, color: Colors.grey) + : null, + ), + if (_selectedImage == null) + Positioned( + bottom: 0, right: 0, + child: Container( + padding: EdgeInsets.all(4 * widget.sf), + decoration: const BoxDecoration(color: AppTheme.primaryRed, shape: BoxShape.circle), + child: Icon(Icons.add, color: Colors.white, size: 16 * widget.sf), + ), + ), + ], + ), + ), + SizedBox(height: 10 * widget.sf), + Text("Logótipo (Opcional)", style: TextStyle(fontSize: 12 * widget.sf, color: Colors.grey)), + SizedBox(height: 20 * widget.sf), + TextField(controller: _nameController, style: TextStyle(fontSize: 14 * widget.sf, color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration(labelText: 'Nome da Equipa', labelStyle: TextStyle(fontSize: 14 * widget.sf)), textCapitalization: TextCapitalization.words), SizedBox(height: 15 * widget.sf), DropdownButtonFormField( @@ -395,8 +487,6 @@ class _CreateTeamDialogState extends State { items: ['2023/24', '2024/25', '2025/26'].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(), onChanged: (val) => setState(() => _selectedSeason = val!), ), - SizedBox(height: 15 * widget.sf), - TextField(controller: _imageController, style: TextStyle(fontSize: 14 * widget.sf, color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration(labelText: 'URL Imagem ou Emoji', labelStyle: TextStyle(fontSize: 14 * widget.sf), hintText: 'Ex: 🏀 ou https://...', hintStyle: TextStyle(fontSize: 14 * widget.sf, color: Colors.grey))), ], ), ), @@ -404,8 +494,16 @@ class _CreateTeamDialogState extends State { TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancelar', style: TextStyle(fontSize: 14 * widget.sf, color: Colors.grey))), ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryRed, padding: EdgeInsets.symmetric(horizontal: 16 * widget.sf, vertical: 10 * widget.sf)), - onPressed: () { if (_nameController.text.trim().isNotEmpty) { widget.onConfirm(_nameController.text.trim(), _selectedSeason, _imageController.text.trim()); Navigator.pop(context); } }, - child: Text('Criar', style: TextStyle(color: Colors.white, fontSize: 14 * widget.sf)), + onPressed: _isLoading ? null : () async { + if (_nameController.text.trim().isNotEmpty) { + setState(() => _isLoading = true); + await widget.onConfirm(_nameController.text.trim(), _selectedSeason, _selectedImage); + if (context.mounted) Navigator.pop(context); + } + }, + child: _isLoading + ? SizedBox(width: 16 * widget.sf, height: 16 * widget.sf, child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) + : Text('Criar', style: TextStyle(color: Colors.white, fontSize: 14 * widget.sf)), ), ], ); diff --git a/lib/screens/team_stats_page.dart b/lib/screens/team_stats_page.dart index b433944..d6bc2dd 100644 --- a/lib/screens/team_stats_page.dart +++ b/lib/screens/team_stats_page.dart @@ -1,23 +1,38 @@ -import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:image_cropper/image_cropper.dart'; +import 'package:shimmer/shimmer.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; -import 'package:playmaker/classe/theme.dart'; // 👇 IMPORT DO TEMA! +import 'package:playmaker/classe/theme.dart'; import '../models/team_model.dart'; import '../models/person_model.dart'; -import '../utils/size_extension.dart'; // 👇 SUPERPODER SF +import '../utils/size_extension.dart'; -// --- CABEÇALHO --- +// ========================================== +// 1. CABEÇALHO (AGORA COM CACHE DE IMAGEM) +// ========================================== class StatsHeader extends StatelessWidget { final Team team; + final String? currentImageUrl; + final VoidCallback onEditPhoto; + final bool isUploading; - const StatsHeader({super.key, required this.team}); + const StatsHeader({ + super.key, + required this.team, + required this.currentImageUrl, + required this.onEditPhoto, + required this.isUploading, + }); @override Widget build(BuildContext context) { return Container( padding: EdgeInsets.only(top: 50 * context.sf, left: 20 * context.sf, right: 20 * context.sf, bottom: 20 * context.sf), decoration: BoxDecoration( - color: AppTheme.primaryRed, // 👇 Usando a cor oficial + color: AppTheme.primaryRed, borderRadius: BorderRadius.only( bottomLeft: Radius.circular(30 * context.sf), bottomRight: Radius.circular(30 * context.sf) @@ -26,23 +41,42 @@ class StatsHeader extends StatelessWidget { child: Row( children: [ IconButton( - icon: Icon(Icons.arrow_back, color: Colors.white, size: 24 * context.sf), - onPressed: () => Navigator.pop(context), + icon: Icon(Icons.arrow_back, color: Colors.white, size: 24 * context.sf), + onPressed: () => Navigator.pop(context) ), SizedBox(width: 10 * context.sf), - CircleAvatar( - radius: 24 * context.sf, - backgroundColor: Colors.white24, - backgroundImage: (team.imageUrl.isNotEmpty && team.imageUrl.startsWith('http')) - ? NetworkImage(team.imageUrl) - : null, - child: (team.imageUrl.isEmpty || !team.imageUrl.startsWith('http')) - ? Text( - team.imageUrl.isEmpty ? "🛡️" : team.imageUrl, - style: TextStyle(fontSize: 20 * context.sf), + GestureDetector( + onTap: onEditPhoto, + child: Stack( + alignment: Alignment.center, + children: [ + CircleAvatar( + radius: 28 * context.sf, + backgroundColor: Colors.white24, + backgroundImage: (currentImageUrl != null && currentImageUrl!.isNotEmpty && currentImageUrl!.startsWith('http')) + ? CachedNetworkImageProvider(currentImageUrl!) + : null, + child: (currentImageUrl == null || currentImageUrl!.isEmpty || !currentImageUrl!.startsWith('http')) + ? Text((currentImageUrl != null && currentImageUrl!.isNotEmpty) ? currentImageUrl! : "🛡️", style: TextStyle(fontSize: 24 * context.sf)) + : null, + ), + Positioned( + bottom: 0, right: 0, + child: Container( + padding: EdgeInsets.all(4 * context.sf), + decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle), + child: Icon(Icons.edit, color: AppTheme.primaryRed, size: 12 * context.sf), + ), + ), + if (isUploading) + Container( + width: 56 * context.sf, height: 56 * context.sf, + decoration: const BoxDecoration(color: Colors.black45, shape: BoxShape.circle), + child: const Padding(padding: EdgeInsets.all(12.0), child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)), ) - : null, + ], + ), ), SizedBox(width: 15 * context.sf), @@ -50,15 +84,8 @@ class StatsHeader extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - team.name, - style: TextStyle(color: Colors.white, fontSize: 20 * context.sf, fontWeight: FontWeight.bold), - overflow: TextOverflow.ellipsis - ), - Text( - team.season, - style: TextStyle(color: Colors.white70, fontSize: 14 * context.sf) - ), + Text(team.name, style: TextStyle(color: Colors.white, fontSize: 20 * context.sf, fontWeight: FontWeight.bold), overflow: TextOverflow.ellipsis), + Text(team.season, style: TextStyle(color: Colors.white70, fontSize: 14 * context.sf)), ], ), ), @@ -71,41 +98,28 @@ class StatsHeader extends StatelessWidget { // --- CARD DE RESUMO --- class StatsSummaryCard extends StatelessWidget { final int total; - const StatsSummaryCard({super.key, required this.total}); @override Widget build(BuildContext context) { - // 👇 Adapta-se ao Modo Claro/Escuro final Color bgColor = Theme.of(context).brightness == Brightness.dark ? const Color(0xFF1E1E1E) : Colors.white; - return Card( elevation: 4, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20 * context.sf)), child: Container( padding: EdgeInsets.all(20 * context.sf), - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(20 * context.sf), - border: Border.all(color: Colors.grey.withOpacity(0.15)), - ), + decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(20 * context.sf), border: Border.all(color: Colors.grey.withOpacity(0.15))), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ - Icon(Icons.groups, color: AppTheme.primaryRed, size: 28 * context.sf), // 👇 Cor do tema + Icon(Icons.groups, color: AppTheme.primaryRed, size: 28 * context.sf), SizedBox(width: 10 * context.sf), - Text( - "Total de Membros", - style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 16 * context.sf, fontWeight: FontWeight.w600) - ), + Text("Total de Membros", style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 16 * context.sf, fontWeight: FontWeight.w600)), ], ), - Text( - "$total", - style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 28 * context.sf, fontWeight: FontWeight.bold) - ), + Text("$total", style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 28 * context.sf, fontWeight: FontWeight.bold)), ], ), ), @@ -116,7 +130,6 @@ class StatsSummaryCard extends StatelessWidget { // --- TÍTULO DE SECÇÃO --- class StatsSectionTitle extends StatelessWidget { final String title; - const StatsSectionTitle({super.key, required this.title}); @override @@ -124,79 +137,107 @@ class StatsSectionTitle extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface) - ), + Text(title, style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface)), Divider(color: Colors.grey.withOpacity(0.2)), ], ); } } -// --- CARD DA PESSOA (JOGADOR/TREINADOR) --- +// --- CARD DA PESSOA (FOTO + NÚMERO + NOME E CACHE) --- class PersonCard extends StatelessWidget { final Person person; final bool isCoach; final VoidCallback onEdit; final VoidCallback onDelete; - const PersonCard({ - super.key, - required this.person, - required this.isCoach, - required this.onEdit, - required this.onDelete, - }); + const PersonCard({super.key, required this.person, required this.isCoach, required this.onEdit, required this.onDelete}); @override Widget build(BuildContext context) { - // 👇 Adapta as cores do Card ao Modo Escuro e ao Tema final Color defaultBg = Theme.of(context).brightness == Brightness.dark ? const Color(0xFF1E1E1E) : Colors.white; final Color coachBg = Theme.of(context).brightness == Brightness.dark ? AppTheme.warningAmber.withOpacity(0.1) : const Color(0xFFFFF9C4); + final String? pImage = person.imageUrl; return Card( margin: EdgeInsets.only(top: 12 * context.sf), - elevation: 2, + elevation: 2, color: isCoach ? coachBg : defaultBg, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * context.sf)), - child: ListTile( - contentPadding: EdgeInsets.symmetric(horizontal: 16 * context.sf, vertical: 4 * context.sf), - leading: isCoach - ? CircleAvatar( - radius: 22 * context.sf, - backgroundColor: AppTheme.warningAmber, // 👇 Cor do tema - child: Icon(Icons.person, color: Colors.white, size: 24 * context.sf) - ) - : Container( - width: 45 * context.sf, - height: 45 * context.sf, - alignment: Alignment.center, - decoration: BoxDecoration( - color: AppTheme.primaryRed.withOpacity(0.1), // 👇 Cor do tema - borderRadius: BorderRadius.circular(10 * context.sf) - ), - child: Text( - person.number ?? "J", - style: TextStyle(color: AppTheme.primaryRed, fontWeight: FontWeight.bold, fontSize: 16 * context.sf) - ), - ), - title: Text( - person.name, - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16 * context.sf, color: Theme.of(context).colorScheme.onSurface) - ), - - trailing: Row( - mainAxisSize: MainAxisSize.min, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16 * context.sf, vertical: 12 * context.sf), + child: Row( children: [ - IconButton( - icon: Icon(Icons.edit_outlined, color: Colors.blue, size: 22 * context.sf), - onPressed: onEdit, + CircleAvatar( + radius: 22 * context.sf, + backgroundColor: isCoach ? AppTheme.warningAmber : AppTheme.primaryRed.withOpacity(0.1), + backgroundImage: (pImage != null && pImage.isNotEmpty) ? CachedNetworkImageProvider(pImage) : null, + child: (pImage == null || pImage.isEmpty) ? Icon(Icons.person, color: isCoach ? Colors.white : AppTheme.primaryRed, size: 24 * context.sf) : null, ), - IconButton( - icon: Icon(Icons.delete_outline, color: AppTheme.primaryRed, size: 22 * context.sf), // 👇 Cor do tema - onPressed: onDelete, + SizedBox(width: 12 * context.sf), + Expanded( + child: Row( + children: [ + if (!isCoach && person.number != null && person.number!.isNotEmpty) ...[ + Container( + padding: EdgeInsets.symmetric(horizontal: 8 * context.sf, vertical: 4 * context.sf), + decoration: BoxDecoration(color: AppTheme.primaryRed.withOpacity(0.1), borderRadius: BorderRadius.circular(6 * context.sf)), + child: Text(person.number!, style: TextStyle(color: AppTheme.primaryRed, fontWeight: FontWeight.bold, fontSize: 14 * context.sf)), + ), + SizedBox(width: 10 * context.sf), + ], + Expanded( + child: Text(person.name, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16 * context.sf, color: Theme.of(context).colorScheme.onSurface), overflow: TextOverflow.ellipsis) + ), + ], + ), ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton(icon: Icon(Icons.edit_outlined, color: Colors.blue, size: 22 * context.sf), onPressed: onEdit, padding: EdgeInsets.zero, constraints: const BoxConstraints()), + SizedBox(width: 16 * context.sf), + IconButton(icon: Icon(Icons.delete_outline, color: AppTheme.primaryRed, size: 22 * context.sf), onPressed: onDelete, padding: EdgeInsets.zero, constraints: const BoxConstraints()), + ], + ), + ], + ), + ), + ); + } +} + +// ========================================== +// WIDGET NOVO: SKELETON LOADING (SHIMMER) +// ========================================== +class SkeletonLoadingStats extends StatelessWidget { + const SkeletonLoadingStats({super.key}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final baseColor = isDark ? Colors.grey[800]! : Colors.grey[300]!; + final highlightColor = isDark ? Colors.grey[700]! : Colors.grey[100]!; + + return Shimmer.fromColors( + baseColor: baseColor, + highlightColor: highlightColor, + child: SingleChildScrollView( + padding: EdgeInsets.all(16.0 * context.sf), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container(height: 80 * context.sf, width: double.infinity, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20 * context.sf))), + SizedBox(height: 30 * context.sf), + Container(height: 20 * context.sf, width: 150 * context.sf, color: Colors.white), + SizedBox(height: 10 * context.sf), + for (int i = 0; i < 3; i++) ...[ + Container( + height: 60 * context.sf, width: double.infinity, + margin: EdgeInsets.only(top: 12 * context.sf), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(15 * context.sf)), + ), + ] ], ), ), @@ -207,10 +248,8 @@ class PersonCard extends StatelessWidget { // ========================================== // 2. PÁGINA PRINCIPAL // ========================================== - class TeamStatsPage extends StatefulWidget { final Team team; - const TeamStatsPage({super.key, required this.team}); @override @@ -219,31 +258,79 @@ class TeamStatsPage extends StatefulWidget { class _TeamStatsPageState extends State { final StatsController _controller = StatsController(); + + late String _teamImageUrl; + bool _isUploadingTeamPhoto = false; + bool _isPickerActive = false; + + @override + void initState() { + super.initState(); + _teamImageUrl = widget.team.imageUrl; + } + + Future _updateTeamPhoto() async { + if (_isPickerActive) return; + setState(() => _isPickerActive = true); + + try { + final File? croppedFile = await _controller.pickAndCropImage(context); + if (croppedFile == null) return; + + setState(() => _isUploadingTeamPhoto = true); + + final fileName = 'team_${widget.team.id}_${DateTime.now().millisecondsSinceEpoch}.png'; + final supabase = Supabase.instance.client; + + await supabase.storage.from('avatars').upload(fileName, croppedFile, fileOptions: const FileOptions(upsert: true)); + final publicUrl = supabase.storage.from('avatars').getPublicUrl(fileName); + + await supabase.from('teams').update({'image_url': publicUrl}).eq('id', widget.team.id); + + if (_teamImageUrl.isNotEmpty && _teamImageUrl.startsWith('http')) { + final oldPath = _controller.extractPathFromUrl(_teamImageUrl, 'avatars'); + if (oldPath != null) await supabase.storage.from('avatars').remove([oldPath]); + } + + if (mounted) setState(() => _teamImageUrl = publicUrl); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Erro: $e"), backgroundColor: AppTheme.primaryRed)); + } finally { + if (mounted) { + setState(() { + _isUploadingTeamPhoto = false; + _isPickerActive = false; + }); + } + } + } @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, // 👇 Adapta-se ao Modo Escuro + backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: Column( children: [ - StatsHeader(team: widget.team), + StatsHeader(team: widget.team, currentImageUrl: _teamImageUrl, onEditPhoto: _updateTeamPhoto, isUploading: _isUploadingTeamPhoto), Expanded( child: StreamBuilder>( stream: _controller.getMembers(widget.team.id), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { - return Center(child: CircularProgressIndicator(color: AppTheme.primaryRed)); + return const SkeletonLoadingStats(); } - if (snapshot.hasError) { - return Center(child: Text("Erro ao carregar: ${snapshot.error}", style: TextStyle(color: Theme.of(context).colorScheme.onSurface))); - } + if (snapshot.hasError) return Center(child: Text("Erro ao carregar: ${snapshot.error}", style: TextStyle(color: Theme.of(context).colorScheme.onSurface))); final members = snapshot.data ?? []; - final coaches = members.where((m) => m.type == 'Treinador').toList(); - final players = members.where((m) => m.type == 'Jogador').toList(); + final coaches = members.where((m) => m.type == 'Treinador').toList()..sort((a, b) => a.name.compareTo(b.name)); + final players = members.where((m) => m.type == 'Jogador').toList()..sort((a, b) { + int numA = int.tryParse(a.number ?? '999') ?? 999; + int numB = int.tryParse(b.number ?? '999') ?? 999; + return numA.compareTo(numB); + }); return RefreshIndicator( color: AppTheme.primaryRed, @@ -257,32 +344,17 @@ class _TeamStatsPageState extends State { StatsSummaryCard(total: members.length), SizedBox(height: 30 * context.sf), - // TREINADORES if (coaches.isNotEmpty) ...[ const StatsSectionTitle(title: "Treinadores"), - ...coaches.map((c) => PersonCard( - person: c, - isCoach: true, - onEdit: () => _controller.showEditPersonDialog(context, widget.team.id, c), - onDelete: () => _confirmDelete(context, c), - )), + ...coaches.map((c) => PersonCard(person: c, isCoach: true, onEdit: () => _controller.showEditPersonDialog(context, widget.team.id, c), onDelete: () => _confirmDelete(context, c))), SizedBox(height: 30 * context.sf), ], - // JOGADORES const StatsSectionTitle(title: "Jogadores"), if (players.isEmpty) - Padding( - padding: EdgeInsets.only(top: 20 * context.sf), - child: Text("Nenhum jogador nesta equipa.", style: TextStyle(color: Colors.grey, fontSize: 16 * context.sf)), - ) + Padding(padding: EdgeInsets.only(top: 20 * context.sf), child: Text("Nenhum jogador nesta equipa.", style: TextStyle(color: Colors.grey, fontSize: 16 * context.sf))) else - ...players.map((p) => PersonCard( - person: p, - isCoach: false, - onEdit: () => _controller.showEditPersonDialog(context, widget.team.id, p), - onDelete: () => _confirmDelete(context, p), - )), + ...players.map((p) => PersonCard(person: p, isCoach: false, onEdit: () => _controller.showEditPersonDialog(context, widget.team.id, p), onDelete: () => _confirmDelete(context, p))), SizedBox(height: 80 * context.sf), ], ), @@ -296,13 +368,13 @@ class _TeamStatsPageState extends State { floatingActionButton: FloatingActionButton( heroTag: 'fab_team_${widget.team.id}', onPressed: () => _controller.showAddPersonDialog(context, widget.team.id), - backgroundColor: AppTheme.successGreen, // 👇 Cor de sucesso do tema + backgroundColor: AppTheme.successGreen, child: Icon(Icons.add, color: Colors.white, size: 24 * context.sf), ), ); } - void _confirmDelete(BuildContext context, Person person) { +void _confirmDelete(BuildContext context, Person person) { showDialog( context: context, builder: (ctx) => AlertDialog( @@ -310,53 +382,91 @@ class _TeamStatsPageState extends State { title: Text("Eliminar Membro?", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)), content: Text("Tens a certeza que queres remover ${person.name}?", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)), actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("Cancelar", style: TextStyle(color: Colors.grey))), TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text("Cancelar", style: TextStyle(color: Colors.grey)) - ), - TextButton( - onPressed: () async { - await _controller.deletePerson(person.id); - if (ctx.mounted) Navigator.pop(ctx); + onPressed: () { + // ⚡ FECHA LOGO O POP-UP! + Navigator.pop(ctx); + // Mostra um aviso rápido para o utilizador saber que a app está a trabalhar + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("A remover ${person.name}..."), duration: const Duration(seconds: 1))); + + // APAGA NO FUNDO + _controller.deletePerson(person).catchError((e) { + if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Erro: $e"), backgroundColor: AppTheme.primaryRed)); + }); }, - child: Text("Eliminar", style: TextStyle(color: AppTheme.primaryRed)), // 👇 Cor oficial + child: const Text("Eliminar", style: TextStyle(color: AppTheme.primaryRed)), ), ], ), ); } -} + } + // ========================================== // 3. CONTROLLER // ========================================== - class StatsController { final _supabase = Supabase.instance.client; Stream> getMembers(String teamId) { - return _supabase - .from('members') - .stream(primaryKey: ['id']) - .eq('team_id', teamId) - .order('name', ascending: true) - .map((data) => data.map((json) => Person.fromMap(json)).toList()); + return _supabase.from('members').stream(primaryKey: ['id']).eq('team_id', teamId).map((data) => data.map((json) => Person.fromMap(json)).toList()); } - Future deletePerson(String personId) async { - try { - await _supabase.from('members').delete().eq('id', personId); - } catch (e) { - debugPrint("Erro ao eliminar: $e"); + String? extractPathFromUrl(String url, String bucket) { + if (url.isEmpty) return null; + final parts = url.split('/$bucket/'); + if (parts.length > 1) return parts.last; + return null; + } + + Future deletePerson(Person person) async { + try { + await _supabase.from('members').delete().eq('id', person.id); + + if (person.imageUrl != null && person.imageUrl!.isNotEmpty) { + final path = extractPathFromUrl(person.imageUrl!, 'avatars'); + if (path != null) await _supabase.storage.from('avatars').remove([path]); + } + } catch (e) { + debugPrint("Erro ao eliminar: $e"); } } - void showAddPersonDialog(BuildContext context, String teamId) { - _showForm(context, teamId: teamId); - } + void showAddPersonDialog(BuildContext context, String teamId) { _showForm(context, teamId: teamId); } + void showEditPersonDialog(BuildContext context, String teamId, Person person) { _showForm(context, teamId: teamId, person: person); } - void showEditPersonDialog(BuildContext context, String teamId, Person person) { - _showForm(context, teamId: teamId, person: person); + Future pickAndCropImage(BuildContext context) async { + final picker = ImagePicker(); + final pickedFile = await picker.pickImage(source: ImageSource.gallery); + + if (pickedFile == null) return null; + + CroppedFile? croppedFile = await ImageCropper().cropImage( + sourcePath: pickedFile.path, + aspectRatio: const CropAspectRatio(ratioX: 1, ratioY: 1), + uiSettings: [ + AndroidUiSettings( + toolbarTitle: 'Recortar Foto', + toolbarColor: AppTheme.primaryRed, + toolbarWidgetColor: Colors.white, + initAspectRatio: CropAspectRatioPreset.square, + lockAspectRatio: true, + hideBottomControls: true, + ), + IOSUiSettings( + title: 'Recortar Foto', + aspectRatioLockEnabled: true, + resetButtonHidden: true, + ), + ], + ); + + if (croppedFile != null) { + return File(croppedFile.path); + } + return null; } void _showForm(BuildContext context, {required String teamId, Person? person}) { @@ -364,6 +474,15 @@ class StatsController { final nameCtrl = TextEditingController(text: person?.name ?? ''); final numCtrl = TextEditingController(text: person?.number ?? ''); String selectedType = person?.type ?? 'Jogador'; + + File? selectedImage; + bool isUploading = false; + bool isPickerActive = false; + String? currentImageUrl = isEdit ? person.imageUrl : null; + + // 👇 VARIÁVEIS PARA O TEXTO PEQUENO VERMELHO (ESTILO LOGIN) 👇 + String? nameError; + String? numError; showDialog( context: context, @@ -371,18 +490,58 @@ class StatsController { builder: (ctx, setState) => AlertDialog( backgroundColor: Theme.of(context).colorScheme.surface, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * context.sf)), - title: Text( - isEdit ? "Editar Membro" : "Novo Membro", - style: TextStyle(color: Theme.of(context).colorScheme.onSurface) - ), + title: Text(isEdit ? "Editar Membro" : "Novo Membro", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ + GestureDetector( + onTap: () async { + if (isPickerActive) return; + setState(() => isPickerActive = true); + + try { + final File? croppedFile = await pickAndCropImage(context); + if (croppedFile != null) { + setState(() => selectedImage = croppedFile); + } + } finally { + setState(() => isPickerActive = false); + } + }, + child: Stack( + alignment: Alignment.center, + children: [ + CircleAvatar( + radius: 40 * context.sf, + backgroundColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.05), + backgroundImage: selectedImage != null + ? FileImage(selectedImage!) + : (currentImageUrl != null && currentImageUrl!.isNotEmpty ? CachedNetworkImageProvider(currentImageUrl!) : null) as ImageProvider?, + child: (selectedImage == null && (currentImageUrl == null || currentImageUrl!.isEmpty)) + ? Icon(Icons.add_a_photo, size: 30 * context.sf, color: Colors.grey) + : null, + ), + Positioned( + bottom: 0, right: 0, + child: Container( + padding: EdgeInsets.all(6 * context.sf), + decoration: BoxDecoration(color: AppTheme.primaryRed, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2)), + child: Icon(Icons.edit, color: Colors.white, size: 14 * context.sf), + ), + ), + ], + ), + ), + SizedBox(height: 20 * context.sf), + TextField( controller: nameCtrl, style: TextStyle(color: Theme.of(context).colorScheme.onSurface), - decoration: const InputDecoration(labelText: "Nome Completo"), + decoration: InputDecoration( + labelText: "Nome Completo", + errorText: nameError, // 👇 ERRO PEQUENO AQUI + ), textCapitalization: TextCapitalization.words, ), SizedBox(height: 15 * context.sf), @@ -391,19 +550,18 @@ class StatsController { dropdownColor: Theme.of(context).colorScheme.surface, style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 16 * context.sf), decoration: const InputDecoration(labelText: "Função"), - items: ["Jogador", "Treinador"] - .map((e) => DropdownMenuItem(value: e, child: Text(e))) - .toList(), - onChanged: (v) { - if (v != null) setState(() => selectedType = v); - }, + items: ["Jogador", "Treinador"].map((e) => DropdownMenuItem(value: e, child: Text(e))).toList(), + onChanged: (v) { if (v != null) setState(() => selectedType = v); }, ), if (selectedType == "Jogador") ...[ SizedBox(height: 15 * context.sf), TextField( controller: numCtrl, style: TextStyle(color: Theme.of(context).colorScheme.onSurface), - decoration: const InputDecoration(labelText: "Número da Camisola"), + decoration: InputDecoration( + labelText: "Número da Camisola", + errorText: numError, // 👇 ERRO PEQUENO AQUI + ), keyboardType: TextInputType.number, ), ] @@ -411,29 +569,46 @@ class StatsController { ), ), actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text("Cancelar", style: TextStyle(color: Colors.grey)) - ), + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("Cancelar", style: TextStyle(color: Colors.grey))), ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.successGreen, // 👇 Cor verde do tema - foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8 * context.sf)) - ), - onPressed: () async { - if (nameCtrl.text.trim().isEmpty) return; + style: ElevatedButton.styleFrom(backgroundColor: AppTheme.successGreen, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8 * context.sf))), + onPressed: isUploading ? null : () async { + + // Limpa os erros antes de tentar de novo + setState(() { + nameError = null; + numError = null; + }); - String? numeroFinal = (selectedType == "Treinador") - ? null - : (numCtrl.text.trim().isEmpty ? null : numCtrl.text.trim()); + if (nameCtrl.text.trim().isEmpty) { + setState(() => nameError = "O nome é obrigatório"); + return; + } + + setState(() => isUploading = true); + + String? numeroFinal = (selectedType == "Treinador") ? null : (numCtrl.text.trim().isEmpty ? null : numCtrl.text.trim()); try { + String? finalImageUrl = currentImageUrl; + + if (selectedImage != null) { + final fileName = 'person_${DateTime.now().millisecondsSinceEpoch}.png'; + await _supabase.storage.from('avatars').upload(fileName, selectedImage!, fileOptions: const FileOptions(upsert: true)); + finalImageUrl = _supabase.storage.from('avatars').getPublicUrl(fileName); + + if (currentImageUrl != null && currentImageUrl!.isNotEmpty) { + final oldPath = extractPathFromUrl(currentImageUrl!, 'avatars'); + if (oldPath != null) await _supabase.storage.from('avatars').remove([oldPath]); + } + } + if (isEdit) { await _supabase.from('members').update({ 'name': nameCtrl.text.trim(), 'type': selectedType, 'number': numeroFinal, + 'image_url': finalImageUrl, }).eq('id', person.id); } else { await _supabase.from('members').insert({ @@ -441,23 +616,25 @@ class StatsController { 'name': nameCtrl.text.trim(), 'type': selectedType, 'number': numeroFinal, + 'image_url': finalImageUrl, }); } if (ctx.mounted) Navigator.pop(ctx); } catch (e) { - debugPrint("Erro Supabase: $e"); - if (ctx.mounted) { - String errorMsg = "Erro ao guardar: $e"; - if (e.toString().contains('unique')) { - errorMsg = "Já existe um membro com este numero na equipa."; + // 👇 AGORA OS ERROS VÃO DIRETOS PARA OS CAMPOS (ESTILO LOGIN) 👇 + setState(() { + isUploading = false; + if (e is PostgrestException && e.code == '23505') { + numError = "Este número já está em uso!"; + } else if (e.toString().toLowerCase().contains('unique') || e.toString().toLowerCase().contains('duplicate')) { + numError = "Este número já está em uso!"; + } else { + nameError = "Erro ao guardar. Tente novamente."; } - ScaffoldMessenger.of(ctx).showSnackBar( - SnackBar(content: Text(errorMsg), backgroundColor: AppTheme.primaryRed) // 👇 Cor oficial para erro - ); - } + }); } }, - child: const Text("Guardar"), + child: isUploading ? SizedBox(width: 16 * context.sf, height: 16 * context.sf, child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : const Text("Guardar"), ) ], ), diff --git a/lib/widgets/placar_widgets.dart b/lib/widgets/placar_widgets.dart index 005e592..884e2d2 100644 --- a/lib/widgets/placar_widgets.dart +++ b/lib/widgets/placar_widgets.dart @@ -1,15 +1,15 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:playmaker/controllers/placar_controller.dart'; -import 'package:playmaker/utils/size_extension.dart'; -import 'package:playmaker/classe/theme.dart'; -import 'dart:math' as math; - +import 'package:playmaker/classe/theme.dart'; +import 'package:playmaker/controllers/placar_controller.dart'; import 'package:playmaker/zone_map_dialog.dart'; +import 'dart:math' as math; // ============================================================================ // 1. PLACAR SUPERIOR (CRONÓMETRO E RESULTADO) // ============================================================================ +// ============================================================================ +// 1. PLACAR SUPERIOR (COM CRONÓMETRO DE ALTA PERFORMANCE) +// ============================================================================ class TopScoreboard extends StatelessWidget { final PlacarController controller; final double sf; @@ -19,60 +19,86 @@ class TopScoreboard extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - padding: EdgeInsets.symmetric(vertical: 10 * sf, horizontal: 35 * sf), + padding: EdgeInsets.symmetric(vertical: 6 * sf, horizontal: 20 * sf), decoration: BoxDecoration( color: AppTheme.placarDarkSurface, - borderRadius: BorderRadius.only( - bottomLeft: Radius.circular(22 * sf), - bottomRight: Radius.circular(22 * sf) - ), - border: Border.all(color: Colors.white, width: 2.5 * sf), + borderRadius: BorderRadius.only(bottomLeft: Radius.circular(22 * sf), bottomRight: Radius.circular(22 * sf)), + border: Border.all(color: Colors.white, width: 2.0 * sf), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ _buildTeamSection(controller.myTeam, controller.myScore, controller.myFouls, controller.myTimeoutsUsed, AppTheme.myTeamBlue, false, sf), - SizedBox(width: 30 * sf), + SizedBox(width: 20 * sf), Column( mainAxisSize: MainAxisSize.min, children: [ Container( - padding: EdgeInsets.symmetric(horizontal: 18 * sf, vertical: 5 * sf), - decoration: BoxDecoration( - color: AppTheme.placarTimerBg, - borderRadius: BorderRadius.circular(9 * sf) - ), - child: Text( - controller.formatTime(), - style: TextStyle(color: Colors.white, fontSize: 28 * sf, fontWeight: FontWeight.w900, fontFamily: 'monospace', letterSpacing: 2 * sf) + padding: EdgeInsets.symmetric(horizontal: 14 * sf, vertical: 4 * sf), + decoration: BoxDecoration(color: AppTheme.placarTimerBg, borderRadius: BorderRadius.circular(9 * sf)), + // 👇 AQUI ESTÁ A MAGIA DE PERFORMANCE! Só este texto se atualiza a cada segundo! 👇 + child: ValueListenableBuilder( + valueListenable: controller.durationNotifier, + builder: (context, duration, child) { + String formatTime = "${duration.inMinutes.toString().padLeft(2, '0')}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}"; + return Text(formatTime, style: TextStyle(color: Colors.white, fontSize: 24 * sf, fontWeight: FontWeight.w900, fontFamily: 'monospace', letterSpacing: 1.5 * sf)); + } ), ), - SizedBox(height: 5 * sf), - Text( - "PERÍODO ${controller.currentQuarter}", - style: TextStyle(color: AppTheme.warningAmber, fontSize: 14 * sf, fontWeight: FontWeight.w900) - ), + SizedBox(height: 4 * sf), + Text("PERÍODO ${controller.currentQuarter}", style: TextStyle(color: AppTheme.warningAmber, fontSize: 12 * sf, fontWeight: FontWeight.w900)), ], ), - SizedBox(width: 30 * sf), + SizedBox(width: 20 * sf), _buildTeamSection(controller.opponentTeam, controller.opponentScore, controller.opponentFouls, controller.opponentTimeoutsUsed, AppTheme.oppTeamRed, true, sf), ], ), ); } + Widget _buildTeamSection(String name, int score, int fouls, int timeouts, Color color, bool isOpp, double sf) { + int displayFouls = fouls > 5 ? 5 : fouls; + final timeoutIndicators = Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(3, (index) => Container( + margin: EdgeInsets.symmetric(horizontal: 2.5 * sf), width: 10 * sf, height: 10 * sf, + decoration: BoxDecoration(shape: BoxShape.circle, color: index < timeouts ? AppTheme.warningAmber : Colors.grey.shade600, border: Border.all(color: Colors.white54, width: 1.0 * sf)), + )), + ); + List content = [ + Column(children: [_scoreBox(score, color, sf), SizedBox(height: 5 * sf), timeoutIndicators]), + SizedBox(width: 12 * sf), + Column( + crossAxisAlignment: isOpp ? CrossAxisAlignment.start : CrossAxisAlignment.end, + children: [ + Text(name.toUpperCase(), style: TextStyle(color: Colors.white, fontSize: 16 * sf, fontWeight: FontWeight.w900, letterSpacing: 1.0 * sf)), + SizedBox(height: 3 * sf), + Text("FALTAS: $displayFouls", style: TextStyle(color: displayFouls >= 5 ? AppTheme.actionMiss : AppTheme.warningAmber, fontSize: 11 * sf, fontWeight: FontWeight.bold)), + ], + ) + ]; + return Row(crossAxisAlignment: CrossAxisAlignment.center, children: isOpp ? content : content.reversed.toList()); + } + + Widget _scoreBox(int score, Color color, double sf) => Container( + width: 45 * sf, height: 35 * sf, alignment: Alignment.center, + decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(6 * sf)), + child: Text(score.toString(), style: TextStyle(color: Colors.white, fontSize: 20 * sf, fontWeight: FontWeight.w900)), + ); +} + Widget _buildTeamSection(String name, int score, int fouls, int timeouts, Color color, bool isOpp, double sf) { int displayFouls = fouls > 5 ? 5 : fouls; final timeoutIndicators = Row( mainAxisSize: MainAxisSize.min, children: List.generate(3, (index) => Container( - margin: EdgeInsets.symmetric(horizontal: 3.5 * sf), - width: 12 * sf, height: 12 * sf, + margin: EdgeInsets.symmetric(horizontal: 2.5 * sf), + width: 10 * sf, height: 10 * sf, decoration: BoxDecoration( shape: BoxShape.circle, color: index < timeouts ? AppTheme.warningAmber : Colors.grey.shade600, - border: Border.all(color: Colors.white54, width: 1.5 * sf) + border: Border.all(color: Colors.white54, width: 1.0 * sf) ), )), ); @@ -81,40 +107,38 @@ class TopScoreboard extends StatelessWidget { Column( children: [ _scoreBox(score, color, sf), - SizedBox(height: 7 * sf), + SizedBox(height: 5 * sf), timeoutIndicators ] ), - SizedBox(width: 18 * sf), + SizedBox(width: 12 * sf), Column( crossAxisAlignment: isOpp ? CrossAxisAlignment.start : CrossAxisAlignment.end, children: [ Text( name.toUpperCase(), - style: TextStyle(color: Colors.white, fontSize: 20 * sf, fontWeight: FontWeight.w900, letterSpacing: 1.2 * sf) + style: TextStyle(color: Colors.white, fontSize: 16 * sf, fontWeight: FontWeight.w900, letterSpacing: 1.0 * sf) ), - SizedBox(height: 5 * sf), + SizedBox(height: 3 * sf), Text( "FALTAS: $displayFouls", - style: TextStyle(color: displayFouls >= 5 ? AppTheme.actionMiss : AppTheme.warningAmber, fontSize: 13 * sf, fontWeight: FontWeight.bold) + style: TextStyle(color: displayFouls >= 5 ? AppTheme.actionMiss : AppTheme.warningAmber, fontSize: 11 * sf, fontWeight: FontWeight.bold) ), ], - ) + ), ]; return Row(crossAxisAlignment: CrossAxisAlignment.center, children: isOpp ? content : content.reversed.toList()); } Widget _scoreBox(int score, Color color, double sf) => Container( - width: 58 * sf, height: 45 * sf, + width: 45 * sf, height: 35 * sf, alignment: Alignment.center, - decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(7 * sf)), - child: Text(score.toString(), style: TextStyle(color: Colors.white, fontSize: 26 * sf, fontWeight: FontWeight.w900)), + decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(6 * sf)), + child: Text(score.toString(), style: TextStyle(color: Colors.white, fontSize: 20 * sf, fontWeight: FontWeight.w900)), ); -} - // ============================================================================ -// 2. BANCO DE SUPLENTES (DRAG & DROP) +// 2. BANCO DE SUPLENTES (COM TRADUTOR DE NOME) // ============================================================================ class BenchPlayersList extends StatelessWidget { final PlacarController controller; @@ -131,51 +155,45 @@ class BenchPlayersList extends StatelessWidget { return Column( mainAxisSize: MainAxisSize.min, - children: bench.map((playerName) { - final num = controller.playerNumbers[playerName] ?? "0"; - final int fouls = controller.playerStats[playerName]?["fls"] ?? 0; + children: bench.map((playerId) { + final playerName = controller.playerNames[playerId] ?? "Erro"; + final num = controller.playerNumbers[playerId] ?? "0"; + final int fouls = controller.playerStats[playerId]?["fls"] ?? 0; final bool isFouledOut = fouls >= 5; + String shortName = playerName.length > 8 ? "${playerName.substring(0, 7)}." : playerName; + Widget avatarUI = Container( margin: EdgeInsets.only(bottom: 7 * sf), - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 1.8 * sf), - boxShadow: [BoxShadow(color: Colors.black45, blurRadius: 5 * sf, offset: Offset(0, 2.5 * sf))] - ), - child: CircleAvatar( - radius: 22 * sf, - backgroundColor: isFouledOut ? Colors.grey.shade800 : teamColor, - child: Text( - num, - style: TextStyle( - color: isFouledOut ? Colors.red.shade300 : Colors.white, - fontSize: 16 * sf, - fontWeight: FontWeight.bold, - decoration: isFouledOut ? TextDecoration.lineThrough : TextDecoration.none - ) - ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.5 * sf), + boxShadow: [BoxShadow(color: Colors.black45, blurRadius: 4 * sf, offset: Offset(0, 2.0 * sf))] + ), + child: CircleAvatar( + radius: 18 * sf, + backgroundColor: isFouledOut ? Colors.grey.shade800 : teamColor, + child: Text(num, style: TextStyle(color: isFouledOut ? Colors.red.shade300 : Colors.white, fontSize: 14 * sf, fontWeight: FontWeight.bold, decoration: isFouledOut ? TextDecoration.lineThrough : TextDecoration.none)), + ), + ), + SizedBox(height: 4 * sf), + Text(shortName, style: TextStyle(color: Colors.white, fontSize: 10 * sf, fontWeight: FontWeight.bold), overflow: TextOverflow.ellipsis), + ], ), ); if (isFouledOut) { - return GestureDetector( - onTap: () => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('🛑 $playerName não pode voltar (Expulso).'), backgroundColor: AppTheme.actionMiss)), - child: avatarUI - ); + return GestureDetector(onTap: () => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('🛑 $playerName não pode voltar (Expulso).'), backgroundColor: AppTheme.actionMiss)), child: avatarUI); } return Draggable( - data: "$prefix$playerName", - feedback: Material( - color: Colors.transparent, - child: CircleAvatar( - radius: 28 * sf, - backgroundColor: teamColor, - child: Text(num, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 18 * sf)) - ) - ), - childWhenDragging: Opacity(opacity: 0.5, child: SizedBox(width: 45 * sf, height: 45 * sf)), + data: "$prefix$playerId", + feedback: Material(color: Colors.transparent, child: CircleAvatar(radius: 22 * sf, backgroundColor: teamColor, child: Text(num, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16 * sf)))), + childWhenDragging: Opacity(opacity: 0.5, child: SizedBox(width: 36 * sf, height: 36 * sf)), child: avatarUI, ); }).toList(), @@ -184,34 +202,36 @@ class BenchPlayersList extends StatelessWidget { } // ============================================================================ -// 3. CARTÃO DO JOGADOR NO CAMPO (TARGET DE FALTAS/PONTOS/SUBSTITUIÇÕES) +// 3. CARTÃO DO JOGADOR NO CAMPO (COM TRADUTOR DE NOME) // ============================================================================ class PlayerCourtCard extends StatelessWidget { final PlacarController controller; - final String name; + final String playerId; final bool isOpponent; final double sf; - const PlayerCourtCard({super.key, required this.controller, required this.name, required this.isOpponent, required this.sf}); + const PlayerCourtCard({super.key, required this.controller, required this.playerId, required this.isOpponent, required this.sf}); @override Widget build(BuildContext context) { final teamColor = isOpponent ? AppTheme.oppTeamRed : AppTheme.myTeamBlue; - final stats = controller.playerStats[name]!; - final number = controller.playerNumbers[name]!; + + final realName = controller.playerNames[playerId] ?? "Erro"; + final stats = controller.playerStats[playerId]!; + final number = controller.playerNumbers[playerId]!; final prefix = isOpponent ? "player_opp_" : "player_my_"; return Draggable( - data: "$prefix$name", + data: "$prefix$playerId", feedback: Material( color: Colors.transparent, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - decoration: BoxDecoration(color: teamColor.withOpacity(0.9), borderRadius: BorderRadius.circular(8)), - child: Text(name, style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration(color: teamColor.withOpacity(0.9), borderRadius: BorderRadius.circular(6)), + child: Text(realName, style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), ), ), - childWhenDragging: Opacity(opacity: 0.5, child: _playerCardUI(number, name, stats, teamColor, false, false, sf)), + childWhenDragging: Opacity(opacity: 0.5, child: _playerCardUI(number, realName, stats, teamColor, false, false, sf)), child: DragTarget( onAcceptWithDetails: (details) { final action = details.data; @@ -223,85 +243,70 @@ class PlayerCourtCard extends StatelessWidget { showDialog( context: context, builder: (ctx) => ZoneMapDialog( - playerName: name, + playerName: realName, isMake: isMake, is3PointAction: is3Pt, onZoneSelected: (zone, points, relX, relY) { - controller.registerShotFromPopup(context, action, "$prefix$name", zone, points, relX, relY); + Navigator.pop(ctx); + controller.registerShotFromPopup(context, action, "$prefix$playerId", zone, points, relX, relY); }, ), ); } else if (action.startsWith("add_") || action.startsWith("sub_") || action.startsWith("miss_")) { - controller.handleActionDrag(context, action, "$prefix$name"); + controller.handleActionDrag(context, action, "$prefix$playerId"); } else if (action.startsWith("bench_")) { - controller.handleSubbing(context, action, name, isOpponent); + controller.handleSubbing(context, action, playerId, isOpponent); } }, builder: (context, candidateData, rejectedData) { bool isSubbing = candidateData.any((data) => data != null && (data.startsWith("bench_my_") || data.startsWith("bench_opp_"))); bool isActionHover = candidateData.any((data) => data != null && (data.startsWith("add_") || data.startsWith("sub_") || data.startsWith("miss_"))); - return _playerCardUI(number, name, stats, teamColor, isSubbing, isActionHover, sf); + return _playerCardUI(number, realName, stats, teamColor, isSubbing, isActionHover, sf); }, ), ); } - Widget _playerCardUI(String number, String name, Map stats, Color teamColor, bool isSubbing, bool isActionHover, double sf) { + Widget _playerCardUI(String number, String displayNameStr, Map stats, Color teamColor, bool isSubbing, bool isActionHover, double sf) { bool isFouledOut = stats["fls"]! >= 5; Color bgColor = isFouledOut ? Colors.red.shade100 : Colors.white; Color borderColor = isFouledOut ? AppTheme.actionMiss : Colors.transparent; - if (isSubbing) { - bgColor = Colors.blue.shade50; borderColor = AppTheme.myTeamBlue; - } else if (isActionHover && !isFouledOut) { - bgColor = Colors.orange.shade50; borderColor = AppTheme.actionPoints; - } + if (isSubbing) { bgColor = Colors.blue.shade50; borderColor = AppTheme.myTeamBlue; } + else if (isActionHover && !isFouledOut) { bgColor = Colors.orange.shade50; borderColor = AppTheme.actionPoints; } - int fgm = stats["fgm"]!; - int fga = stats["fga"]!; + int fgm = stats["fgm"]!; int fga = stats["fga"]!; String fgPercent = fga > 0 ? ((fgm / fga) * 100).toStringAsFixed(0) : "0"; - String displayName = name.length > 12 ? "${name.substring(0, 10)}..." : name; + String displayName = displayNameStr.length > 12 ? "${displayNameStr.substring(0, 10)}..." : displayNameStr; return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: bgColor, borderRadius: BorderRadius.circular(12), border: Border.all(color: borderColor, width: 2), - boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 6, offset: Offset(0, 3))], - ), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(8), border: Border.all(color: borderColor, width: 1.5), boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 4, offset: Offset(0, 2))]), child: ClipRRect( - borderRadius: BorderRadius.circular(9 * sf), + borderRadius: BorderRadius.circular(6 * sf), child: IntrinsicHeight( child: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( - padding: EdgeInsets.symmetric(horizontal: 16 * sf), + padding: EdgeInsets.symmetric(horizontal: 10 * sf), color: isFouledOut ? Colors.grey[700] : teamColor, alignment: Alignment.center, - child: Text(number, style: TextStyle(color: Colors.white, fontSize: 22 * sf, fontWeight: FontWeight.bold)), + child: Text(number, style: TextStyle(color: Colors.white, fontSize: 18 * sf, fontWeight: FontWeight.bold)), ), Padding( - padding: EdgeInsets.symmetric(horizontal: 12 * sf, vertical: 7 * sf), + padding: EdgeInsets.symmetric(horizontal: 8 * sf, vertical: 4 * sf), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text( - displayName, - style: TextStyle(fontSize: 16 * sf, fontWeight: FontWeight.bold, color: isFouledOut ? AppTheme.actionMiss : Colors.black87, decoration: isFouledOut ? TextDecoration.lineThrough : TextDecoration.none) - ), - SizedBox(height: 2.5 * sf), - Text( - "${stats["pts"]} Pts | FG: $fgm/$fga ($fgPercent%)", - style: TextStyle(fontSize: 12 * sf, color: isFouledOut ? AppTheme.actionMiss : Colors.grey[700], fontWeight: FontWeight.w600) - ), - Text( - "${stats["ast"]} Ast | ${stats["orb"]! + stats["drb"]!} Rbs | ${stats["fls"]} Fls", - style: TextStyle(fontSize: 12 * sf, color: isFouledOut ? AppTheme.actionMiss : Colors.grey[500], fontWeight: FontWeight.w600) - ), + Text(displayName, style: TextStyle(fontSize: 14 * sf, fontWeight: FontWeight.bold, color: isFouledOut ? AppTheme.actionMiss : Colors.black87, decoration: isFouledOut ? TextDecoration.lineThrough : TextDecoration.none)), + SizedBox(height: 1.5 * sf), + Text("${stats["pts"]} Pts | FG: $fgm/$fga ($fgPercent%)", style: TextStyle(fontSize: 10 * sf, color: isFouledOut ? AppTheme.actionMiss : Colors.grey[700], fontWeight: FontWeight.w600)), + Text("${stats["ast"]} Ast | ${stats["orb"]! + stats["drb"]!} Rbs | ${stats["fls"]} Fls", style: TextStyle(fontSize: 10 * sf, color: isFouledOut ? AppTheme.actionMiss : Colors.grey[500], fontWeight: FontWeight.w600)), ], ), ), @@ -314,7 +319,7 @@ class PlayerCourtCard extends StatelessWidget { } // ============================================================================ -// 4. PAINEL DE BOTÕES DE AÇÃO (PONTOS, RESSALTOS, ETC) +// 4. PAINEL DE BOTÕES DE AÇÃO // ============================================================================ class ActionButtonsPanel extends StatelessWidget { final PlacarController controller; @@ -324,9 +329,9 @@ class ActionButtonsPanel extends StatelessWidget { @override Widget build(BuildContext context) { - final double baseSize = 65 * sf; - final double feedSize = 82 * sf; - final double gap = 7 * sf; + final double baseSize = 58 * sf; + final double feedSize = 73 * sf; + final double gap = 5 * sf; return Row( mainAxisAlignment: MainAxisAlignment.center, @@ -452,329 +457,7 @@ class ActionButtonsPanel extends StatelessWidget { } // ============================================================================ -// 5. PÁGINA DO PLACAR -// ============================================================================ -class PlacarPage extends StatefulWidget { - final String gameId, myTeam, opponentTeam; - - const PlacarPage({ - super.key, - required this.gameId, - required this.myTeam, - required this.opponentTeam - }); - - @override - State 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(); - } - - @override - void dispose() { - _controller.dispose(); - SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); - super.dispose(); - } - - Widget _buildFloatingFoulBtn(String label, Color color, String action, IconData icon, double left, double right, double top, double sf) { - return Positioned( - top: top, - left: left > 0 ? left : null, - right: right > 0 ? right : null, - child: Draggable( - data: action, - feedback: Material( - color: Colors.transparent, - child: CircleAvatar( - radius: 30 * sf, - backgroundColor: color.withOpacity(0.8), - child: Icon(icon, color: Colors.white, size: 30 * sf) - ), - ), - child: Column( - children: [ - CircleAvatar( - radius: 27 * sf, - backgroundColor: color, - child: Icon(icon, color: Colors.white, size: 28 * sf), - ), - SizedBox(height: 5 * sf), - Text(label, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12 * sf)), - ], - ), - ), - ); - } - - Widget _buildCornerBtn({required String heroTag, required IconData icon, required Color color, required VoidCallback onTap, required double size, bool isLoading = false}) { - return SizedBox( - width: size, - height: size, - child: FloatingActionButton( - heroTag: heroTag, - backgroundColor: color, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14 * (size / 50))), - elevation: 5, - onPressed: isLoading ? null : onTap, - child: isLoading - ? SizedBox(width: size * 0.45, height: size * 0.45, child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2.5)) - : Icon(icon, color: Colors.white, size: size * 0.55), - ), - ); - } - - // 👇 ATIVA O NOVO MAPA DE CALOR 👇 - void _showHeatmap(BuildContext context) { - showDialog( - context: context, - builder: (ctx) => HeatmapDialog( - shots: _controller.matchShots, - myTeamName: _controller.myTeam, - oppTeamName: _controller.opponentTeam, - myPlayers: [..._controller.myCourt, ..._controller.myBench], - oppPlayers: [..._controller.oppCourt, ..._controller.oppBench], - playerStats: _controller.playerStats, // Passa os stats para mostrar os pontos - ), - ); - } - - @override - Widget build(BuildContext context) { - final double wScreen = MediaQuery.of(context).size.width; - final double hScreen = MediaQuery.of(context).size.height; - - final double sf = math.min(wScreen / 1150, hScreen / 720); - final double cornerBtnSize = 48 * sf; - - if (_controller.isLoading) { - return Scaffold( - backgroundColor: AppTheme.placarDarkSurface, - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text("PREPARANDO O PAVILHÃO", style: TextStyle(color: Colors.white24, fontSize: 45 * sf, fontWeight: FontWeight.bold, letterSpacing: 2)), - SizedBox(height: 35 * sf), - StreamBuilder( - stream: Stream.periodic(const Duration(seconds: 3)), - builder: (context, snapshot) { - List frases = [ - "O Treinador está a desenhar a tática...", - "A encher as bolas com ar de campeão...", - "O árbitro está a testar o apito...", - "A verificar se o cesto está nivelado...", - "Os jogadores estão a terminar o aquecimento..." - ]; - String frase = frases[DateTime.now().second % frases.length]; - return Text(frase, style: TextStyle(color: AppTheme.actionPoints.withOpacity(0.7), fontSize: 26 * sf, fontStyle: FontStyle.italic)); - }, - ), - ], - ), - ), - ); - } - - return Scaffold( - backgroundColor: AppTheme.placarBackground, - body: SafeArea( - top: false, - bottom: false, - child: IgnorePointer( - ignoring: _controller.isSaving, - child: Stack( - children: [ - Container( - margin: EdgeInsets.only(left: 65 * sf, right: 65 * sf, bottom: 55 * sf), - decoration: BoxDecoration(border: Border.all(color: Colors.white, width: 2.5)), - child: LayoutBuilder( - builder: (context, constraints) { - final w = constraints.maxWidth; - final h = constraints.maxHeight; - - return Stack( - children: [ - GestureDetector( - onTapDown: (details) { - if (_controller.isSelectingShotLocation) { - _controller.registerShotLocation(context, details.localPosition, Size(w, h)); - } - }, - child: Container( - decoration: const BoxDecoration( - image: DecorationImage( - image: AssetImage('assets/campo.png'), - fit: BoxFit.fill, - ), - ), - ), - ), - - if (!_controller.isSelectingShotLocation) ...[ - Positioned(top: h * 0.25, left: w * 0.02, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[0], isOpponent: false, sf: sf)), - Positioned(top: h * 0.68, left: w * 0.02, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[1], isOpponent: false, sf: sf)), - Positioned(top: h * 0.45, left: w * 0.25, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[2], isOpponent: false, sf: sf)), - Positioned(top: h * 0.15, left: w * 0.20, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[3], isOpponent: false, sf: sf)), - Positioned(top: h * 0.80, left: w * 0.20, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[4], isOpponent: false, sf: sf)), - - Positioned(top: h * 0.25, right: w * 0.02, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[0], isOpponent: true, sf: sf)), - Positioned(top: h * 0.68, right: w * 0.02, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[1], isOpponent: true, sf: sf)), - Positioned(top: h * 0.45, right: w * 0.25, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[2], isOpponent: true, sf: sf)), - Positioned(top: h * 0.15, right: w * 0.20, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[3], isOpponent: true, sf: sf)), - Positioned(top: h * 0.80, right: w * 0.20, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[4], isOpponent: true, sf: sf)), - ], - - if (!_controller.isSelectingShotLocation) ...[ - _buildFloatingFoulBtn("FALTA +", AppTheme.actionPoints, "add_foul", Icons.sports, w * 0.39, 0.0, h * 0.31, sf), - _buildFloatingFoulBtn("FALTA -", AppTheme.actionMiss, "sub_foul", Icons.block, 0.0, w * 0.39, h * 0.31, sf), - ], - - if (!_controller.isSelectingShotLocation) - Positioned( - top: (h * 0.32) + (40 * sf), - left: 0, right: 0, - child: Center( - child: GestureDetector( - onTap: () => _controller.toggleTimer(context), - child: CircleAvatar( - radius: 68 * sf, - backgroundColor: Colors.grey.withOpacity(0.5), - child: Icon(_controller.isRunning ? Icons.pause : Icons.play_arrow, color: Colors.white, size: 58 * sf) - ), - ), - ), - ), - - Positioned(top: 0, left: 0, right: 0, child: Center(child: TopScoreboard(controller: _controller, sf: sf))), - - if (!_controller.isSelectingShotLocation) Positioned(bottom: -10 * sf, left: 0, right: 0, child: ActionButtonsPanel(controller: _controller, sf: sf)), - - if (_controller.isSelectingShotLocation) - Positioned( - top: h * 0.4, left: 0, right: 0, - child: Center( - child: Container( - padding: EdgeInsets.symmetric(horizontal: 35 * sf, vertical: 18 * sf), - decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(11 * sf), border: Border.all(color: Colors.white, width: 1.5 * sf)), - child: Text("TOQUE NO CAMPO PARA MARCAR O LOCAL DO LANÇAMENTO", style: TextStyle(color: Colors.white, fontSize: 27 * sf, fontWeight: FontWeight.bold)), - ), - ), - ), - ], - ); - }, - ), - ), - - Positioned( - top: 50 * sf, left: 12 * sf, - child: _buildCornerBtn( - heroTag: 'btn_save_exit', - icon: Icons.save_alt, - color: AppTheme.oppTeamRed, - size: cornerBtnSize, - isLoading: _controller.isSaving, - onTap: () async { - await _controller.saveGameStats(context); - if (context.mounted) { - Navigator.pop(context); - } - } - ), - ), - - Positioned( - top: 50 * sf, right: 12 * sf, - child: _buildCornerBtn( - heroTag: 'btn_heatmap', - icon: Icons.local_fire_department, - color: Colors.orange.shade800, - size: cornerBtnSize, - onTap: () => _showHeatmap(context), - ), - ), - - Positioned( - bottom: 55 * sf, left: 12 * sf, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_controller.showMyBench) BenchPlayersList(controller: _controller, isOpponent: false, sf: sf), - SizedBox(height: 12 * sf), - _buildCornerBtn(heroTag: 'btn_sub_home', icon: Icons.swap_horiz, color: AppTheme.myTeamBlue, size: cornerBtnSize, onTap: () { _controller.showMyBench = !_controller.showMyBench; _controller.onUpdate(); }), - SizedBox(height: 12 * sf), - _buildCornerBtn( - heroTag: 'btn_to_home', - icon: Icons.timer, - color: _controller.myTimeoutsUsed >= 3 ? Colors.grey : AppTheme.myTeamBlue, - size: cornerBtnSize, - onTap: _controller.myTimeoutsUsed >= 3 - ? () => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: const Text('🛑 A equipa da casa já usou os 3 Timeouts deste período!'), backgroundColor: AppTheme.actionMiss)) - : () => _controller.useTimeout(false) - ), - ], - ), - ), - - Positioned( - bottom: 55 * sf, right: 12 * sf, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_controller.showOppBench) BenchPlayersList(controller: _controller, isOpponent: true, sf: sf), - SizedBox(height: 12 * sf), - _buildCornerBtn(heroTag: 'btn_sub_away', icon: Icons.swap_horiz, color: AppTheme.oppTeamRed, size: cornerBtnSize, onTap: () { _controller.showOppBench = !_controller.showOppBench; _controller.onUpdate(); }), - SizedBox(height: 12 * sf), - _buildCornerBtn( - heroTag: 'btn_to_away', - icon: Icons.timer, - color: _controller.opponentTimeoutsUsed >= 3 ? Colors.grey : AppTheme.oppTeamRed, - size: cornerBtnSize, - onTap: _controller.opponentTimeoutsUsed >= 3 - ? () => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: const Text('🛑 A equipa visitante já usou os 3 Timeouts deste período!'), backgroundColor: AppTheme.actionMiss)) - : () => _controller.useTimeout(true) - ), - ], - ), - ), - - if (_controller.isSaving) - Positioned.fill( - child: Container( - color: Colors.black.withOpacity(0.4), - ), - ), - ], - ), - ), - ), - ); - } -} - -// ============================================================================ -// 👇 O TEU MAPA DE CALOR: ADVERSÁRIO À ESQUERDA | TUA EQUIPA À DIREITA 👇 +// MAPA DE CALOR // ============================================================================ class HeatmapDialog extends StatefulWidget { final List shots; @@ -864,21 +547,21 @@ class _HeatmapDialogState extends State { padding: const EdgeInsets.all(8.0), child: Row( children: [ - // 👇 ESQUERDA: COLUNA DA EQUIPA ADVERSÁRIA 👇 - Expanded( - child: _buildTeamColumn( - teamName: widget.oppTeamName, - players: widget.oppPlayers, - teamColor: AppTheme.oppTeamRed, // Vermelho do Tema - ), - ), - const SizedBox(width: 8), - // 👇 DIREITA: COLUNA DA TUA EQUIPA 👇 + // 👇 ESQUERDA: COLUNA DA TUA EQUIPA (AZUL) 👇 Expanded( child: _buildTeamColumn( teamName: widget.myTeamName, players: widget.myPlayers, - teamColor: AppTheme.myTeamBlue, // Azul do Tema + teamColor: AppTheme.myTeamBlue, + ), + ), + const SizedBox(width: 8), + // 👇 DIREITA: COLUNA DA EQUIPA ADVERSÁRIA (VERMELHA) 👇 + Expanded( + child: _buildTeamColumn( + teamName: widget.oppTeamName, + players: widget.oppPlayers, + teamColor: AppTheme.oppTeamRed, ), ), ], @@ -899,7 +582,6 @@ class _HeatmapDialogState extends State { ), child: Column( children: [ - // CABEÇALHO DA EQUIPA (Botão para ver a equipa toda) InkWell( onTap: () => setState(() { _selectedTeam = teamName; @@ -926,8 +608,6 @@ class _HeatmapDialogState extends State { ), ), ), - - // LISTA DOS JOGADORES COM OS SEUS PONTOS Expanded( child: ListView.separated( itemCount: realPlayers.length, @@ -945,7 +625,7 @@ class _HeatmapDialogState extends State { onTap: () => setState(() { _selectedTeam = teamName; _selectedPlayer = p; - _isMapVisible = true; // Abre o mapa para este jogador! + _isMapVisible = true; }), ); }, @@ -956,9 +636,6 @@ class _HeatmapDialogState extends State { ); } - // ========================================== - // TELA 2: O MAPA DE CALOR DESENHADO - // ========================================== Widget _buildMapScreen(Color headerColor) { List filteredShots = widget.shots.where((s) { if (_selectedPlayer != 'Todos') return s.playerName == _selectedPlayer; @@ -973,7 +650,6 @@ class _HeatmapDialogState extends State { return Column( children: [ - // CABEÇALHO COM BOTÃO VOLTAR Container( height: 40, color: headerColor, @@ -984,7 +660,7 @@ class _HeatmapDialogState extends State { Positioned( left: 8, child: InkWell( - onTap: () => setState(() => _isMapVisible = false), // Botão de voltar ao menu de seleção! + onTap: () => setState(() => _isMapVisible = false), child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), @@ -1005,7 +681,7 @@ class _HeatmapDialogState extends State { Positioned( right: 8, child: InkWell( - onTap: () => Navigator.pop(context), // Fecha o popup todo + onTap: () => Navigator.pop(context), child: Container( padding: const EdgeInsets.all(4), decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle), @@ -1016,14 +692,17 @@ class _HeatmapDialogState extends State { ], ), ), - - // O DESENHO DO CAMPO E AS BOLAS Expanded( child: LayoutBuilder( builder: (context, constraints) { return Stack( children: [ - CustomPaint(size: Size(constraints.maxWidth, constraints.maxHeight), painter: HeatmapCourtPainter()), + // 👇 A MÁGICA: O CAMPO DESENHADO IGUAL AO POP-UP (CustomPaint) 👇 + CustomPaint( + size: Size(constraints.maxWidth, constraints.maxHeight), + painter: HeatmapCourtPainter(), + ), + // AS BOLINHAS DOS LANÇAMENTOS POR CIMA DAS LINHAS ...filteredShots.map((shot) => Positioned( left: (shot.relativeX * constraints.maxWidth) - 8, top: (shot.relativeY * constraints.maxHeight) - 8, @@ -1043,6 +722,7 @@ class _HeatmapDialogState extends State { } } +// 👇 O PINTOR QUE DESENHA AS LINHAS PERFEITAS DO CAMPO 👇 class HeatmapCourtPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { diff --git a/lib/widgets/stats_widgets.dart b/lib/widgets/stats_widgets.dart index aeab62e..841a28e 100644 --- a/lib/widgets/stats_widgets.dart +++ b/lib/widgets/stats_widgets.dart @@ -118,8 +118,7 @@ class PersonCard extends StatelessWidget { height: 45, alignment: Alignment.center, decoration: BoxDecoration(color: Colors.blue.withOpacity(0.1), borderRadius: BorderRadius.circular(10)), - child: Text(person.number, style: const TextStyle(color: Colors.blue, fontWeight: FontWeight.bold, fontSize: 16)), - ), +child: Text(person.number ?? "J", style: const TextStyle(color: Colors.blue, fontWeight: FontWeight.bold, fontSize: 16)), ), title: Text(person.name, style: const TextStyle(fontWeight: FontWeight.bold)), trailing: Row( mainAxisSize: MainAxisSize.min, diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 3792af4..e12c657 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,10 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) gtk_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); gtk_plugin_register_with_registrar(gtk_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 5d07423..4453582 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux gtk url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 92b6497..7ebbc2f 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,13 +6,17 @@ import FlutterMacOS import Foundation import app_links +import file_selector_macos import path_provider_foundation import shared_preferences_foundation +import sqflite_darwin import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 720ba58..90a8561 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,14 +57,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -89,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -145,6 +177,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" fixnum: dependency: transitive description: @@ -158,6 +222,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" flutter_lints: dependency: "direct dev" description: @@ -166,6 +238,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" flutter_test: dependency: "direct dev" description: flutter @@ -216,6 +296,94 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image_cropper: + dependency: "direct main" + description: + name: image_cropper + sha256: "46c8f9aae51c8350b2a2982462f85a129e77b04675d35b09db5499437d7a996b" + url: "https://pub.dev" + source: hosted + version: "11.0.0" + image_cropper_for_web: + dependency: transitive + description: + name: image_cropper_for_web + sha256: e09749714bc24c4e3b31fbafa2e5b7229b0ff23e8b14d4ba44bd723b77611a0f + url: "https://pub.dev" + source: hosted + version: "7.0.0" + image_cropper_platform_interface: + dependency: transitive + description: + name: image_cropper_platform_interface + sha256: "886a30ec199362cdcc2fbb053b8e53347fbfb9dbbdaa94f9ff85622609f5e7ff" + url: "https://pub.dev" + source: hosted + version: "8.0.0" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156 + url: "https://pub.dev" + source: hosted + version: "0.8.13+14" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" jwt_decode: dependency: transitive description: @@ -268,18 +436,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -304,6 +472,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" path: dependency: transitive description: @@ -425,7 +601,7 @@ packages: source: hosted version: "0.28.0" shared_preferences: - dependency: transitive + dependency: "direct main" description: name: shared_preferences sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" @@ -480,6 +656,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + shimmer: + dependency: "direct main" + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter @@ -493,6 +677,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" + url: "https://pub.dev" + source: hosted + version: "2.4.2+3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" stack_trace: dependency: transitive description: @@ -541,6 +765,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.12.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" term_glyph: dependency: transitive description: @@ -553,10 +785,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.9" typed_data: dependency: transitive description: @@ -629,6 +861,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 590c58f..eb3fb20 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,6 +36,11 @@ dependencies: cupertino_icons: ^1.0.8 provider: ^6.1.5+1 supabase_flutter: ^2.12.0 + image_picker: ^1.2.1 + image_cropper: ^11.0.0 + shimmer: ^3.0.0 + cached_network_image: ^3.4.1 + shared_preferences: ^2.5.4 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 785a046..5bbd4c3 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,11 +7,14 @@ #include "generated_plugin_registrant.h" #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 8f8ee4f..79001bc 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links + file_selector_windows url_launcher_windows )