esta melhor des que comecei

This commit is contained in:
Diogo
2026-03-12 00:57:01 +00:00
parent 5be578a64e
commit f5d7e88149
10 changed files with 1447 additions and 507 deletions

View File

@@ -4,25 +4,34 @@ import '../models/game_model.dart';
class GameController { class GameController {
final _supabase = Supabase.instance.client; final _supabase = Supabase.instance.client;
// 1. LER JOGOS (Stream em Tempo Real) // 1. LER JOGOS (Com Filtros Opcionais)
Stream<List<Game>> get gamesStream { Stream<List<Game>> getFilteredGames({String? teamFilter, String? seasonFilter}) {
return _supabase return _supabase
.from('games') // 1. Fica à escuta da tabela original (Garante o Tempo Real!) .from('games')
.stream(primaryKey: ['id']) .stream(primaryKey: ['id'])
.asyncMap((event) async { .asyncMap((event) async {
// 2. Sempre que a tabela 'games' mudar (novo jogo, alteração de resultado),
// vamos buscar os dados já misturados com as imagens à nossa View. // 👇 A CORREÇÃO ESTÁ AQUI: Lê diretamente da tabela 'games'
final viewData = await _supabase var query = _supabase.from('games').select();
.from('games_with_logos')
.select() // Aplica o filtro de Temporada
.order('game_date', ascending: false); if (seasonFilter != null && seasonFilter.isNotEmpty && seasonFilter != 'Todas') {
query = query.eq('season', seasonFilter);
}
// Aplica o filtro de Equipa (Procura em casa ou fora)
if (teamFilter != null && teamFilter.isNotEmpty && teamFilter != 'Todas') {
query = query.or('my_team.eq.$teamFilter,opponent_team.eq.$teamFilter');
}
// Executa a query com a ordenação por data
final viewData = await query.order('game_date', ascending: false);
// 3. Convertemos para a nossa lista de objetos Game
return viewData.map((json) => Game.fromMap(json)).toList(); return viewData.map((json) => Game.fromMap(json)).toList();
}); });
} }
// 2. CRIAR JOGO // 2. CRIAR JOGO
// Retorna o ID do jogo criado para podermos navegar para o placar
Future<String?> createGame(String myTeam, String opponent, String season) async { Future<String?> createGame(String myTeam, String opponent, String season) async {
try { try {
final response = await _supabase.from('games').insert({ final response = await _supabase.from('games').insert({
@@ -31,18 +40,16 @@ Stream<List<Game>> get gamesStream {
'season': season, 'season': season,
'my_score': 0, 'my_score': 0,
'opponent_score': 0, 'opponent_score': 0,
'status': 'Decorrer', // Começa como "Decorrer" 'status': 'Decorrer',
'game_date': DateTime.now().toIso8601String(), 'game_date': DateTime.now().toIso8601String(),
}).select().single(); // .select().single() retorna o objeto criado }).select().single();
return response['id']; // Retorna o UUID gerado pelo Supabase return response['id'];
} catch (e) { } catch (e) {
print("Erro ao criar jogo: $e"); print("Erro ao criar jogo: $e");
return null; return null;
} }
} }
void dispose() { void dispose() {}
// Não é necessário fechar streams do Supabase manualmente aqui
}
} }

View File

@@ -187,16 +187,16 @@ class PlacarController {
timer.cancel(); timer.cancel();
isRunning = false; isRunning = false;
if (currentQuarter < 4) { if (currentQuarter < 4) {
currentQuarter++; currentQuarter++;
duration = const Duration(minutes: 10); duration = const Duration(minutes: 10);
myFouls = 0; myFouls = 0;
opponentFouls = 0; opponentFouls = 0;
myTimeoutsUsed = 0; myTimeoutsUsed = 0;
opponentTimeoutsUsed = 0; opponentTimeoutsUsed = 0;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Período $currentQuarter iniciado. Faltas e Timeouts resetados!'), backgroundColor: Colors.blue)); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Período $currentQuarter iniciado. Faltas e Timeouts resetados!'), backgroundColor: Colors.blue));
} else { } else {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('FIM DO JOGO! Clica em Guardar para fechar a partida.'), backgroundColor: Colors.red)); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('FIM DO JOGO! Clica em Guardar para fechar a partida.'), backgroundColor: Colors.red));
} }
} }
onUpdate(); onUpdate();
}); });
@@ -355,7 +355,34 @@ class PlacarController {
bool isGameFinishedNow = currentQuarter >= 4 && duration.inSeconds == 0; bool isGameFinishedNow = currentQuarter >= 4 && duration.inSeconds == 0;
String newStatus = isGameFinishedNow ? 'Terminado' : 'Pausado'; String newStatus = isGameFinishedNow ? 'Terminado' : 'Pausado';
// 1. Atualizar o Jogo na BD // 👇👇👇 0. CÉREBRO: CALCULAR OS LÍDERES E MVP DO JOGO 👇👇👇
String topPtsName = '---'; int maxPts = -1;
String topAstName = '---'; int maxAst = -1;
String topRbsName = '---'; int maxRbs = -1;
String topDefName = '---'; int maxDef = -1;
String mvpName = '---'; int maxMvpScore = -1;
// Passa por todos os jogadores e calcula a matemática
playerStats.forEach((playerName, stats) {
int pts = stats['pts'] ?? 0;
int ast = stats['ast'] ?? 0;
int rbs = stats['rbs'] ?? 0;
int stl = stats['stl'] ?? 0;
int blk = stats['blk'] ?? 0;
int defScore = stl + blk; // Defesa: Roubos + Cortes
int mvpScore = pts + ast + rbs + defScore; // Impacto Total (MVP)
// Compara com o máximo atual e substitui se for maior
if (pts > maxPts && pts > 0) { maxPts = pts; topPtsName = '$playerName ($pts)'; }
if (ast > maxAst && ast > 0) { maxAst = ast; topAstName = '$playerName ($ast)'; }
if (rbs > maxRbs && rbs > 0) { maxRbs = rbs; topRbsName = '$playerName ($rbs)'; }
if (defScore > maxDef && defScore > 0) { maxDef = defScore; topDefName = '$playerName ($defScore)'; }
if (mvpScore > maxMvpScore && mvpScore > 0) { maxMvpScore = mvpScore; mvpName = playerName; } // MVP não leva nº à frente, fica mais limpo
});
// 👆👆👆 FIM DO CÉREBRO 👆👆👆
// 1. Atualizar o Jogo na BD (Agora inclui os Reis da partida!)
await supabase.from('games').update({ await supabase.from('games').update({
'my_score': myScore, 'my_score': myScore,
'opponent_score': opponentScore, 'opponent_score': opponentScore,
@@ -364,12 +391,18 @@ class PlacarController {
'opp_timeouts': opponentTimeoutsUsed, 'opp_timeouts': opponentTimeoutsUsed,
'current_quarter': currentQuarter, 'current_quarter': currentQuarter,
'status': newStatus, 'status': newStatus,
// ENVIA A MATEMÁTICA PARA A TUA BASE DE DADOS
'top_pts_name': topPtsName,
'top_ast_name': topAstName,
'top_rbs_name': topRbsName,
'top_def_name': topDefName,
'mvp_name': mvpName,
}).eq('id', gameId); }).eq('id', gameId);
// 👇 2. LÓGICA DE VITÓRIAS, DERROTAS E EMPATES 👇 // 2. LÓGICA DE VITÓRIAS, DERROTAS E EMPATES
if (isGameFinishedNow && !gameWasAlreadyFinished && myTeamDbId != null && oppTeamDbId != null) { if (isGameFinishedNow && !gameWasAlreadyFinished && myTeamDbId != null && oppTeamDbId != null) {
// Vai buscar os dados atuais das equipas
final teamsData = await supabase.from('teams').select('id, wins, losses, draws').inFilter('id', [myTeamDbId, oppTeamDbId]); final teamsData = await supabase.from('teams').select('id, wins, losses, draws').inFilter('id', [myTeamDbId, oppTeamDbId]);
Map<String, dynamic> myTeamUpdate = {}; Map<String, dynamic> myTeamUpdate = {};
@@ -380,7 +413,6 @@ class PlacarController {
if(t['id'].toString() == oppTeamDbId) oppTeamUpdate = Map.from(t); if(t['id'].toString() == oppTeamDbId) oppTeamUpdate = Map.from(t);
} }
// Calcula os resultados
if (myScore > opponentScore) { if (myScore > opponentScore) {
myTeamUpdate['wins'] = (myTeamUpdate['wins'] ?? 0) + 1; myTeamUpdate['wins'] = (myTeamUpdate['wins'] ?? 0) + 1;
oppTeamUpdate['losses'] = (oppTeamUpdate['losses'] ?? 0) + 1; oppTeamUpdate['losses'] = (oppTeamUpdate['losses'] ?? 0) + 1;
@@ -388,12 +420,10 @@ class PlacarController {
myTeamUpdate['losses'] = (myTeamUpdate['losses'] ?? 0) + 1; myTeamUpdate['losses'] = (myTeamUpdate['losses'] ?? 0) + 1;
oppTeamUpdate['wins'] = (oppTeamUpdate['wins'] ?? 0) + 1; oppTeamUpdate['wins'] = (oppTeamUpdate['wins'] ?? 0) + 1;
} else { } else {
// Empate
myTeamUpdate['draws'] = (myTeamUpdate['draws'] ?? 0) + 1; myTeamUpdate['draws'] = (myTeamUpdate['draws'] ?? 0) + 1;
oppTeamUpdate['draws'] = (oppTeamUpdate['draws'] ?? 0) + 1; oppTeamUpdate['draws'] = (oppTeamUpdate['draws'] ?? 0) + 1;
} }
// Envia as atualizações para a tabela 'teams'
await supabase.from('teams').update({ await supabase.from('teams').update({
'wins': myTeamUpdate['wins'], 'losses': myTeamUpdate['losses'], 'draws': myTeamUpdate['draws'] 'wins': myTeamUpdate['wins'], 'losses': myTeamUpdate['losses'], 'draws': myTeamUpdate['draws']
}).eq('id', myTeamDbId!); }).eq('id', myTeamDbId!);
@@ -402,7 +432,6 @@ class PlacarController {
'wins': oppTeamUpdate['wins'], 'losses': oppTeamUpdate['losses'], 'draws': oppTeamUpdate['draws'] 'wins': oppTeamUpdate['wins'], 'losses': oppTeamUpdate['losses'], 'draws': oppTeamUpdate['draws']
}).eq('id', oppTeamDbId!); }).eq('id', oppTeamDbId!);
// Bloqueia o trinco para não contar 2 vezes se o utilizador clicar "Guardar" outra vez
gameWasAlreadyFinished = true; gameWasAlreadyFinished = true;
} }

View File

@@ -5,7 +5,6 @@ class TeamController {
final _supabase = Supabase.instance.client; final _supabase = Supabase.instance.client;
// 1. STREAM (Realtime) // 1. STREAM (Realtime)
// Adicionei o .map() no final para garantir que o Dart entende que é uma List<Map>
Stream<List<Map<String, dynamic>>> get teamsStream { Stream<List<Map<String, dynamic>>> get teamsStream {
return _supabase return _supabase
.from('teams') .from('teams')
@@ -15,7 +14,6 @@ class TeamController {
} }
// 2. CRIAR // 2. CRIAR
// Alterei imageUrl para String? (pode ser nulo) para evitar erros se não houver imagem
Future<void> createTeam(String name, String season, String? imageUrl) async { Future<void> createTeam(String name, String season, String? imageUrl) async {
try { try {
await _supabase.from('teams').insert({ await _supabase.from('teams').insert({
@@ -51,21 +49,14 @@ class TeamController {
} }
} }
// 5. CONTAR JOGADORES // 5. CONTAR JOGADORES (AGORA EM TEMPO REAL COM STREAM!)
// CORRIGIDO: A sintaxe antiga dava erro. O método .count() é o correto agora. Stream<int> getPlayerCountStream(String teamId) {
Future<int> getPlayerCount(String teamId) async { return _supabase
try { .from('members')
final count = await _supabase .stream(primaryKey: ['id'])
.from('members') .eq('team_id', teamId)
.count() // Retorna diretamente o número inteiro .map((data) => data.length); // O tamanho da lista é o número de jogadores
.eq('team_id', teamId);
return count;
} catch (e) {
print("Erro ao contar jogadores: $e");
return 0;
}
} }
// Mantemos o dispose vazio para não quebrar a chamada na TeamsPage
void dispose() {} void dispose() {}
} }

View File

@@ -2,8 +2,6 @@ class Game {
final String id; final String id;
final String myTeam; final String myTeam;
final String opponentTeam; 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 opponentScore;
final String status; final String status;
@@ -13,26 +11,22 @@ class Game {
required this.id, required this.id,
required this.myTeam, required this.myTeam,
required this.opponentTeam, required this.opponentTeam,
this.myTeamLogo,
this.opponentTeamLogo,
required this.myScore, required this.myScore,
required this.opponentScore, required this.opponentScore,
required this.status, required this.status,
required this.season, required this.season,
}); });
// No seu factory, certifique-se de mapear os campos da tabela (ou de um JOIN)
factory Game.fromMap(Map<String, dynamic> map) { factory Game.fromMap(Map<String, dynamic> map) {
return Game( return Game(
id: map['id'], // O "?." converte para texto com segurança, e o "?? '...'" diz o que mostrar se for nulo (vazio)
myTeam: map['my_team_name'], id: map['id']?.toString() ?? '',
opponentTeam: map['opponent_team_name'], myTeam: map['my_team']?.toString() ?? 'Desconhecida',
myTeamLogo: map['my_team_logo'], // Certifique-se que o Supabase retorna isto opponentTeam: map['opponent_team']?.toString() ?? 'Adversário',
opponentTeamLogo: map['opponent_team_logo'], myScore: map['my_score']?.toString() ?? '0',
myScore: map['my_score'].toString(), opponentScore: map['opponent_score']?.toString() ?? '0',
opponentScore: map['opponent_score'].toString(), status: map['status']?.toString() ?? 'Terminado',
status: map['status'], season: map['season']?.toString() ?? 'Sem Época',
season: map['season'],
); );
} }
} }

View File

@@ -1,10 +1,230 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:playmaker/pages/PlacarPage.dart';
import '../controllers/game_controller.dart'; import '../controllers/game_controller.dart';
import '../controllers/team_controller.dart'; import '../controllers/team_controller.dart';
import '../models/game_model.dart'; import '../models/game_model.dart';
import '../widgets/game_widgets.dart'; import 'dart:math' as math;
import 'dart:math' as math; // <-- IMPORTANTE: Para o cálculo da escala
// --- CARD DE EXIBIÇÃO DO JOGO ---
class GameResultCard extends StatelessWidget {
final String gameId;
final String myTeam, opponentTeam, myScore, opponentScore, status, season;
final String? myTeamLogo;
final String? opponentTeamLogo;
final double sf;
const GameResultCard({
super.key,
required this.gameId,
required this.myTeam,
required this.opponentTeam,
required this.myScore,
required this.opponentScore,
required this.status,
required this.season,
this.myTeamLogo,
this.opponentTeamLogo,
required this.sf,
});
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.only(bottom: 16 * sf),
padding: EdgeInsets.all(16 * sf),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20 * sf),
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10 * sf)],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: _buildTeamInfo(myTeam, const Color(0xFFE74C3C), myTeamLogo, sf)),
_buildScoreCenter(context, gameId, sf),
Expanded(child: _buildTeamInfo(opponentTeam, Colors.black87, opponentTeamLogo, sf)),
],
),
);
}
Widget _buildTeamInfo(String name, Color color, String? logoUrl, double sf) {
return Column(
children: [
CircleAvatar(
radius: 24 * sf,
backgroundColor: color,
backgroundImage: (logoUrl != null && logoUrl.isNotEmpty) ? NetworkImage(logoUrl) : null,
child: (logoUrl == null || logoUrl.isEmpty) ? Icon(Icons.shield, color: Colors.white, size: 24 * sf) : null,
),
SizedBox(height: 6 * sf),
Text(name,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13 * sf),
textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, maxLines: 2,
),
],
);
}
Widget _buildScoreCenter(BuildContext context, String id, double sf) {
return Column(
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
_scoreBox(myScore, Colors.green, sf),
Text(" : ", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 22 * sf)),
_scoreBox(opponentScore, Colors.grey, sf),
],
),
SizedBox(height: 10 * sf),
TextButton.icon(
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (context) => PlacarPage(gameId: id, myTeam: myTeam, opponentTeam: opponentTeam)));
},
icon: Icon(Icons.play_circle_fill, size: 18 * sf, color: const Color(0xFFE74C3C)),
label: Text("RETORNAR", style: TextStyle(fontSize: 11 * sf, color: const Color(0xFFE74C3C), fontWeight: FontWeight.bold)),
style: TextButton.styleFrom(
backgroundColor: const Color(0xFFE74C3C).withOpacity(0.1),
padding: EdgeInsets.symmetric(horizontal: 14 * sf, vertical: 8 * sf),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20 * sf)),
visualDensity: VisualDensity.compact,
),
),
SizedBox(height: 6 * sf),
Text(status, style: TextStyle(fontSize: 12 * sf, color: Colors.blue, fontWeight: FontWeight.bold)),
],
);
}
Widget _scoreBox(String pts, Color c, double sf) => Container(
padding: EdgeInsets.symmetric(horizontal: 12 * sf, vertical: 6 * sf),
decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(8 * sf)),
child: Text(pts, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16 * sf)),
);
}
// --- POPUP DE CRIAÇÃO ---
class CreateGameDialogManual extends StatefulWidget {
final TeamController teamController;
final GameController gameController;
final double sf;
const CreateGameDialogManual({super.key, required this.teamController, required this.gameController, required this.sf});
@override
State<CreateGameDialogManual> createState() => _CreateGameDialogManualState();
}
class _CreateGameDialogManualState extends State<CreateGameDialogManual> {
late TextEditingController _seasonController;
final TextEditingController _myTeamController = TextEditingController();
final TextEditingController _opponentController = TextEditingController();
bool _isLoading = false;
@override
void initState() {
super.initState();
_seasonController = TextEditingController(text: _calculateSeason());
}
String _calculateSeason() {
final now = DateTime.now();
return now.month >= 7 ? "${now.year}/${(now.year + 1).toString().substring(2)}" : "${now.year - 1}/${now.year.toString().substring(2)}";
}
@override
Widget build(BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20 * widget.sf)),
title: Text('Configurar Partida', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18 * widget.sf)),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _seasonController, style: TextStyle(fontSize: 14 * widget.sf),
decoration: InputDecoration(labelText: 'Temporada', labelStyle: TextStyle(fontSize: 14 * widget.sf), border: const OutlineInputBorder(), prefixIcon: Icon(Icons.calendar_today, size: 20 * widget.sf)),
),
SizedBox(height: 15 * widget.sf),
_buildSearch(label: "Minha Equipa", controller: _myTeamController, sf: widget.sf),
Padding(padding: EdgeInsets.symmetric(vertical: 10 * widget.sf), child: Text("VS", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey, fontSize: 16 * widget.sf))),
_buildSearch(label: "Adversário", controller: _opponentController, sf: widget.sf),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text('CANCELAR', style: TextStyle(fontSize: 14 * widget.sf))),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFFE74C3C), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10 * widget.sf)), padding: EdgeInsets.symmetric(horizontal: 16 * widget.sf, vertical: 10 * widget.sf)),
onPressed: _isLoading ? null : () async {
if (_myTeamController.text.isNotEmpty && _opponentController.text.isNotEmpty) {
setState(() => _isLoading = true);
String? newGameId = await widget.gameController.createGame(_myTeamController.text, _opponentController.text, _seasonController.text);
setState(() => _isLoading = false);
if (newGameId != null && context.mounted) {
Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(builder: (context) => PlacarPage(gameId: newGameId, myTeam: _myTeamController.text, opponentTeam: _opponentController.text)));
}
}
},
child: _isLoading ? SizedBox(width: 20 * widget.sf, height: 20 * widget.sf, child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : Text('CRIAR', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14 * widget.sf)),
),
],
);
}
Widget _buildSearch({required String label, required TextEditingController controller, required double sf}) {
return StreamBuilder<List<Map<String, dynamic>>>(
stream: widget.teamController.teamsStream,
builder: (context, snapshot) {
List<Map<String, dynamic>> teamList = snapshot.hasData ? snapshot.data! : [];
return Autocomplete<Map<String, dynamic>>(
displayStringForOption: (Map<String, dynamic> option) => option['name'].toString(),
optionsBuilder: (TextEditingValue val) {
if (val.text.isEmpty) return const Iterable<Map<String, dynamic>>.empty();
return teamList.where((t) => t['name'].toString().toLowerCase().contains(val.text.toLowerCase()));
},
onSelected: (Map<String, dynamic> selection) { controller.text = selection['name'].toString(); },
optionsViewBuilder: (context, onSelected, options) {
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4.0, borderRadius: BorderRadius.circular(8 * sf),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: 250 * sf, maxWidth: MediaQuery.of(context).size.width * 0.7),
child: ListView.builder(
padding: EdgeInsets.zero, shrinkWrap: true, itemCount: options.length,
itemBuilder: (BuildContext context, int index) {
final option = options.elementAt(index);
final String name = option['name'].toString();
final String? imageUrl = option['image_url'];
return ListTile(
leading: CircleAvatar(radius: 20 * sf, backgroundColor: Colors.grey.shade200, backgroundImage: (imageUrl != null && imageUrl.isNotEmpty) ? NetworkImage(imageUrl) : null, child: (imageUrl == null || imageUrl.isEmpty) ? Icon(Icons.shield, color: Colors.grey, size: 20 * sf) : null),
title: Text(name, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14 * sf)),
onTap: () { onSelected(option); },
);
},
),
),
),
);
},
fieldViewBuilder: (ctx, txtCtrl, node, submit) {
if (txtCtrl.text.isEmpty && controller.text.isNotEmpty) { txtCtrl.text = controller.text; }
txtCtrl.addListener(() { controller.text = txtCtrl.text; });
return TextField(
controller: txtCtrl, focusNode: node, style: TextStyle(fontSize: 14 * sf),
decoration: InputDecoration(labelText: label, labelStyle: TextStyle(fontSize: 14 * sf), prefixIcon: Icon(Icons.search, size: 20 * sf), border: const OutlineInputBorder()),
);
},
);
},
);
}
}
// --- PÁGINA PRINCIPAL DOS JOGOS COM FILTROS ---
class GamePage extends StatefulWidget { class GamePage extends StatefulWidget {
const GamePage({super.key}); const GamePage({super.key});
@@ -16,40 +236,68 @@ class _GamePageState extends State<GamePage> {
final GameController gameController = GameController(); final GameController gameController = GameController();
final TeamController teamController = TeamController(); final TeamController teamController = TeamController();
// Variáveis para os filtros
String selectedSeason = 'Todas';
String selectedTeam = 'Todas';
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 👇 CÁLCULO DA ESCALA (sf) PARA SE ADAPTAR A QUALQUER ECRÃ 👇
final double wScreen = MediaQuery.of(context).size.width; final double wScreen = MediaQuery.of(context).size.width;
final double hScreen = MediaQuery.of(context).size.height; final double hScreen = MediaQuery.of(context).size.height;
final double sf = math.min(wScreen, hScreen) / 400; final double sf = math.min(wScreen, hScreen) / 400;
// Verifica se algum filtro está ativo para mudar a cor do ícone
bool isFilterActive = selectedSeason != 'Todas' || selectedTeam != 'Todas';
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF5F7FA), backgroundColor: const Color(0xFFF5F7FA),
appBar: AppBar( appBar: AppBar(
title: Text("Jogos", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20 * sf)), title: Text("Jogos", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20 * sf)),
backgroundColor: Colors.white, backgroundColor: Colors.white,
elevation: 0, elevation: 0,
actions: [
// 👇 BOTÃO DE FILTRO NA APP BAR 👇
Padding(
padding: EdgeInsets.only(right: 8.0 * sf),
child: IconButton(
icon: Icon(
isFilterActive ? Icons.filter_list_alt : Icons.filter_list,
color: isFilterActive ? const Color(0xFFE74C3C) : Colors.black87,
size: 26 * sf,
),
onPressed: () => _showFilterPopup(context, sf),
),
)
],
), ),
// 1º STREAM: Lemos as equipas para ter as imagens
body: StreamBuilder<List<Map<String, dynamic>>>( body: StreamBuilder<List<Map<String, dynamic>>>(
stream: teamController.teamsStream, stream: teamController.teamsStream,
builder: (context, teamSnapshot) { builder: (context, teamSnapshot) {
final List<Map<String, dynamic>> teamsList = teamSnapshot.data ?? []; final List<Map<String, dynamic>> teamsList = teamSnapshot.data ?? [];
// 2º STREAM: Lemos os jogos
return StreamBuilder<List<Game>>( return StreamBuilder<List<Game>>(
stream: gameController.gamesStream, stream: gameController.getFilteredGames(teamFilter: selectedTeam, seasonFilter: selectedSeason),
builder: (context, gameSnapshot) { builder: (context, gameSnapshot) {
if (gameSnapshot.connectionState == ConnectionState.waiting && teamsList.isEmpty) { if (gameSnapshot.connectionState == ConnectionState.waiting && teamsList.isEmpty) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (gameSnapshot.hasError) { if (gameSnapshot.hasError) {
return Center(child: Text("Erro: ${gameSnapshot.error}", style: TextStyle(fontSize: 14 * sf))); return Center(child: Text("Erro: ${gameSnapshot.error}", style: TextStyle(fontSize: 14 * sf)));
} }
if (!gameSnapshot.hasData || gameSnapshot.data!.isEmpty) { if (!gameSnapshot.hasData || gameSnapshot.data!.isEmpty) {
return Center(child: Text("Nenhum jogo registado.", style: TextStyle(fontSize: 16 * sf))); return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 48 * sf, color: Colors.grey.shade300),
SizedBox(height: 10 * sf),
Text("Nenhum jogo encontrado para este filtro.", style: TextStyle(fontSize: 14 * sf, color: Colors.grey.shade600)),
],
)
);
} }
return ListView.builder( return ListView.builder(
@@ -58,17 +306,12 @@ class _GamePageState extends State<GamePage> {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final game = gameSnapshot.data![index]; final game = gameSnapshot.data![index];
// --- LÓGICA PARA ENCONTRAR A IMAGEM PELO NOME ---
String? myLogo; String? myLogo;
String? oppLogo; String? oppLogo;
for (var team in teamsList) { for (var team in teamsList) {
if (team['name'] == game.myTeam) { if (team['name'] == game.myTeam) { myLogo = team['image_url']; }
myLogo = team['image_url']; if (team['name'] == game.opponentTeam) { oppLogo = team['image_url']; }
}
if (team['name'] == game.opponentTeam) {
oppLogo = team['image_url'];
}
} }
return GameResultCard( return GameResultCard(
@@ -81,7 +324,7 @@ class _GamePageState extends State<GamePage> {
season: game.season, season: game.season,
myTeamLogo: myLogo, myTeamLogo: myLogo,
opponentTeamLogo: oppLogo, opponentTeamLogo: oppLogo,
sf: sf, // <-- Passamos a escala para o Cartão sf: sf,
); );
}, },
); );
@@ -97,13 +340,135 @@ class _GamePageState extends State<GamePage> {
); );
} }
// 👇 O POPUP DE FILTROS 👇
void _showFilterPopup(BuildContext context, double sf) {
// Variáveis temporárias para o Popup (para não atualizar a lista antes de clicar em "Aplicar")
String tempSeason = selectedSeason;
String tempTeam = selectedTeam;
showDialog(
context: context,
builder: (context) {
// StatefulBuilder permite atualizar a interface APENAS dentro do Popup
return StatefulBuilder(
builder: (context, setPopupState) {
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20 * sf)),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Filtrar Jogos', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18 * sf)),
IconButton(
icon: const Icon(Icons.close, color: Colors.grey),
onPressed: () => Navigator.pop(context),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
)
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Filtro de Temporada
Text("Temporada", style: TextStyle(fontSize: 12 * sf, color: Colors.grey.shade600, fontWeight: FontWeight.bold)),
SizedBox(height: 6 * sf),
Container(
padding: EdgeInsets.symmetric(horizontal: 12 * sf),
decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(10 * sf)),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
isExpanded: true,
value: tempSeason,
style: TextStyle(fontSize: 14 * sf, color: Colors.black87, fontWeight: FontWeight.bold),
items: ['Todas', '2024/25', '2025/26'].map((String value) {
return DropdownMenuItem<String>(value: value, child: Text(value));
}).toList(),
onChanged: (newValue) {
setPopupState(() => tempSeason = newValue!);
},
),
),
),
SizedBox(height: 20 * sf),
// 2. Filtro de Equipa
Text("Equipa", style: TextStyle(fontSize: 12 * sf, color: Colors.grey.shade600, fontWeight: FontWeight.bold)),
SizedBox(height: 6 * sf),
Container(
padding: EdgeInsets.symmetric(horizontal: 12 * sf),
decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(10 * sf)),
child: StreamBuilder<List<Map<String, dynamic>>>(
stream: teamController.teamsStream,
builder: (context, snapshot) {
List<String> teamNames = ['Todas'];
if (snapshot.hasData) {
teamNames.addAll(snapshot.data!.map((t) => t['name'].toString()));
}
if (!teamNames.contains(tempTeam)) tempTeam = 'Todas';
return DropdownButtonHideUnderline(
child: DropdownButton<String>(
isExpanded: true,
value: tempTeam,
style: TextStyle(fontSize: 14 * sf, color: Colors.black87, fontWeight: FontWeight.bold),
items: teamNames.map((String value) {
return DropdownMenuItem<String>(value: value, child: Text(value, overflow: TextOverflow.ellipsis));
}).toList(),
onChanged: (newValue) {
setPopupState(() => tempTeam = newValue!);
},
),
);
}
),
),
],
),
actions: [
TextButton(
onPressed: () {
// Limpar Filtros
setState(() {
selectedSeason = 'Todas';
selectedTeam = 'Todas';
});
Navigator.pop(context);
},
child: Text('LIMPAR', style: TextStyle(fontSize: 12 * sf, color: Colors.grey))
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFE74C3C),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10 * sf)),
),
onPressed: () {
// Aplicar Filtros (atualiza a página principal)
setState(() {
selectedSeason = tempSeason;
selectedTeam = tempTeam;
});
Navigator.pop(context);
},
child: Text('APLICAR', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13 * sf)),
),
],
);
}
);
},
);
}
void _showCreateDialog(BuildContext context, double sf) { void _showCreateDialog(BuildContext context, double sf) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => CreateGameDialogManual( builder: (context) => CreateGameDialogManual(
teamController: teamController, teamController: teamController,
gameController: gameController, gameController: gameController,
sf: sf, // <-- Passamos a escala para o Pop-up sf: sf,
), ),
); );
} }

View File

@@ -1,448 +1,473 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:playmaker/classe/home.config.dart'; import 'package:playmaker/classe/home.config.dart';
import 'package:playmaker/grafico%20de%20pizza/grafico.dart'; import 'package:playmaker/grafico%20de%20pizza/grafico.dart';
import 'package:playmaker/pages/gamePage.dart'; import 'package:playmaker/pages/gamePage.dart';
import 'package:playmaker/pages/teamPage.dart'; import 'package:playmaker/pages/teamPage.dart';
import 'package:playmaker/controllers/team_controller.dart'; import 'package:playmaker/controllers/team_controller.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'dart:math' as math; import 'package:playmaker/pages/status_page.dart';
import 'dart:math' as math;
import 'package:playmaker/grafico%20de%20pizza/controllers/contollers_grafico.dart'; import 'package:playmaker/grafico%20de%20pizza/controllers/contollers_grafico.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@override @override
State<HomeScreen> createState() => _HomeScreenState(); State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
int _selectedIndex = 0;
final TeamController _teamController = TeamController();
String? _selectedTeamId;
String _selectedTeamName = "Selecionar Equipa";
int _teamWins = 0;
int _teamLosses = 0;
int _teamDraws = 0;
final _supabase = Supabase.instance.client;
@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, hScreen) / 400;
final List<Widget> pages = [
_buildHomeContent(sf, wScreen),
const GamePage(),
const TeamsPage(),
const StatusPage(),
];
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: Text('PlayMaker', style: TextStyle(fontSize: 20 * sf)),
backgroundColor: HomeConfig.primaryColor,
foregroundColor: Colors.white,
leading: IconButton(
icon: Icon(Icons.person, size: 24 * sf),
onPressed: () {},
),
),
body: IndexedStack(
index: _selectedIndex,
children: pages,
),
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: (index) => setState(() => _selectedIndex = index),
backgroundColor: Theme.of(context).colorScheme.surface,
surfaceTintColor: Theme.of(context).colorScheme.surfaceTint,
elevation: 1,
height: 70 * math.min(sf, 1.2),
destinations: const [
NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home_filled), label: 'Home'),
NavigationDestination(icon: Icon(Icons.sports_soccer_outlined), selectedIcon: Icon(Icons.sports_soccer), label: 'Jogo'),
NavigationDestination(icon: Icon(Icons.people_outline), selectedIcon: Icon(Icons.people), label: 'Equipas'),
NavigationDestination(icon: Icon(Icons.insights_outlined), selectedIcon: Icon(Icons.insights), label: 'Status'),
],
),
);
} }
class _HomeScreenState extends State<HomeScreen> { void _showTeamSelector(BuildContext context, double sf) {
int _selectedIndex = 0; showModalBottomSheet(
final TeamController _teamController = TeamController(); context: context,
String? _selectedTeamId; shape: RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20 * sf))),
String _selectedTeamName = "Selecionar Equipa"; builder: (context) {
return StreamBuilder<List<Map<String, dynamic>>>(
stream: _teamController.teamsStream,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) return const SizedBox(height: 200, child: Center(child: CircularProgressIndicator()));
if (!snapshot.hasData || snapshot.data!.isEmpty) return SizedBox(height: 200 * sf, child: const Center(child: Text("Nenhuma equipa criada.")));
int _teamWins = 0; final teams = snapshot.data!;
int _teamLosses = 0; return ListView.builder(
int _teamDraws = 0; shrinkWrap: true,
itemCount: teams.length,
itemBuilder: (context, index) {
final team = teams[index];
return ListTile(
title: Text(team['name']),
onTap: () {
setState(() {
_selectedTeamId = team['id'];
_selectedTeamName = team['name'];
_teamWins = team['wins'] != null ? int.tryParse(team['wins'].toString()) ?? 0 : 0;
_teamLosses = team['losses'] != null ? int.tryParse(team['losses'].toString()) ?? 0 : 0;
_teamDraws = team['draws'] != null ? int.tryParse(team['draws'].toString()) ?? 0 : 0;
});
Navigator.pop(context);
},
);
},
);
},
);
},
);
}
final _supabase = Supabase.instance.client; Widget _buildHomeContent(double sf, double wScreen) {
final double cardHeight = (wScreen / 2) * 1.0;
@override return StreamBuilder<List<Map<String, dynamic>>>(
Widget build(BuildContext context) { stream: _selectedTeamId != null
final double wScreen = MediaQuery.of(context).size.width; ? _supabase.from('player_stats_with_names').stream(primaryKey: ['id']).eq('team_id', _selectedTeamId!)
final double hScreen = MediaQuery.of(context).size.height; : const Stream.empty(),
final double sf = math.min(wScreen, hScreen) / 400; builder: (context, snapshot) {
Map<String, dynamic> leaders = _calculateLeaders(snapshot.data ?? []);
final List<Widget> pages = [ return SingleChildScrollView(
_buildHomeContent(sf, wScreen), child: Padding(
const GamePage(), padding: EdgeInsets.symmetric(horizontal: 22.0 * sf, vertical: 16.0 * sf),
const TeamsPage(), child: Column(
const Center(child: Text('Tela de Status')), crossAxisAlignment: CrossAxisAlignment.start,
]; children: [
InkWell(
onTap: () => _showTeamSelector(context, sf),
child: Container(
padding: EdgeInsets.all(12 * sf),
decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(15 * sf), border: Border.all(color: Colors.grey.shade300)),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(children: [Icon(Icons.shield, color: HomeConfig.primaryColor, size: 24 * sf), SizedBox(width: 10 * sf), Text(_selectedTeamName, style: TextStyle(fontSize: 16 * sf, fontWeight: FontWeight.bold))]),
const Icon(Icons.arrow_drop_down),
],
),
),
),
SizedBox(height: 20 * sf),
return Scaffold( SizedBox(
backgroundColor: Colors.white, height: cardHeight,
appBar: AppBar( child: Row(
title: Text('PlayMaker', style: TextStyle(fontSize: 20 * sf)), children: [
backgroundColor: HomeConfig.primaryColor, Expanded(child: _buildStatCard(title: 'Mais Pontos', playerName: leaders['pts_name'], statValue: leaders['pts_val'].toString(), statLabel: 'TOTAL', color: const Color(0xFF1565C0), isHighlighted: true)),
foregroundColor: Colors.white, SizedBox(width: 12 * sf),
leading: IconButton( Expanded(child: _buildStatCard(title: 'Assistências', playerName: leaders['ast_name'], statValue: leaders['ast_val'].toString(), statLabel: 'TOTAL', color: const Color(0xFF2E7D32))),
icon: Icon(Icons.person, size: 24 * sf), ],
onPressed: () {}, ),
),
SizedBox(height: 12 * sf),
SizedBox(
height: cardHeight,
child: Row(
children: [
Expanded(child: _buildStatCard(title: 'Rebotes', playerName: leaders['rbs_name'], statValue: leaders['rbs_val'].toString(), statLabel: 'TOTAL', color: const Color(0xFF6A1B9A))),
SizedBox(width: 12 * sf),
Expanded(
child: PieChartCard(
victories: _teamWins,
defeats: _teamLosses,
draws: _teamDraws,
title: 'DESEMPENHO',
subtitle: 'Temporada',
backgroundColor: const Color(0xFFC62828),
sf: sf
),
),
],
),
),
SizedBox(height: 40 * sf),
Text('Histórico de Jogos', style: TextStyle(fontSize: 20 * sf, fontWeight: FontWeight.bold, color: Colors.grey[800])),
SizedBox(height: 16 * sf),
// 👇 HISTÓRICO LIGADO À BASE DE DADOS (COM AS COLUNAS CORRIGIDAS) 👇
// 👇 LIGAÇÃO CORRIGIDA: Agora usa a coluna 'nome' como pediste 👇
// 👇 HISTÓRICO DINÂMICO (Qualquer equipa) 👇
_selectedTeamName == "Selecionar Equipa"
? Container(
padding: EdgeInsets.all(20 * sf),
alignment: Alignment.center,
child: Text("Seleciona uma equipa no topo.", style: TextStyle(color: Colors.grey, fontSize: 14 * sf)),
)
: StreamBuilder<List<Map<String, dynamic>>>(
// Pede os jogos ordenados pela data (sem filtros rígidos aqui)
stream: _supabase.from('games').stream(primaryKey: ['id'])
.order('game_date', ascending: false),
builder: (context, gameSnapshot) {
if (gameSnapshot.hasError) {
return Text("Erro ao carregar jogos: ${gameSnapshot.error}", style: const TextStyle(color: Colors.red));
}
if (gameSnapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
// 👇 O CÉREBRO DA APP: Filtro inteligente no Flutter 👇
final todosOsJogos = gameSnapshot.data ?? [];
final gamesList = todosOsJogos.where((game) {
String myT = game['my_team']?.toString() ?? '';
String oppT = game['opponent_team']?.toString() ?? '';
String status = game['status']?.toString() ?? '';
// O jogo tem de envolver a equipa selecionada E estar Terminado
bool isPlaying = (myT == _selectedTeamName || oppT == _selectedTeamName);
bool isFinished = status == 'Terminado';
return isPlaying && isFinished;
}).take(3).toList(); // Pega apenas nos 3 mais recentes
if (gamesList.isEmpty) {
return Container(
padding: EdgeInsets.all(20 * sf),
decoration: BoxDecoration(color: Colors.grey.shade50, borderRadius: BorderRadius.circular(14)),
alignment: Alignment.center,
child: Text("Ainda não há jogos terminados para $_selectedTeamName.", style: TextStyle(color: Colors.grey)),
);
}
return Column(
children: gamesList.map((game) {
// Lê os dados brutos da base de dados
String dbMyTeam = game['my_team']?.toString() ?? '';
String dbOppTeam = game['opponent_team']?.toString() ?? '';
int dbMyScore = int.tryParse(game['my_score'].toString()) ?? 0;
int dbOppScore = int.tryParse(game['opponent_score'].toString()) ?? 0;
String opponent;
int myScore;
int oppScore;
// 🔄 MAGIA DA INVERSÃO DE RESULTADOS 🔄
// Garante que os pontos da equipa selecionada aparecem sempre do lado esquerdo
if (dbMyTeam == _selectedTeamName) {
// A equipa que escolhemos está guardada no 'my_team'
opponent = dbOppTeam;
myScore = dbMyScore;
oppScore = dbOppScore;
} else {
// A equipa que escolhemos está guardada no 'opponent_team'
opponent = dbMyTeam;
myScore = dbOppScore;
oppScore = dbMyScore;
}
// Limpa a data (Remove as horas e deixa só YYYY-MM-DD)
String rawDate = game['game_date']?.toString() ?? '---';
String date = rawDate.length >= 10 ? rawDate.substring(0, 10) : rawDate;
// Calcula Vitória, Empate ou Derrota para a equipa selecionada
String result = 'E';
if (myScore > oppScore) result = 'V';
if (myScore < oppScore) result = 'D';
return _buildGameHistoryCard(
opponent: opponent,
result: result,
myScore: myScore,
oppScore: oppScore,
date: date,
sf: sf,
topPts: game['top_pts_name'] ?? '---',
topAst: game['top_ast_name'] ?? '---',
topRbs: game['top_rbs_name'] ?? '---',
topDef: game['top_def_name'] ?? '---',
mvp: game['mvp_name'] ?? '---',
);
}).toList(),
);
},
),
SizedBox(height: 20 * sf),
],
),
), ),
), );
},
body: IndexedStack( );
index: _selectedIndex, }
children: pages,
),
bottomNavigationBar: NavigationBar( Map<String, dynamic> _calculateLeaders(List<Map<String, dynamic>> data) {
selectedIndex: _selectedIndex, Map<String, int> ptsMap = {}; Map<String, int> astMap = {}; Map<String, int> rbsMap = {}; Map<String, String> namesMap = {};
onDestinationSelected: (index) => setState(() => _selectedIndex = index), for (var row in data) {
backgroundColor: Theme.of(context).colorScheme.surface, String pid = row['member_id'].toString();
surfaceTintColor: Theme.of(context).colorScheme.surfaceTint, namesMap[pid] = row['player_name']?.toString() ?? "Desconhecido";
elevation: 1, ptsMap[pid] = (ptsMap[pid] ?? 0) + (row['pts'] as int? ?? 0);
height: 70 * math.min(sf, 1.2), astMap[pid] = (astMap[pid] ?? 0) + (row['ast'] as int? ?? 0);
destinations: const [ rbsMap[pid] = (rbsMap[pid] ?? 0) + (row['rbs'] as int? ?? 0);
NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home_filled), label: 'Home'),
NavigationDestination(icon: Icon(Icons.sports_soccer_outlined), selectedIcon: Icon(Icons.sports_soccer), label: 'Jogo'),
NavigationDestination(icon: Icon(Icons.people_outline), selectedIcon: Icon(Icons.people), label: 'Equipas'),
NavigationDestination(icon: Icon(Icons.insights_outlined), selectedIcon: Icon(Icons.insights), label: 'Status'),
],
),
);
} }
if (ptsMap.isEmpty) return {'pts_name': '---', 'pts_val': 0, 'ast_name': '---', 'ast_val': 0, 'rbs_name': '---', 'rbs_val': 0};
String getBest(Map<String, int> map) { var bestId = map.entries.reduce((a, b) => a.value > b.value ? a : b).key; return namesMap[bestId]!; }
int getBestVal(Map<String, int> map) => map.values.reduce((a, b) => a > b ? a : b);
return {'pts_name': getBest(ptsMap), 'pts_val': getBestVal(ptsMap), 'ast_name': getBest(astMap), 'ast_val': getBestVal(astMap), 'rbs_name': getBest(rbsMap), 'rbs_val': getBestVal(rbsMap)};
}
void _showTeamSelector(BuildContext context, double sf) { Widget _buildStatCard({required String title, required String playerName, required String statValue, required String statLabel, required Color color, bool isHighlighted = false}) {
showModalBottomSheet( return Card(
context: context, elevation: 4, margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20 * sf))), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14), side: isHighlighted ? const BorderSide(color: Colors.amber, width: 2) : BorderSide.none),
builder: (context) { child: Container(
return StreamBuilder<List<Map<String, dynamic>>>( decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), gradient: LinearGradient(begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [color.withOpacity(0.9), color])),
stream: _teamController.teamsStream, child: LayoutBuilder(
builder: (context, snapshot) { builder: (context, constraints) {
if (snapshot.connectionState == ConnectionState.waiting) return const SizedBox(height: 200, child: Center(child: CircularProgressIndicator())); final double ch = constraints.maxHeight;
if (!snapshot.hasData || snapshot.data!.isEmpty) return SizedBox(height: 200 * sf, child: const Center(child: Text("Nenhuma equipa criada."))); final double cw = constraints.maxWidth;
final teams = snapshot.data!; return Padding(
return ListView.builder( padding: EdgeInsets.all(cw * 0.06),
shrinkWrap: true,
itemCount: teams.length,
itemBuilder: (context, index) {
final team = teams[index];
return ListTile(
title: Text(team['name']),
onTap: () {
setState(() {
_selectedTeamId = team['id'];
_selectedTeamName = team['name'];
_teamWins = team['wins'] != null ? int.tryParse(team['wins'].toString()) ?? 0 : 0;
_teamLosses = team['losses'] != null ? int.tryParse(team['losses'].toString()) ?? 0 : 0;
_teamDraws = team['draws'] != null ? int.tryParse(team['draws'].toString()) ?? 0 : 0;
});
Navigator.pop(context);
},
);
},
);
},
);
},
);
}
Widget _buildHomeContent(double sf, double wScreen) {
final double cardHeight = (wScreen / 2) * 1.0;
return StreamBuilder<List<Map<String, dynamic>>>(
stream: _selectedTeamId != null
? _supabase.from('player_stats_with_names').stream(primaryKey: ['id']).eq('team_id', _selectedTeamId!)
: const Stream.empty(),
builder: (context, snapshot) {
Map<String, dynamic> leaders = _calculateLeaders(snapshot.data ?? []);
return SingleChildScrollView(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 22.0 * sf, vertical: 16.0 * sf),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
InkWell( Text(title.toUpperCase(), style: TextStyle(fontSize: ch * 0.06, fontWeight: FontWeight.bold, color: Colors.white70), maxLines: 1, overflow: TextOverflow.ellipsis),
onTap: () => _showTeamSelector(context, sf), SizedBox(height: ch * 0.011),
child: Container( SizedBox(
padding: EdgeInsets.all(12 * sf), width: double.infinity,
decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(15 * sf), border: Border.all(color: Colors.grey.shade300)), child: FittedBox(
child: Row( fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(playerName, style: TextStyle(fontSize: ch * 0.08, fontWeight: FontWeight.bold, color: Colors.white)),
),
),
const Spacer(),
Center(child: FittedBox(fit: BoxFit.scaleDown, child: Text(statValue, style: TextStyle(fontSize: ch * 0.18, fontWeight: FontWeight.bold, color: Colors.white, height: 1.0)))),
SizedBox(height: ch * 0.015),
Center(child: Text(statLabel, style: TextStyle(fontSize: ch * 0.05, color: Colors.white70))),
const Spacer(),
Container(
width: double.infinity,
padding: EdgeInsets.symmetric(vertical: ch * 0.035),
decoration: BoxDecoration(color: Colors.white24, borderRadius: BorderRadius.circular(ch * 0.03)),
child: Center(child: Text('DETALHES', style: TextStyle(color: Colors.white, fontSize: ch * 0.05, fontWeight: FontWeight.bold)))
),
],
),
);
}
),
),
);
}
Widget _buildGameHistoryCard({
required String opponent, required String result, required int myScore, required int oppScore, required String date, required double sf,
required String topPts, required String topAst, required String topRbs, required String topDef, required String mvp
}) {
bool isWin = result == 'V';
bool isDraw = result == 'E';
Color statusColor = isWin ? Colors.green : (isDraw ? Colors.yellow.shade700 : Colors.red);
return Container(
margin: EdgeInsets.only(bottom: 14 * sf),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade200),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 8, offset: const Offset(0, 4))],
),
child: Column(
children: [
Padding(
padding: EdgeInsets.all(14 * sf),
child: Row(
children: [
Container(
width: 36 * sf, height: 36 * sf,
decoration: BoxDecoration(color: statusColor.withOpacity(0.15), shape: BoxShape.circle),
child: Center(child: Text(result, style: TextStyle(color: statusColor, fontWeight: FontWeight.bold, fontSize: 16 * sf))),
),
SizedBox(width: 14 * sf),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(date, style: TextStyle(fontSize: 11 * sf, color: Colors.grey, fontWeight: FontWeight.w600)),
SizedBox(height: 6 * sf),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Row(children: [Icon(Icons.shield, color: HomeConfig.primaryColor, size: 24 * sf), SizedBox(width: 10 * sf), Text(_selectedTeamName, style: TextStyle(fontSize: 16 * sf, fontWeight: FontWeight.bold))]), Expanded(child: Text(_selectedTeamName == "Selecionar Equipa" ? "Minha Equipa" : _selectedTeamName, style: TextStyle(fontSize: 14 * sf, fontWeight: FontWeight.bold), maxLines: 1, overflow: TextOverflow.ellipsis)),
const Icon(Icons.arrow_drop_down), Padding(
padding: EdgeInsets.symmetric(horizontal: 8 * sf),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 8 * sf, vertical: 4 * sf),
decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(8)),
child: Text('$myScore - $oppScore', style: TextStyle(fontSize: 15 * sf, fontWeight: FontWeight.w900, letterSpacing: 1.5, color: Colors.black87)),
),
),
Expanded(child: Text(opponent, style: TextStyle(fontSize: 14 * sf, fontWeight: FontWeight.bold), textAlign: TextAlign.right, maxLines: 1, overflow: TextOverflow.ellipsis)),
], ],
), ),
), ],
), ),
SizedBox(height: 20 * sf), ),
],
SizedBox(
height: cardHeight,
child: Row(
children: [
Expanded(child: _buildStatCard(title: 'Mais Pontos', playerName: leaders['pts_name'], statValue: leaders['pts_val'].toString(), statLabel: 'TOTAL', color: const Color(0xFF1565C0), isHighlighted: true)),
SizedBox(width: 12 * sf),
Expanded(child: _buildStatCard(title: 'Assistências', playerName: leaders['ast_name'], statValue: leaders['ast_val'].toString(), statLabel: 'TOTAL', color: const Color(0xFF2E7D32))),
],
),
),
SizedBox(height: 12 * sf),
SizedBox(
height: cardHeight,
child: Row(
children: [
Expanded(child: _buildStatCard(title: 'Rebotes', playerName: leaders['rbs_name'], statValue: leaders['rbs_val'].toString(), statLabel: 'TOTAL', color: const Color(0xFF6A1B9A))),
SizedBox(width: 12 * sf),
Expanded(
child: PieChartCard(
victories: _teamWins,
defeats: _teamLosses,
draws: _teamDraws,
title: 'DESEMPENHO',
subtitle: 'Temporada',
backgroundColor: const Color(0xFFC62828),
sf: sf
),
),
],
),
),
SizedBox(height: 40 * sf),
Text('Histórico de Jogos', style: TextStyle(fontSize: 20 * sf, fontWeight: FontWeight.bold, color: Colors.grey[800])),
SizedBox(height: 16 * sf),
// 👇 MAGIA ACONTECE AQUI: Ligação à Base de Dados para os Jogos 👇
_selectedTeamId == null
? Container(
padding: EdgeInsets.all(20 * sf),
alignment: Alignment.center,
child: Text("Seleciona uma equipa para ver os jogos.", style: TextStyle(color: Colors.grey, fontSize: 14 * sf)),
)
: StreamBuilder<List<Map<String, dynamic>>>(
// ⚠️ ATENÇÃO: Substitui 'games' pelo nome real da tua tabela de jogos na Supabase
stream: _supabase.from('games').stream(primaryKey: ['id'])
.eq('team_id', _selectedTeamId!)
// ⚠️ ATENÇÃO: Substitui 'date' pelo nome da coluna da data do jogo
.order('date', ascending: false)
.limit(3), // Mostra só os 3 últimos jogos
builder: (context, gameSnapshot) {
if (gameSnapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (!gameSnapshot.hasData || gameSnapshot.data!.isEmpty) {
return Container(
padding: EdgeInsets.all(20 * sf),
decoration: BoxDecoration(color: Colors.grey.shade50, borderRadius: BorderRadius.circular(14)),
alignment: Alignment.center,
child: Column(
children: [
Icon(Icons.sports_basketball, size: 40 * sf, color: Colors.grey.shade300),
SizedBox(height: 10 * sf),
Text("Ainda não há jogos registados.", style: TextStyle(color: Colors.grey.shade600, fontSize: 14 * sf, fontWeight: FontWeight.bold)),
],
),
);
}
final gamesList = gameSnapshot.data!;
return Column(
children: gamesList.map((game) {
// ⚠️ ATENÇÃO: Confirma se os nomes entre parênteses retos [ ]
// batem certo com as tuas colunas na tabela Supabase!
String opponent = game['opponent_name']?.toString() ?? 'Adversário';
int myScore = game['my_score'] != null ? int.tryParse(game['my_score'].toString()) ?? 0 : 0;
int oppScore = game['opponent_score'] != null ? int.tryParse(game['opponent_score'].toString()) ?? 0 : 0;
String date = game['date']?.toString() ?? 'Sem Data';
// Calcula Vitória (V), Derrota (D) ou Empate (E) automaticamente
String result = 'E';
if (myScore > oppScore) result = 'V';
if (myScore < oppScore) result = 'D';
// ⚠️ Destaques da Partida. Se ainda não tiveres estas colunas na tabela 'games',
// podes deixar assim e ele mostra '---' sem dar erro.
String topPts = game['top_pts']?.toString() ?? '---';
String topAst = game['top_ast']?.toString() ?? '---';
String topRbs = game['top_rbs']?.toString() ?? '---';
String topDef = game['top_def']?.toString() ?? '---';
String mvp = game['mvp']?.toString() ?? '---';
return _buildGameHistoryCard(
opponent: opponent,
result: result,
myScore: myScore,
oppScore: oppScore,
date: date,
sf: sf,
topPts: topPts,
topAst: topAst,
topRbs: topRbs,
topDef: topDef,
mvp: mvp
);
}).toList(),
);
},
),
SizedBox(height: 20 * sf),
],
),
), ),
); ),
},
); Divider(height: 1, color: Colors.grey.shade100, thickness: 1.5),
}
Map<String, dynamic> _calculateLeaders(List<Map<String, dynamic>> data) { Container(
Map<String, int> ptsMap = {}; Map<String, int> astMap = {}; Map<String, int> rbsMap = {}; Map<String, String> namesMap = {}; width: double.infinity,
for (var row in data) { padding: EdgeInsets.symmetric(horizontal: 16 * sf, vertical: 12 * sf),
String pid = row['member_id'].toString(); decoration: BoxDecoration(
namesMap[pid] = row['player_name']?.toString() ?? "Desconhecido"; color: Colors.grey.shade50,
ptsMap[pid] = (ptsMap[pid] ?? 0) + (row['pts'] as int? ?? 0); borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(16), bottomRight: Radius.circular(16)),
astMap[pid] = (astMap[pid] ?? 0) + (row['ast'] as int? ?? 0); ),
rbsMap[pid] = (rbsMap[pid] ?? 0) + (row['rbs'] as int? ?? 0); child: Column(
} children: [
if (ptsMap.isEmpty) return {'pts_name': '---', 'pts_val': 0, 'ast_name': '---', 'ast_val': 0, 'rbs_name': '---', 'rbs_val': 0}; Row(
String getBest(Map<String, int> map) { var bestId = map.entries.reduce((a, b) => a.value > b.value ? a : b).key; return namesMap[bestId]!; }
int getBestVal(Map<String, int> map) => map.values.reduce((a, b) => a > b ? a : b);
return {'pts_name': getBest(ptsMap), 'pts_val': getBestVal(ptsMap), 'ast_name': getBest(astMap), 'ast_val': getBestVal(astMap), 'rbs_name': getBest(rbsMap), 'rbs_val': getBestVal(rbsMap)};
}
Widget _buildStatCard({required String title, required String playerName, required String statValue, required String statLabel, required Color color, bool isHighlighted = false}) {
return Card(
elevation: 4, margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14), side: isHighlighted ? const BorderSide(color: Colors.amber, width: 2) : BorderSide.none),
child: Container(
decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), gradient: LinearGradient(begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [color.withOpacity(0.9), color])),
child: LayoutBuilder(
builder: (context, constraints) {
final double ch = constraints.maxHeight;
final double cw = constraints.maxWidth;
return Padding(
padding: EdgeInsets.all(cw * 0.06),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(title.toUpperCase(), style: TextStyle(fontSize: ch * 0.06, fontWeight: FontWeight.bold, color: Colors.white70), maxLines: 1, overflow: TextOverflow.ellipsis), Expanded(child: _buildGridStatRow(Icons.workspace_premium, Colors.amber.shade700, "MVP", mvp, sf, isMvp: true)),
SizedBox(height: ch * 0.011), Expanded(child: _buildGridStatRow(Icons.shield, Colors.deepOrange.shade700, "Defesa", topDef, sf)),
SizedBox(
width: double.infinity,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(playerName, style: TextStyle(fontSize: ch * 0.08, fontWeight: FontWeight.bold, color: Colors.white)),
),
),
const Spacer(),
Center(child: FittedBox(fit: BoxFit.scaleDown, child: Text(statValue, style: TextStyle(fontSize: ch * 0.18, fontWeight: FontWeight.bold, color: Colors.white, height: 1.0)))),
SizedBox(height: ch * 0.015),
Center(child: Text(statLabel, style: TextStyle(fontSize: ch * 0.05, color: Colors.white70))),
const Spacer(),
Container(
width: double.infinity,
padding: EdgeInsets.symmetric(vertical: ch * 0.035),
decoration: BoxDecoration(color: Colors.white24, borderRadius: BorderRadius.circular(ch * 0.03)),
child: Center(child: Text('DETALHES', style: TextStyle(color: Colors.white, fontSize: ch * 0.05, fontWeight: FontWeight.bold)))
),
], ],
), ),
); SizedBox(height: 8 * sf),
} Row(
), children: [
), Expanded(child: _buildGridStatRow(Icons.bolt, Colors.blue.shade700, "Pontos", topPts, sf)),
); Expanded(child: _buildGridStatRow(Icons.trending_up, Colors.purple.shade700, "Rebotes", topRbs, sf)),
} ],
),
Widget _buildGameHistoryCard({ SizedBox(height: 8 * sf),
required String opponent, required String result, required int myScore, required int oppScore, required String date, required double sf, Row(
required String topPts, required String topAst, required String topRbs, required String topDef, required String mvp children: [
}) { Expanded(child: _buildGridStatRow(Icons.star, Colors.green.shade700, "Assists", topAst, sf)),
bool isWin = result == 'V'; const Expanded(child: SizedBox()),
bool isDraw = result == 'E'; ],
Color statusColor = isWin ? Colors.green : (isDraw ? Colors.yellow.shade700 : Colors.red); ),
],
return Container(
margin: EdgeInsets.only(bottom: 14 * sf),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade200),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 8, offset: const Offset(0, 4))],
),
child: Column(
children: [
Padding(
padding: EdgeInsets.all(14 * sf),
child: Row(
children: [
Container(
width: 36 * sf, height: 36 * sf,
decoration: BoxDecoration(color: statusColor.withOpacity(0.15), shape: BoxShape.circle),
child: Center(child: Text(result, style: TextStyle(color: statusColor, fontWeight: FontWeight.bold, fontSize: 16 * sf))),
),
SizedBox(width: 14 * sf),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(date, style: TextStyle(fontSize: 11 * sf, color: Colors.grey, fontWeight: FontWeight.w600)),
SizedBox(height: 6 * sf),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: Text(_selectedTeamName == "Selecionar Equipa" ? "Minha Equipa" : _selectedTeamName, style: TextStyle(fontSize: 14 * sf, fontWeight: FontWeight.bold), maxLines: 1, overflow: TextOverflow.ellipsis)),
Padding(
padding: EdgeInsets.symmetric(horizontal: 8 * sf),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 8 * sf, vertical: 4 * sf),
decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(8)),
child: Text('$myScore - $oppScore', style: TextStyle(fontSize: 15 * sf, fontWeight: FontWeight.w900, letterSpacing: 1.5, color: Colors.black87)),
),
),
Expanded(child: Text(opponent, style: TextStyle(fontSize: 14 * sf, fontWeight: FontWeight.bold), textAlign: TextAlign.right, maxLines: 1, overflow: TextOverflow.ellipsis)),
],
),
],
),
),
],
),
), ),
)
Divider(height: 1, color: Colors.grey.shade100, thickness: 1.5),
Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 16 * sf, vertical: 12 * sf),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(16), bottomRight: Radius.circular(16)),
),
child: Column(
children: [
Row(
children: [
Expanded(child: _buildGridStatRow(Icons.workspace_premium, Colors.amber.shade700, "MVP", mvp, sf, isMvp: true)),
Expanded(child: _buildGridStatRow(Icons.shield, Colors.deepOrange.shade700, "Defesa", topDef, sf)),
],
),
SizedBox(height: 8 * sf),
Row(
children: [
Expanded(child: _buildGridStatRow(Icons.bolt, Colors.blue.shade700, "Pontos", topPts, sf)),
Expanded(child: _buildGridStatRow(Icons.trending_up, Colors.purple.shade700, "Rebotes", topRbs, sf)),
],
),
SizedBox(height: 8 * sf),
Row(
children: [
Expanded(child: _buildGridStatRow(Icons.star, Colors.green.shade700, "Assists", topAst, sf)),
const Expanded(child: SizedBox()),
],
),
],
),
)
],
),
);
}
Widget _buildGridStatRow(IconData icon, Color color, String label, String value, double sf, {bool isMvp = false}) {
return Row(
children: [
Icon(icon, size: 14 * sf, color: color),
SizedBox(width: 4 * sf),
Text('$label: ', style: TextStyle(fontSize: 11 * sf, color: Colors.grey.shade600, fontWeight: FontWeight.bold)),
Expanded(
child: Text(
value,
style: TextStyle(
fontSize: 11 * sf,
color: isMvp ? Colors.amber.shade900 : Colors.black87,
fontWeight: FontWeight.bold
),
maxLines: 1,
overflow: TextOverflow.ellipsis
)
),
], ],
); ),
} );
} }
Widget _buildGridStatRow(IconData icon, Color color, String label, String value, double sf, {bool isMvp = false}) {
return Row(
children: [
Icon(icon, size: 14 * sf, color: color),
SizedBox(width: 4 * sf),
Text('$label: ', style: TextStyle(fontSize: 11 * sf, color: Colors.grey.shade600, fontWeight: FontWeight.bold)),
Expanded(
child: Text(
value,
style: TextStyle(
fontSize: 11 * sf,
color: isMvp ? Colors.amber.shade900 : Colors.black87,
fontWeight: FontWeight.bold
),
maxLines: 1,
overflow: TextOverflow.ellipsis
)
),
],
);
}
}

282
lib/pages/status_page.dart Normal file
View File

@@ -0,0 +1,282 @@
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../controllers/team_controller.dart';
import 'dart:math' as math;
class StatusPage extends StatefulWidget {
const StatusPage({super.key});
@override
State<StatusPage> createState() => _StatusPageState();
}
class _StatusPageState extends State<StatusPage> {
final TeamController _teamController = TeamController();
final _supabase = Supabase.instance.client;
String? _selectedTeamId;
String _selectedTeamName = "Selecionar Equipa";
String _sortColumn = 'pts';
bool _isAscending = false;
@override
Widget build(BuildContext context) {
final double sf = math.min(MediaQuery.of(context).size.width, MediaQuery.of(context).size.height) / 400;
return Column(
children: [
// --- SELETOR DE EQUIPA ---
Padding(
padding: EdgeInsets.all(16.0 * sf),
child: InkWell(
onTap: () => _showTeamSelector(context, sf),
child: Container(
padding: EdgeInsets.all(12 * sf),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15 * sf),
border: Border.all(color: Colors.grey.shade300),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 5)]
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(children: [
Icon(Icons.shield, color: const Color(0xFFE74C3C), size: 24 * sf),
SizedBox(width: 10 * sf),
Text(_selectedTeamName, style: TextStyle(fontSize: 16 * sf, fontWeight: FontWeight.bold))
]),
const Icon(Icons.arrow_drop_down),
],
),
),
),
),
// --- TABELA DE ESTATÍSTICAS (AGORA EM TEMPO REAL) ---
Expanded(
child: _selectedTeamId == null
? Center(child: Text("Seleciona uma equipa acima.", style: TextStyle(color: Colors.grey, fontSize: 14 * sf)))
// 👇 STREAM 1: LÊ AS ESTATÍSTICAS 👇
: StreamBuilder<List<Map<String, dynamic>>>(
stream: _supabase.from('player_stats_with_names').stream(primaryKey: ['id']).eq('team_id', _selectedTeamId!),
builder: (context, statsSnapshot) {
// 👇 STREAM 2: LÊ OS JOGOS (Para os MVPs e contagem de jogos da equipa) 👇
return StreamBuilder<List<Map<String, dynamic>>>(
stream: _supabase.from('games').stream(primaryKey: ['id']).eq('my_team', _selectedTeamName),
builder: (context, gamesSnapshot) {
// 👇 STREAM 3: LÊ TODOS OS MEMBROS DO PLANTEL 👇
// 👇 A CORREÇÃO ESTÁ AQUI: Remover o .eq('type', 'Jogador')
return StreamBuilder<List<Map<String, dynamic>>>(
stream: _supabase.from('members').stream(primaryKey: ['id']).eq('team_id', _selectedTeamId!),
builder: (context, membersSnapshot) {
// Verifica se ALGUM dos 3 streams ainda está a carregar
if (statsSnapshot.connectionState == ConnectionState.waiting ||
gamesSnapshot.connectionState == ConnectionState.waiting ||
membersSnapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator(color: Color(0xFFE74C3C)));
}
final membersData = membersSnapshot.data ?? [];
if (membersData.isEmpty) {
return Center(child: Text("Esta equipa não tem jogadores registados.", style: TextStyle(color: Colors.grey, fontSize: 14 * sf)));
}
final statsData = statsSnapshot.data ?? [];
final gamesData = gamesSnapshot.data ?? [];
// Conta o total de jogos terminados da equipa
final totalGamesPlayedByTeam = gamesData.where((g) => g['status'] == 'Terminado').length;
// Agrega os dados
final List<Map<String, dynamic>> playerTotals = _aggregateStats(statsData, gamesData, membersData);
// Calcula os Totais da Equipa
final teamTotals = _calculateTeamTotals(playerTotals, totalGamesPlayedByTeam);
// Ordenação
playerTotals.sort((a, b) {
var valA = a[_sortColumn] ?? 0;
var valB = b[_sortColumn] ?? 0;
return _isAscending ? valA.compareTo(valB) : valB.compareTo(valA);
});
return _buildStatsGrid(playerTotals, teamTotals, sf);
}
);
}
);
}
),
),
],
);
}
// --- CÉREBRO CORRIGIDO ---
List<Map<String, dynamic>> _aggregateStats(List<dynamic> stats, List<dynamic> games, List<dynamic> members) {
Map<String, Map<String, dynamic>> aggregated = {};
// 1. Mete a malta toda do plantel com ZERO JOGOS e ZERO STATS
for (var member in members) {
String name = member['name']?.toString() ?? "Desconhecido";
aggregated[name] = {
'name': name,
'j': 0,
'pts': 0, 'ast': 0, 'rbs': 0, 'stl': 0, 'blk': 0, 'mvp': 0, 'def': 0,
};
}
// 2. Se o jogador tiver linha nas estatísticas, soma +1 Jogo e os pontos dele
for (var row in stats) {
String name = row['player_name']?.toString() ?? "Desconhecido";
if (!aggregated.containsKey(name)) {
aggregated[name] = {
'name': name, 'j': 0, 'pts': 0, 'ast': 0, 'rbs': 0, 'stl': 0, 'blk': 0, 'mvp': 0, 'def': 0,
};
}
aggregated[name]!['j'] += 1;
aggregated[name]!['pts'] += (row['pts'] ?? 0);
aggregated[name]!['ast'] += (row['ast'] ?? 0);
aggregated[name]!['rbs'] += (row['rbs'] ?? 0);
aggregated[name]!['stl'] += (row['stl'] ?? 0);
aggregated[name]!['blk'] += (row['blk'] ?? 0);
}
// 3. Conta os troféus
for (var game in games) {
String? mvp = game['mvp_name'];
String? defRaw = game['top_def_name'];
if (mvp != null && aggregated.containsKey(mvp)) {
aggregated[mvp]!['mvp'] += 1;
}
if (defRaw != null) {
String defName = defRaw.split(' (')[0].trim();
if (aggregated.containsKey(defName)) {
aggregated[defName]!['def'] += 1;
}
}
}
return aggregated.values.toList();
}
Map<String, dynamic> _calculateTeamTotals(List<Map<String, dynamic>> players, int teamGames) {
int tPts = 0, tAst = 0, tRbs = 0, tStl = 0, tBlk = 0, tMvp = 0, tDef = 0;
for (var p in players) {
tPts += (p['pts'] as int); tAst += (p['ast'] as int); tRbs += (p['rbs'] as int);
tStl += (p['stl'] as int); tBlk += (p['blk'] as int); tMvp += (p['mvp'] as int);
tDef += (p['def'] as int);
}
return {'name': 'TOTAL EQUIPA', 'j': teamGames, 'pts': tPts, 'ast': tAst, 'rbs': tRbs, 'stl': tStl, 'blk': tBlk, 'mvp': tMvp, 'def': tDef};
}
Widget _buildStatsGrid(List<Map<String, dynamic>> players, Map<String, dynamic> teamTotals, double sf) {
return Container(
color: Colors.white,
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columnSpacing: 25 * sf,
headingRowColor: MaterialStateProperty.all(Colors.grey.shade100),
dataRowHeight: 60 * sf,
columns: [
DataColumn(label: const Text('JOGADOR')),
_buildSortableColumn('J', 'j', sf),
_buildSortableColumn('PTS', 'pts', sf),
_buildSortableColumn('AST', 'ast', sf),
_buildSortableColumn('RBS', 'rbs', sf),
_buildSortableColumn('STL', 'stl', sf),
_buildSortableColumn('BLK', 'blk', sf),
_buildSortableColumn('DEF 🛡️', 'def', sf),
_buildSortableColumn('MVP 🏆', 'mvp', sf),
],
rows: [
...players.map((player) => DataRow(cells: [
DataCell(Row(children: [
CircleAvatar(radius: 15 * sf, backgroundColor: Colors.grey.shade200, child: Icon(Icons.person, size: 18 * sf)),
SizedBox(width: 10 * sf),
Text(player['name'], style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13 * sf)),
])),
DataCell(Center(child: Text(player['j'].toString()))),
_buildStatCell(player['pts'], sf, isHighlight: true),
_buildStatCell(player['ast'], sf),
_buildStatCell(player['rbs'], sf),
_buildStatCell(player['stl'], sf),
_buildStatCell(player['blk'], sf),
_buildStatCell(player['def'], sf, isBlue: true),
_buildStatCell(player['mvp'], sf, isGold: true),
])),
DataRow(
color: MaterialStateProperty.all(Colors.grey.shade50),
cells: [
DataCell(Text('TOTAL EQUIPA', style: TextStyle(fontWeight: FontWeight.w900, color: Colors.black, fontSize: 12 * sf))),
DataCell(Center(child: Text(teamTotals['j'].toString(), style: const TextStyle(fontWeight: FontWeight.bold)))),
_buildStatCell(teamTotals['pts'], sf, isHighlight: true),
_buildStatCell(teamTotals['ast'], sf),
_buildStatCell(teamTotals['rbs'], sf),
_buildStatCell(teamTotals['stl'], sf),
_buildStatCell(teamTotals['blk'], sf),
_buildStatCell(teamTotals['def'], sf, isBlue: true),
_buildStatCell(teamTotals['mvp'], sf, isGold: true),
]
)
],
),
),
),
);
}
DataColumn _buildSortableColumn(String title, String sortKey, double sf) {
return DataColumn(label: InkWell(
onTap: () => setState(() {
if (_sortColumn == sortKey) _isAscending = !_isAscending;
else { _sortColumn = sortKey; _isAscending = false; }
}),
child: Row(children: [
Text(title, style: TextStyle(fontSize: 12 * sf, fontWeight: FontWeight.bold)),
if (_sortColumn == sortKey) Icon(_isAscending ? Icons.arrow_drop_up : Icons.arrow_drop_down, size: 18 * sf, color: const Color(0xFFE74C3C)),
]),
));
}
DataCell _buildStatCell(int value, double sf, {bool isHighlight = false, bool isGold = false, bool isBlue = false}) {
return DataCell(Center(child: Container(
padding: EdgeInsets.symmetric(horizontal: 8 * sf, vertical: 4 * sf),
decoration: BoxDecoration(
color: isGold && value > 0 ? Colors.amber.withOpacity(0.2) : (isBlue && value > 0 ? Colors.blue.withOpacity(0.1) : Colors.transparent),
borderRadius: BorderRadius.circular(6),
),
child: Text(value == 0 ? "-" : value.toString(), style: TextStyle(
fontWeight: (isHighlight || isGold || isBlue) ? FontWeight.w900 : FontWeight.w600,
fontSize: 14 * sf,
color: isGold && value > 0 ? Colors.orange.shade900 : (isBlue && value > 0 ? Colors.blue.shade800 : (isHighlight ? Colors.green.shade700 : Colors.black87))
)),
)));
}
void _showTeamSelector(BuildContext context, double sf) {
showModalBottomSheet(context: context, builder: (context) => StreamBuilder<List<Map<String, dynamic>>>(
stream: _teamController.teamsStream,
builder: (context, snapshot) {
final teams = snapshot.data ?? [];
return ListView.builder(itemCount: teams.length, itemBuilder: (context, i) => ListTile(
title: Text(teams[i]['name']),
onTap: () { setState(() { _selectedTeamId = teams[i]['id']; _selectedTeamName = teams[i]['name']; }); Navigator.pop(context); },
));
},
));
}
}

View File

@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:playmaker/screens/team_stats_page.dart'; import 'package:playmaker/screens/team_stats_page.dart';
import '../controllers/team_controller.dart'; import '../controllers/team_controller.dart';
import '../models/team_model.dart'; import '../models/team_model.dart';
import '../widgets/team_widgets.dart';
import 'dart:math' as math; // <-- IMPORTANTE: Adicionar para o cálculo import 'dart:math' as math; // <-- IMPORTANTE: Adicionar para o cálculo
class TeamsPage extends StatefulWidget { class TeamsPage extends StatefulWidget {
@@ -139,7 +138,8 @@ class _TeamsPageState extends State<TeamsPage> {
// 👇 CÁLCULO DA ESCALA (sf) PARA SE ADAPTAR A QUALQUER ECRÃ 👇 // 👇 CÁLCULO DA ESCALA (sf) PARA SE ADAPTAR A QUALQUER ECRÃ 👇
final double wScreen = MediaQuery.of(context).size.width; final double wScreen = MediaQuery.of(context).size.width;
final double hScreen = MediaQuery.of(context).size.height; final double hScreen = MediaQuery.of(context).size.height;
final double sf = math.min(wScreen, hScreen) / 400; final double sf = math.min(wScreen, hScreen) / 400;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF5F7FA), backgroundColor: const Color(0xFFF5F7FA),
appBar: AppBar( appBar: AppBar(
@@ -256,4 +256,250 @@ final double sf = math.min(wScreen, hScreen) / 400;
), ),
); );
} }
}
// --- TEAM CARD ---
class TeamCard extends StatelessWidget {
final Team team;
final TeamController controller;
final VoidCallback onFavoriteTap;
final double sf; // <-- Variável de escala
const TeamCard({
super.key,
required this.team,
required this.controller,
required this.onFavoriteTap,
required this.sf,
});
@override
Widget build(BuildContext context) {
return Card(
color: Colors.white,
elevation: 3,
margin: EdgeInsets.only(bottom: 12 * sf),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * sf)),
child: ListTile(
contentPadding: EdgeInsets.symmetric(horizontal: 16 * sf, vertical: 8 * sf),
// --- 1. IMAGEM + FAVORITO ---
leading: Stack(
clipBehavior: Clip.none,
children: [
CircleAvatar(
radius: 28 * sf,
backgroundColor: Colors.grey[200],
backgroundImage: (team.imageUrl.isNotEmpty && team.imageUrl.startsWith('http'))
? NetworkImage(team.imageUrl)
: null,
child: (team.imageUrl.isEmpty || !team.imageUrl.startsWith('http'))
? Text(
team.imageUrl.isEmpty ? "🏀" : team.imageUrl,
style: TextStyle(fontSize: 24 * sf),
)
: null,
),
Positioned(
left: -15 * sf,
top: -10 * sf,
child: IconButton(
icon: Icon(
team.isFavorite ? Icons.star : Icons.star_border,
color: team.isFavorite ? Colors.amber : Colors.black.withOpacity(0.1),
size: 28 * sf,
shadows: [
Shadow(
color: Colors.black.withOpacity(team.isFavorite ? 0.3 : 0.1),
blurRadius: 4 * sf,
),
],
),
onPressed: onFavoriteTap,
),
),
],
),
// --- 2. TÍTULO ---
title: Text(
team.name,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16 * sf),
overflow: TextOverflow.ellipsis, // Previne overflows em nomes longos
),
// --- 3. SUBTÍTULO (Contagem + Época em TEMPO REAL) ---
subtitle: Padding(
padding: EdgeInsets.only(top: 6.0 * sf),
child: Row(
children: [
Icon(Icons.groups_outlined, size: 16 * sf, color: Colors.grey),
SizedBox(width: 4 * sf),
// 👇 STREAMBUILDER EM VEZ DE FUTUREBUILDER 👇
StreamBuilder<int>(
stream: controller.getPlayerCountStream(team.id),
initialData: 0,
builder: (context, snapshot) {
final count = snapshot.data ?? 0;
return Text(
"$count Jogs.",
style: TextStyle(
color: count > 0 ? Colors.green[700] : Colors.orange,
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,
),
),
],
),
),
// --- 4. BOTÕES (Estatísticas e Apagar) ---
trailing: Row(
mainAxisSize: MainAxisSize.min, // <-- ISTO RESOLVE O OVERFLOW DAS RISCAS AMARELAS
children: [
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),
),
);
},
),
IconButton(
tooltip: 'Eliminar Equipa',
icon: Icon(Icons.delete_outline, color: const Color(0xFFE74C3C), size: 24 * sf),
onPressed: () => _confirmDelete(context),
),
],
),
),
);
}
void _confirmDelete(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Eliminar Equipa?', style: TextStyle(fontSize: 18 * sf, fontWeight: FontWeight.bold)),
content: Text('Tens a certeza que queres eliminar "${team.name}"?', style: TextStyle(fontSize: 14 * sf)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancelar', style: TextStyle(fontSize: 14 * sf)),
),
TextButton(
onPressed: () {
controller.deleteTeam(team.id);
Navigator.pop(context);
},
child: Text('Eliminar', style: TextStyle(color: Colors.red, fontSize: 14 * sf)),
),
],
),
);
}
}
// --- DIALOG DE CRIAÇÃO ---
class CreateTeamDialog extends StatefulWidget {
final Function(String name, String season, String imageUrl) onConfirm;
final double sf; // Recebe a escala
const CreateTeamDialog({super.key, required this.onConfirm, required this.sf});
@override
State<CreateTeamDialog> createState() => _CreateTeamDialogState();
}
class _CreateTeamDialogState extends State<CreateTeamDialog> {
final TextEditingController _nameController = TextEditingController();
final TextEditingController _imageController = TextEditingController();
String _selectedSeason = '2024/25';
@override
Widget build(BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * widget.sf)),
title: Text('Nova Equipa', style: TextStyle(fontSize: 18 * widget.sf, fontWeight: FontWeight.bold)),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _nameController,
style: TextStyle(fontSize: 14 * widget.sf),
decoration: InputDecoration(
labelText: 'Nome da Equipa',
labelStyle: TextStyle(fontSize: 14 * widget.sf)
),
textCapitalization: TextCapitalization.words,
),
SizedBox(height: 15 * widget.sf),
DropdownButtonFormField<String>(
value: _selectedSeason,
decoration: InputDecoration(
labelText: 'Temporada',
labelStyle: TextStyle(fontSize: 14 * widget.sf)
),
style: TextStyle(fontSize: 14 * widget.sf, color: Colors.black87),
items: ['2023/24', '2024/25', '2025/26']
.map((s) => DropdownMenuItem(value: s, child: Text(s)))
.toList(),
onChanged: (val) => setState(() => _selectedSeason = val!),
),
SizedBox(height: 15 * widget.sf),
TextField(
controller: _imageController,
style: TextStyle(fontSize: 14 * widget.sf),
decoration: InputDecoration(
labelText: 'URL Imagem ou Emoji',
labelStyle: TextStyle(fontSize: 14 * widget.sf),
hintText: 'Ex: 🏀 ou https://...',
hintStyle: TextStyle(fontSize: 14 * widget.sf)
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancelar', style: TextStyle(fontSize: 14 * widget.sf))
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFE74C3C),
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)),
),
],
);
}
} }

View File

@@ -1 +0,0 @@
import 'package:flutter/material.dart';

View File

@@ -72,15 +72,17 @@ class TeamCard extends StatelessWidget {
overflow: TextOverflow.ellipsis, // Previne overflows em nomes longos overflow: TextOverflow.ellipsis, // Previne overflows em nomes longos
), ),
// --- 3. SUBTÍTULO (Contagem + Época) --- // --- 3. SUBTÍTULO (Contagem + Época em TEMPO REAL) ---
subtitle: Padding( subtitle: Padding(
padding: EdgeInsets.only(top: 6.0 * sf), padding: EdgeInsets.only(top: 6.0 * sf),
child: Row( child: Row(
children: [ children: [
Icon(Icons.groups_outlined, size: 16 * sf, color: Colors.grey), Icon(Icons.groups_outlined, size: 16 * sf, color: Colors.grey),
SizedBox(width: 4 * sf), SizedBox(width: 4 * sf),
FutureBuilder<int>(
future: controller.getPlayerCount(team.id), // 👇 A CORREÇÃO ESTÁ AQUI: StreamBuilder em vez de FutureBuilder 👇
StreamBuilder<int>(
stream: controller.getPlayerCountStream(team.id),
initialData: 0, initialData: 0,
builder: (context, snapshot) { builder: (context, snapshot) {
final count = snapshot.data ?? 0; final count = snapshot.data ?? 0;
@@ -94,6 +96,7 @@ class TeamCard extends StatelessWidget {
); );
}, },
), ),
SizedBox(width: 8 * sf), SizedBox(width: 8 * sf),
Expanded( // Garante que a temporada se adapta se faltar espaço Expanded( // Garante que a temporada se adapta se faltar espaço
child: Text( child: Text(
@@ -107,7 +110,6 @@ class TeamCard extends StatelessWidget {
), ),
// --- 4. BOTÕES (Estatísticas e Apagar) --- // --- 4. BOTÕES (Estatísticas e Apagar) ---
// Removido o SizedBox fixo! Agora é MainAxisSize.min
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, // <-- ISTO RESOLVE O OVERFLOW DAS RISCAS AMARELAS mainAxisSize: MainAxisSize.min, // <-- ISTO RESOLVE O OVERFLOW DAS RISCAS AMARELAS
children: [ children: [