From 74453f7cbc53e34adf18d20e4533f21ab5cd1139 Mon Sep 17 00:00:00 2001 From: 230404 <230404@epvc.pt> Date: Tue, 3 Feb 2026 17:33:40 +0000 Subject: [PATCH] ndk --- android/app/build.gradle.kts | 14 +- android/build.gradle.kts | 20 +- android/settings.gradle.kts | 2 +- ios/Flutter/Debug.xcconfig | 1 + ios/Flutter/Release.xcconfig | 1 + ios/Podfile | 43 ++++ lib/controllers/game_controller.dart | 25 ++ lib/controllers/stats_controller.dart | 211 ++++++++-------- lib/controllers/team_controller.dart | 107 ++++----- lib/controllers/team_controllers.dart | 1 - lib/models/game_model.dart | 21 ++ lib/models/team_model.dart | 6 +- lib/pages/PlacarPage.dart | 139 +++++++++++ lib/pages/gamePage.dart | 319 ++++--------------------- lib/pages/teamPage.dart | 71 ++++-- lib/screens/team_stats_page.dart | 268 ++++++++++----------- lib/widgets/game_widgets.dart | 220 +++++++++++++++++ lib/widgets/stats_widgets.dart | 140 +++++++++++ lib/widgets/team_widgets.dart | 68 +++--- macos/Flutter/Flutter-Debug.xcconfig | 1 + macos/Flutter/Flutter-Release.xcconfig | 1 + macos/Podfile | 42 ++++ 22 files changed, 1082 insertions(+), 639 deletions(-) create mode 100644 ios/Podfile create mode 100644 lib/controllers/game_controller.dart delete mode 100644 lib/controllers/team_controllers.dart create mode 100644 lib/models/game_model.dart create mode 100644 lib/pages/PlacarPage.dart create mode 100644 lib/widgets/game_widgets.dart create mode 100644 lib/widgets/stats_widgets.dart create mode 100644 macos/Podfile diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c95d943..c1984fb 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -8,8 +8,8 @@ plugins { android { namespace = "com.example.playmaker" compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion - + //ndkVersion = flutter.ndkVersion + ndkVersion = "26.1.10909125" compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 @@ -39,12 +39,14 @@ android { } // Adicione no FINAL do arquivo: -apply plugin: 'com.google.gms.google-services' +apply(plugin = "com.google.gms.google-services") dependencies { - // Dependências do Firebase que você vai usar - implementation 'com.google.firebase:firebase-analytics' - // Adicione outras conforme necessário + // Usa parênteses e aspas duplas + implementation("com.google.firebase:firebase-analytics") + + // Se tiveres o BOM do Firebase, também deve ser assim: + implementation(platform("com.google.firebase:firebase-bom:32.7.0")) } } diff --git a/android/build.gradle.kts b/android/build.gradle.kts index f98099a..0d9471b 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,3 +1,14 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + // Esta linha permite que o Gradle encontre o plugin do Google Services + classpath("com.google.gms:google-services:4.4.0") + } +} + allprojects { repositories { google() @@ -15,16 +26,11 @@ subprojects { val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) project.layout.buildDirectory.value(newSubprojectBuildDir) } + subprojects { project.evaluationDependsOn(":app") } tasks.register("clean") { delete(rootProject.layout.buildDirectory) -} -buildscript { - dependencies { - // Adicione esta linha: - classpath 'com.google.gms:google-services:4.4.0' - } -} +} \ No newline at end of file diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index ff284ff..aebf363 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -21,7 +21,7 @@ plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.9.1" apply false // START: FlutterFire Configuration - id("com.google.gms.google-services") version("4.3.15") apply false + id("com.google.gms.google-services") version "4.3.15" apply false // END: FlutterFire Configuration id("org.jetbrains.kotlin.android") version "2.1.0" apply false } diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/lib/controllers/game_controller.dart b/lib/controllers/game_controller.dart new file mode 100644 index 0000000..0499a76 --- /dev/null +++ b/lib/controllers/game_controller.dart @@ -0,0 +1,25 @@ +import 'dart:async'; +import '../models/game_model.dart'; + +class GameController { + final List _games = []; + final _gameStreamController = StreamController>.broadcast(); + + Stream> get gamesStream => _gameStreamController.stream; + + void addGame(String myTeam, String opponent, String season) { + final newGame = Game( + id: DateTime.now().toString(), + myTeam: myTeam, + opponentTeam: opponent, + season: season, + date: DateTime.now(), + ); + _games.insert(0, newGame); // Adiciona ao topo da lista + _gameStreamController.add(List.unmodifiable(_games)); + } + + void dispose() { + _gameStreamController.close(); + } +} \ No newline at end of file diff --git a/lib/controllers/stats_controller.dart b/lib/controllers/stats_controller.dart index 502e6c8..634f114 100644 --- a/lib/controllers/stats_controller.dart +++ b/lib/controllers/stats_controller.dart @@ -5,18 +5,18 @@ import '../models/person_model.dart'; class StatsController { final SupabaseClient _supabase = Supabase.instance.client; - // --- 1. LER MEMBROS (STREAM EM TEMPO REAL) --- + // 1. LER Stream> getMembers(String teamId) { return _supabase - .from('members') // Nome da tua tabela no 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 teamId, String personId) async { + // 2. APAGAR + Future deletePerson(String personId) async { try { await _supabase.from('members').delete().eq('id', personId); } catch (e) { @@ -24,112 +24,135 @@ class StatsController { } } - // --- 3. DIÁLOGOS DE ADICIONAR / EDITAR --- - - // Abrir diálogo para criar novo + // 3. DIÁLOGOS void showAddPersonDialog(BuildContext context, String teamId) { - _showPersonForm(context, teamId: teamId); + _showForm(context, teamId: teamId); } - // Abrir diálogo para editar existente void showEditPersonDialog(BuildContext context, String teamId, Person person) { - _showPersonForm(context, teamId: teamId, person: person); + _showForm(context, teamId: teamId, person: person); } - // Lógica interna do formulário - void _showPersonForm(BuildContext context, {required String teamId, Person? person}) { - final isEditing = person != null; + // --- 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 ?? ''); - // Controladores de texto - final nameController = TextEditingController(text: person?.name ?? ''); - final numberController = TextEditingController(text: person?.number ?? ''); - - // Variável para o Dropdown (Valor inicial) + // Define o valor inicial String selectedType = person?.type ?? 'Jogador'; showDialog( context: context, - builder: (context) { - // StatefulBuilder serve para atualizar o Dropdown DENTRO do diálogo - return StatefulBuilder( - builder: (context, setState) { - return AlertDialog( - title: Text(isEditing ? 'Editar Membro' : 'Novo Membro'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Nome - TextField( - controller: nameController, - decoration: const InputDecoration(labelText: 'Nome'), - textCapitalization: TextCapitalization.sentences, - ), - const SizedBox(height: 10), - - // Tipo (Jogador vs Treinador) - DropdownButtonFormField( - value: selectedType, - decoration: const InputDecoration(labelText: 'Função'), - items: const [ - DropdownMenuItem(value: 'Jogador', child: Text('Jogador')), - DropdownMenuItem(value: 'Treinador', child: Text('Treinador')), - ], - onChanged: (value) { - if (value != null) { - setState(() => selectedType = value); - } - }, - ), - const SizedBox(height: 10), - - // Número (Só mostramos se for Jogador) - if (selectedType == 'Jogador') - TextField( - controller: numberController, - decoration: const InputDecoration(labelText: 'Número da Camisola'), - keyboardType: TextInputType.number, - ), - ], + 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, ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancelar'), - ), - ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF00C853)), - onPressed: () async { - if (nameController.text.trim().isEmpty) return; + const SizedBox(height: 10), - final name = nameController.text.trim(); - final number = numberController.text.trim(); + // 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); + }, + ), - if (isEditing) { - // ATUALIZAR - await _supabase.from('members').update({ - 'name': name, - 'type': selectedType, - 'number': number, - }).eq('id', person!.id); - } else { - // CRIAR NOVO - await _supabase.from('members').insert({ - 'team_id': teamId, - 'name': name, - 'type': selectedType, - 'number': number, - }); - } - - if (context.mounted) Navigator.pop(context); - }, - child: Text(isEditing ? 'Guardar' : 'Adicionar', style: const TextStyle(color: Colors.white)), + // 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 51f43d8..bf45320 100644 --- a/lib/controllers/team_controller.dart +++ b/lib/controllers/team_controller.dart @@ -1,80 +1,71 @@ -import 'dart:async'; +import 'package:supabase_flutter/supabase_flutter.dart'; class TeamController { - // --- BASE DE DADOS LOCAL (Listas Estáticas) --- - // Mantemos estático para que os dados persistam entre navegações de ecrãs - static final List> _teams = []; - static final List> _members = []; + // Instância do cliente Supabase + final _supabase = Supabase.instance.client; - static List> get members => _members; - - // StreamController broadcast para permitir múltiplos ouvintes (ex: Home e TeamsPage) - final _streamController = StreamController>>.broadcast(); - - // 1. STREAM - // Retorna a lista atual mal alguém subscreve + // 1. STREAM (Realtime) + // Adicionei o .map() no final para garantir que o Dart entende que é uma List Stream>> get teamsStream { - _notifyListeners(); - return _streamController.stream; + return _supabase + .from('teams') + .stream(primaryKey: ['id']) + .order('name', ascending: true) + .map((data) => List>.from(data)); } // 2. CRIAR - Future createTeam(String name, String season, String imageUrl) async { - await Future.delayed(const Duration(milliseconds: 100)); // Simula latência - final newTeam = { - 'id': DateTime.now().millisecondsSinceEpoch.toString(), - 'name': name, - 'season': season, - 'image_url': imageUrl, - 'is_favorite': false, // Inicializa sempre como falso - }; - _teams.add(newTeam); - _notifyListeners(); + // Alterei imageUrl para String? (pode ser nulo) para evitar erros se não houver imagem + Future createTeam(String name, String season, String? imageUrl) async { + try { + await _supabase.from('teams').insert({ + 'name': name, + 'season': season, + 'image_url': imageUrl, + 'is_favorite': false, + }); + print("✅ Equipa guardada no Supabase!"); + } catch (e) { + print("❌ Erro ao criar: $e"); + } } // 3. ELIMINAR Future deleteTeam(String id) async { - _teams.removeWhere((team) => team['id'] == id); - _members.removeWhere((member) => member['team_id'] == id); - _notifyListeners(); + try { + await _supabase.from('teams').delete().eq('id', id); + } catch (e) { + print("❌ Erro ao eliminar: $e"); + } } // 4. FAVORITAR - Future toggleFavorite(String teamId) async { - final index = _teams.indexWhere((t) => t['id'] == teamId); - if (index != -1) { - // Inverte o valor booleano (trata null como false) - final bool currentStatus = _teams[index]['is_favorite'] ?? false; - _teams[index]['is_favorite'] = !currentStatus; - _notifyListeners(); + Future toggleFavorite(String teamId, bool currentStatus) async { + try { + await _supabase + .from('teams') + .update({'is_favorite': !currentStatus}) // Inverte o valor + .eq('id', teamId); + } catch (e) { + print("❌ Erro ao favoritar: $e"); } } // 5. CONTAR JOGADORES + // CORRIGIDO: A sintaxe antiga dava erro. O método .count() é o correto agora. Future getPlayerCount(String teamId) async { - return _members.where((m) => m['team_id'] == teamId).length; + try { + final count = await _supabase + .from('members') + .count() // Retorna diretamente o número inteiro + .eq('team_id', teamId); + return count; + } catch (e) { + print("Erro ao contar jogadores: $e"); + return 0; + } } - // 6. NOTIFICAR E ORDENAR (Única versão corrigida) - void _notifyListeners() { - if (_streamController.isClosed) return; - - // Ordenação: 1º Favoritos, 2º Nome (Alfabético) - _teams.sort((a, b) { - final bool favA = a['is_favorite'] ?? false; - final bool favB = b['is_favorite'] ?? false; - - if (favA == favB) { - return (a['name'] as String).compareTo(b['name'] as String); - } - return favB ? 1 : -1; // b (favorito) vem antes de a - }); - - // Enviamos uma CÓPIA da lista (List.from) para garantir que o StreamBuilder detecte a mudança - _streamController.add(List.from(_teams)); - } - - void dispose() { - _streamController.close(); - } + // Mantemos o dispose vazio para não quebrar a chamada na TeamsPage + void dispose() {} } \ No newline at end of file diff --git a/lib/controllers/team_controllers.dart b/lib/controllers/team_controllers.dart deleted file mode 100644 index b9f78bb..0000000 --- a/lib/controllers/team_controllers.dart +++ /dev/null @@ -1 +0,0 @@ -// TODO Implement this library. \ No newline at end of file diff --git a/lib/models/game_model.dart b/lib/models/game_model.dart new file mode 100644 index 0000000..6d95d8c --- /dev/null +++ b/lib/models/game_model.dart @@ -0,0 +1,21 @@ +class Game { + final String id; + final String myTeam; + final String opponentTeam; + final String myScore; + final String opponentScore; + final String season; + final String status; + final DateTime date; + + Game({ + required this.id, + required this.myTeam, + required this.opponentTeam, + this.myScore = "0", + this.opponentScore = "0", + required this.season, + this.status = "Brevemente", + required this.date, + }); +} \ No newline at end of file diff --git a/lib/models/team_model.dart b/lib/models/team_model.dart index c5d51ac..3a23600 100644 --- a/lib/models/team_model.dart +++ b/lib/models/team_model.dart @@ -13,10 +13,10 @@ class Team { this.isFavorite = false }); - // Converte de Mapa (o formato da nossa "memória") para Objeto + // Mapeia o JSON que vem do Supabase (id costuma ser UUID ou String) factory Team.fromMap(Map map) { return Team( - id: map['id'] ?? '', + id: map['id']?.toString() ?? '', name: map['name'] ?? '', season: map['season'] ?? '', imageUrl: map['image_url'] ?? '', @@ -24,10 +24,8 @@ class Team { ); } - // Converte de Objeto para Mapa (para guardar na lista) Map toMap() { return { - 'id': id, 'name': name, 'season': season, 'image_url': imageUrl, diff --git a/lib/pages/PlacarPage.dart b/lib/pages/PlacarPage.dart new file mode 100644 index 0000000..5fead31 --- /dev/null +++ b/lib/pages/PlacarPage.dart @@ -0,0 +1,139 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; + +class PlacarPage extends StatefulWidget { + final String gameId; + final String myTeam; + final String opponentTeam; + + const PlacarPage({ + super.key, + required this.gameId, + required this.myTeam, + required this.opponentTeam, + }); + + @override + State createState() => _PlacarPageState(); +} + +class _PlacarPageState extends State { + int _myScore = 0; + int _opponentScore = 0; + + // Lógica do Tempo (Exemplo: 10 minutos) + Duration _duration = const Duration(minutes: 10); + Timer? _timer; + bool _isRunning = false; + + void _toggleTimer() { + if (_isRunning) { + _timer?.cancel(); + } else { + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_duration.inSeconds > 0) { + setState(() => _duration -= const Duration(seconds: 1)); + } else { + _timer?.cancel(); + setState(() => _isRunning = false); + } + }); + } + setState(() => _isRunning = !_isRunning); + } + + String _formatTime(Duration d) { + return "${d.inMinutes.toString().padLeft(2, '0')}:${d.inSeconds.remainder(60).toString().padLeft(2, '0')}"; + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF121212), + appBar: AppBar( + title: const Text("Placar Live"), + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + elevation: 0, + ), + body: Column( + children: [ + const SizedBox(height: 20), + // Cronómetro + GestureDetector( + onTap: _toggleTimer, + child: Text( + _formatTime(_duration), + style: const TextStyle(color: Colors.white, fontSize: 75, fontWeight: FontWeight.bold, fontFamily: 'monospace'), + ), + ), + const Text("CLIQUE NO TEMPO PARA INICIAR/PAUSAR", style: TextStyle(color: Colors.grey, fontSize: 10)), + + Expanded( + child: Row( + children: [ + // Minha Equipa + _buildTeamSide(widget.myTeam, _myScore, (p) => setState(() => _myScore += p), const Color(0xFFE74C3C)), + // Divisor + Container(width: 1, color: Colors.white24, margin: const EdgeInsets.symmetric(vertical: 40)), + // Adversário + _buildTeamSide(widget.opponentTeam, _opponentScore, (p) => setState(() => _opponentScore += p), Colors.blueGrey), + ], + ), + ), + + // Botão Finalizar + Padding( + padding: const EdgeInsets.all(20.0), + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), + ), + onPressed: () { + // Aqui podes adicionar a lógica para salvar o resultado final no Controller + Navigator.pop(context); + }, + child: const Text("FINALIZAR PARTIDA", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + ), + ), + ], + ), + ); + } + + Widget _buildTeamSide(String name, int score, Function(int) onAdd, Color color) { + return Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(name, textAlign: TextAlign.center, style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 10), + Text("$score", style: TextStyle(color: color, fontSize: 80, fontWeight: FontWeight.bold)), + const SizedBox(height: 30), + // Botões de Pontuação + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [1, 2, 3].map((p) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: InkWell( + onTap: () => onAdd(p), + child: CircleAvatar( + backgroundColor: color.withOpacity(0.2), + child: Text("+$p", style: TextStyle(color: color, fontWeight: FontWeight.bold)), + ), + ), + )).toList(), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/pages/gamePage.dart b/lib/pages/gamePage.dart index 542302c..0632e1f 100644 --- a/lib/pages/gamePage.dart +++ b/lib/pages/gamePage.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import '../controllers/game_controller.dart'; import '../controllers/team_controller.dart'; - +import '../models/game_model.dart'; +import '../widgets/game_widgets.dart'; class GamePage extends StatefulWidget { const GamePage({super.key}); @@ -10,103 +12,17 @@ class GamePage extends StatefulWidget { } class _GamePageState extends State { - final TeamController controller = TeamController(); - final TextEditingController _searchController = TextEditingController(); - - String _selectedSeasonFilter = 'Todas'; - String _currentSort = 'Recentes'; - String _searchQuery = ''; + // Criamos as instâncias dos controllers + final GameController gameController = GameController(); + final TeamController teamController = TeamController(); @override void dispose() { - _searchController.dispose(); + // É importante fechar os streams quando a página sai da memória + gameController.dispose(); super.dispose(); } - void _showCreateGameDialog(BuildContext context) { - showDialog( - context: context, - builder: (context) { - return CreateGameDialogManual(controller: controller); - }, - ); - } - - void _showFilterDialog(BuildContext context) { - showDialog( - context: context, - builder: (context) { - return StatefulBuilder( - builder: (context, setModalState) { - return AlertDialog( - backgroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text("Filtros", - style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold, fontSize: 18) - ), - IconButton( - icon: const Icon(Icons.close, color: Colors.black, size: 20), - onPressed: () => Navigator.pop(context), - ) - ], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Divider(), - Row( - children: [ - _buildPopupColumn("TEMPORADA", ['Todas', '2023/24', '2024/25', '2025/26'], _selectedSeasonFilter, (val) { - setState(() => _selectedSeasonFilter = val); - setModalState(() {}); - }), - const SizedBox(width: 20), - _buildPopupColumn("ORDENAR", ['Recentes', 'Nome'], _currentSort, (val) { - setState(() => _currentSort = val); - setModalState(() {}); - }), - ], - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text("CONCLUÍDO", style: TextStyle(color: Color(0xFFE74C3C), fontWeight: FontWeight.bold)), - ), - ], - ); - }, - ); - }, - ); - } - - Widget _buildPopupColumn(String title, List options, String current, Function(String) onSelect) { - return Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: TextStyle(color: Colors.grey[700], fontSize: 11, fontWeight: FontWeight.bold)), - const SizedBox(height: 12), - ...options.map((opt) => InkWell( - onTap: () => onSelect(opt), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Text(opt, - style: TextStyle( - color: current == opt ? const Color(0xFFE74C3C) : Colors.black, - fontWeight: current == opt ? FontWeight.bold : FontWeight.normal)), - ), - )), - ], - ), - ); - } - @override Widget build(BuildContext context) { return Scaffold( @@ -115,194 +31,55 @@ class _GamePageState extends State { title: const Text("Jogos", style: TextStyle(fontWeight: FontWeight.bold)), backgroundColor: Colors.white, elevation: 0, - actions: [ - IconButton( - icon: const Icon(Icons.filter_list, color: Color(0xFFE74C3C)), - onPressed: () => _showFilterDialog(context), - ), - ], ), - body: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: TextField( - controller: _searchController, - onChanged: (v) => setState(() => _searchQuery = v.toLowerCase()), - decoration: InputDecoration( - hintText: 'Pesquisar jogo...', - prefixIcon: const Icon(Icons.search, color: Color(0xFFE74C3C)), - filled: true, - fillColor: Colors.white, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(15), borderSide: BorderSide.none), - ), - ), - ), - const Expanded(child: Center(child: Text("Nenhum jogo registado."))), - ], + body: StreamBuilder>( + stream: gameController.gamesStream, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return const Center(child: Text("Nenhum jogo registado.")); + } + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: snapshot.data!.length, + itemBuilder: (context, index) { + final game = snapshot.data![index]; + + // ATUALIZADO: Passamos o gameId para o card + return GameResultCard( + gameId: game.id, + myTeam: game.myTeam, + opponentTeam: game.opponentTeam, + myScore: game.myScore, + opponentScore: game.opponentScore, + status: game.status, + season: game.season, + ); + }, + ); + }, ), floatingActionButton: FloatingActionButton( - onPressed: () => _showCreateGameDialog(context), backgroundColor: const Color(0xFFE74C3C), child: const Icon(Icons.add, color: Colors.white), + onPressed: () => _showCreateDialog(context), ), ); } -} -class CreateGameDialogManual extends StatefulWidget { - final TeamController controller; - const CreateGameDialogManual({super.key, required this.controller}); - - @override - State createState() => _CreateGameDialogManualState(); -} - -class _CreateGameDialogManualState extends State { - final TextEditingController _seasonController = TextEditingController(); - - // Controllers para capturar o texto dos campos de pesquisa - String _myTeamName = ""; - String _opponentName = ""; - - @override - void dispose() { - _seasonController.dispose(); - super.dispose(); - } - - // --- WIDGET DE PESQUISA (AUTOCOMPLETE) --- -Widget _buildSearchField({ - required String label, - required List options, - required Function(String) onSelected, - }) { - return Autocomplete( - optionsBuilder: (TextEditingValue textEditingValue) { - if (textEditingValue.text.isEmpty) { - return const Iterable.empty(); - } - return options.where((String option) { - return option.toLowerCase().contains(textEditingValue.text.toLowerCase()); - }); - }, - onSelected: (String selection) { - onSelected(selection); - FocusScope.of(context).unfocus(); - }, - // --- ESTE BLOCO CONSTRÓI A LISTA DE SUGESTÕES EM BAIXO --- - optionsViewBuilder: (context, onSelected, options) { - return Align( - alignment: Alignment.topLeft, - child: Material( - elevation: 4.0, - borderRadius: BorderRadius.circular(10), - child: Container( - width: MediaQuery.of(context).size.width * 0.7, // Ajusta à largura do dialog - constraints: const BoxConstraints(maxHeight: 200), // Limita a altura da lista - child: ListView.builder( - padding: EdgeInsets.zero, - shrinkWrap: true, - itemCount: options.length, - itemBuilder: (BuildContext context, int index) { - final String option = options.elementAt(index); - return ListTile( - title: Text(option), - onTap: () => onSelected(option), - ); - }, - ), - ), - ), - ); - }, - fieldViewBuilder: (context, fieldTextController, focusNode, onFieldSubmitted) { - return TextField( - controller: fieldTextController, - focusNode: focusNode, - onChanged: (value) => onSelected(value), - decoration: InputDecoration( - labelText: label, - prefixIcon: const Icon(Icons.search, color: Color(0xFFE74C3C)), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), - filled: true, - fillColor: Colors.white, - ), - ); - }, - ); - } - - @override - Widget build(BuildContext context) { - return StreamBuilder>>( - stream: widget.controller.teamsStream, - builder: (context, snapshot) { - // Lista de nomes das equipas que vêm da TeamsPage - List teamList = []; - if (snapshot.hasData) { - teamList = snapshot.data!.map((t) => t['name'].toString()).toList(); - } - - return AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - title: const Text('Configurar Partida', style: TextStyle(fontWeight: FontWeight.bold)), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Campo da Temporada - TextField( - controller: _seasonController, - decoration: InputDecoration( - labelText: 'Temporada', - prefixIcon: const Icon(Icons.calendar_today, size: 20), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), - ), - ), - const SizedBox(height: 20), - - // Pesquisa: Minha Equipa - _buildSearchField( - label: "A Minha Equipa", - options: teamList, - onSelected: (val) => _myTeamName = val, - ), - - const Padding( - padding: EdgeInsets.symmetric(vertical: 15), - child: Text("VS", style: TextStyle( color: Colors.grey, fontSize: 18)), - ), - - // Pesquisa: Equipa Adversária - _buildSearchField( - label: "Equipa Adversária", - options: teamList, - onSelected: (val) => _opponentName = val, - ), - ], - ), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('CANCELAR')), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFE74C3C), - minimumSize: const Size(100, 45), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - onPressed: () { - if (_myTeamName.isNotEmpty && _opponentName.isNotEmpty) { - // Lógica para iniciar o jogo - print("Iniciando: $_myTeamName VS $_opponentName"); - Navigator.pop(context); - } - }, - child: const Text('CRIAR JOGO', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), - ), - ], - ); - }, + void _showCreateDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => CreateGameDialogManual( + controller: teamController, + onConfirm: (my, opp, sea) { + gameController.addGame(my, opp, sea); + }, + ), ); } } \ No newline at end of file diff --git a/lib/pages/teamPage.dart b/lib/pages/teamPage.dart index e18917e..e1e4036 100644 --- a/lib/pages/teamPage.dart +++ b/lib/pages/teamPage.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:playmaker/screens/team_stats_page.dart'; import '../controllers/team_controller.dart'; import '../models/team_model.dart'; import '../widgets/team_widgets.dart'; @@ -24,7 +25,7 @@ class _TeamsPageState extends State { super.dispose(); } - // --- POPUP DE FILTROS (ESTILO DIALOG CENTRAL) --- + // --- POPUP DE FILTROS --- void _showFilterDialog(BuildContext context) { showDialog( context: context, @@ -32,6 +33,7 @@ class _TeamsPageState extends State { return StatefulBuilder( builder: (context, setModalState) { return AlertDialog( + backgroundColor: const Color(0xFF2C3E50), // 2. CORRIGIDO: Fundo escuro shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), title: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -116,7 +118,8 @@ class _TeamsPageState extends State { child: Text( opt, style: TextStyle( - color: isSelected ? const Color(0xFFE74C3C) : Colors.black, + // 3. CORRIGIDO: Cor do texto (Branco se não selecionado, Vermelho se selecionado) + color: isSelected ? const Color(0xFFE74C3C) : Colors.white70, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, ), ), @@ -177,46 +180,61 @@ class _TeamsPageState extends State { return StreamBuilder>>( stream: controller.teamsStream, builder: (context, snapshot) { - if (!snapshot.hasData) return const Center(child: CircularProgressIndicator()); + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } - var data = snapshot.data!; + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return const Center(child: Text("Nenhuma equipa encontrada.")); + } - // 1. Filtro Temporada + var data = List>.from(snapshot.data!); + + // --- 1. FILTROS --- if (_selectedSeason != 'Todas') { data = data.where((t) => t['season'] == _selectedSeason).toList(); } - - // 2. Filtro Pesquisa if (_searchQuery.isNotEmpty) { data = data.where((t) => t['name'].toString().toLowerCase().contains(_searchQuery)).toList(); } - // 3. Ordenação (O controller já lida com favoritos, aqui aplicamos a manual) - if (_currentSort == 'Recentes') { - data.sort((a, b) => b['id'].compareTo(a['id'])); - } else if (_currentSort == 'Nome') { - data.sort((a, b) => a['name'].toString().compareTo(b['name'].toString())); - } else if (_currentSort == 'Tamanho') { - data.sort((a, b) { - int countA = TeamController.members.where((m) => m['team_id'] == a['id']).length; - int countB = TeamController.members.where((m) => m['team_id'] == b['id']).length; - return countB.compareTo(countA); - }); - } + // --- 2. ORDENAÇÃO (FAVORITOS PRIMEIRO) --- + data.sort((a, b) { + // Apanhar o estado de favorito (tratando null como false) + bool favA = a['is_favorite'] ?? false; + bool favB = b['is_favorite'] ?? false; - if (data.isEmpty) { - return const Center(child: Text("Nenhuma equipa encontrada.")); - } + // REGRA 1: Favoritos aparecem sempre primeiro + if (favA && !favB) return -1; // A sobe + if (!favA && favB) return 1; // B sobe + + // REGRA 2: Se o estado de favorito for igual, aplica o filtro do utilizador + if (_currentSort == 'Nome') { + return a['name'].toString().compareTo(b['name'].toString()); + } else { // Recentes + return (b['created_at'] ?? '').toString().compareTo((a['created_at'] ?? '').toString()); + } + }); return ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: data.length, itemBuilder: (context, index) { final team = Team.fromMap(data[index]); - return TeamCard( - team: team, - controller: controller, - onFavoriteTap: () => controller.toggleFavorite(team.id), + + // Navegação para estatísticas + 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), + ), ); }, ); @@ -232,4 +250,5 @@ class _TeamsPageState extends State { ), ); } + } \ No newline at end of file diff --git a/lib/screens/team_stats_page.dart b/lib/screens/team_stats_page.dart index aa3a7ce..c720111 100644 --- a/lib/screens/team_stats_page.dart +++ b/lib/screens/team_stats_page.dart @@ -1,15 +1,30 @@ -import 'package:flutter/material.dart'; -import 'package:playmaker/controllers/team_controller.dart'; import 'dart:async'; +import 'package:flutter/material.dart'; import '../models/team_model.dart'; import '../models/person_model.dart'; -import '../controllers/team_controllers.dart'; // Para aceder à lista estática de membros +// Se tiveres os widgets noutro ficheiro, importa-os. +// Caso contrário, podes colar as classes StatsHeader, etc. no fundo deste ficheiro. +import '../widgets/team_widgets.dart'; +import '../widgets/stats_widgets.dart'; // Assumindo que criaste este ficheiro anteriormente -class TeamStatsPage extends StatelessWidget { +class TeamStatsPage extends StatefulWidget { final Team team; + + const TeamStatsPage({super.key, required this.team}); + + @override + State createState() => _TeamStatsPageState(); +} + +class _TeamStatsPageState extends State { + // Instancia o controlador local final StatsController _controller = StatsController(); - TeamStatsPage({super.key, required this.team}); + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { @@ -17,10 +32,13 @@ class TeamStatsPage extends StatelessWidget { backgroundColor: const Color(0xFFF5F7FA), body: Column( children: [ - _buildLocalHeader(context), + // Header (Widget que criámos antes) + StatsHeader(team: widget.team), + Expanded( child: StreamBuilder>( - stream: _controller.getMembers(team.id), + // LÊ DA LISTA LOCAL + stream: _controller.getMembers(widget.team.id), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); @@ -35,21 +53,36 @@ class TeamStatsPage extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildLocalSummaryCard(members.length), + // Resumo + StatsSummaryCard(total: members.length), const SizedBox(height: 30), + + // Secção Treinadores if (coaches.isNotEmpty) ...[ - _buildSectionTitle("Treinadores"), - ...coaches.map((c) => _buildPersonCard(context, c, isCoach: true)), + const StatsSectionTitle(title: "Treinadores"), + ...coaches.map((c) => PersonCard( + person: c, + isCoach: true, + onEdit: () => _controller.showEditPersonDialog(context, widget.team.id, c), + onDelete: () => _confirmDelete(context, c), + )), const SizedBox(height: 30), ], - _buildSectionTitle("Jogadores"), + + // Secção Jogadores + const StatsSectionTitle(title: "Jogadores"), if (players.isEmpty) const Padding( padding: EdgeInsets.only(top: 20), child: Text("Nenhum jogador adicionado.", style: TextStyle(color: Colors.grey)), ) else - ...players.map((p) => _buildPersonCard(context, p, isCoach: false)), + ...players.map((p) => PersonCard( + person: p, + isCoach: false, + onEdit: () => _controller.showEditPersonDialog(context, widget.team.id, p), + onDelete: () => _confirmDelete(context, p), + )), const SizedBox(height: 80), ], ), @@ -60,100 +93,14 @@ class TeamStatsPage extends StatelessWidget { ], ), floatingActionButton: FloatingActionButton( - onPressed: () => _controller.showAddPersonDialog(context, team.id), + heroTag: 'player_fab', + onPressed: () => _controller.showAddPersonDialog(context, widget.team.id), backgroundColor: const Color(0xFF00C853), child: const Icon(Icons.add, color: Colors.white), ), ); } - // --- WIDGETS QUE ESTAVAM EM STATS_WIDGETS --- - - Widget _buildLocalHeader(BuildContext context) { - return Container( - padding: const EdgeInsets.only(top: 50, left: 20, right: 20, bottom: 20), - decoration: const BoxDecoration( - color: Color(0xFF2C3E50), - borderRadius: BorderRadius.only(bottomLeft: Radius.circular(30), bottomRight: Radius.circular(30)), - ), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - const SizedBox(width: 10), - CircleAvatar( - backgroundColor: Colors.white24, - child: Text(team.imageUrl.isNotEmpty && !team.imageUrl.startsWith('http') ? team.imageUrl : "🏀"), - ), - const SizedBox(width: 15), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(team.name, style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold)), - Text(team.season, style: const TextStyle(color: Colors.white70, fontSize: 14)), - ], - ), - ], - ), - ); - } - - Widget _buildLocalSummaryCard(int total) { - return Card( - elevation: 4, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - gradient: LinearGradient(colors: [Colors.blue.shade700, Colors.blue.shade400]), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text("Total de Membros", style: TextStyle(color: Colors.white, fontSize: 16)), - Text("$total", style: const TextStyle(color: Colors.white, fontSize: 28, fontWeight: FontWeight.bold)), - ], - ), - ), - ); - } - - Widget _buildPersonCard(BuildContext context, Person person, {required bool isCoach}) { - return Card( - margin: const EdgeInsets.only(top: 12), - elevation: 2, - color: isCoach ? const Color(0xFFFFF9C4) : Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), - child: ListTile( - leading: isCoach - ? const CircleAvatar(backgroundColor: Colors.orange, child: Icon(Icons.person, color: Colors.white)) - : Container( - width: 45, 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)), - ), - title: Text(person.name, style: const TextStyle(fontWeight: FontWeight.bold)), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.edit_outlined, color: Colors.blue), - onPressed: () => _controller.showEditPersonDialog(context, team.id, person), - ), - IconButton( - icon: const Icon(Icons.delete_outline, color: Colors.red), - onPressed: () => _confirmDelete(context, person), - ), - ], - ), - ), - ); - } - void _confirmDelete(BuildContext context, Person person) { showDialog( context: context, @@ -164,7 +111,7 @@ class TeamStatsPage extends StatelessWidget { TextButton(onPressed: () => Navigator.pop(context), child: const Text("Cancelar")), TextButton( onPressed: () { - _controller.deletePerson(team.id, person.id); + _controller.deletePerson(widget.team.id, person.id); Navigator.pop(context); }, child: const Text("Eliminar", style: TextStyle(color: Colors.red)), @@ -173,41 +120,47 @@ class TeamStatsPage extends StatelessWidget { ), ); } - - Widget _buildSectionTitle(String title) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Color(0xFF2C3E50))), - const Divider(), - ], - ); - } } // --- CONTROLLER LOCAL (SEM SUPABASE) --- class StatsController { - final _streamController = StreamController>.broadcast(); + // Base de dados simulada na memória (Estática para não perder dados ao mudar de ecrã) + static final List> _mockMembers = [ + // Podes deixar vazio ou meter dados de teste + // {'id': '1', 'team_id': 'exemplo', 'name': 'Mourinho', 'type': 'Treinador', 'number': null}, + ]; + // Stream para atualizar a UI automaticamente + final StreamController> _streamController = StreamController>.broadcast(); + + // 1. LER (Filtra a lista local) Stream> getMembers(String teamId) { - _emitMembers(teamId); + // Pequeno delay para garantir que o Stream ouve a primeira emissão + Future.delayed(Duration.zero, () => _emitMembers(teamId)); return _streamController.stream; } + // Função auxiliar para atualizar quem está a ouvir void _emitMembers(String teamId) { - final list = TeamController.members + final filtered = _mockMembers .where((m) => m['team_id'] == teamId) - .map((json) => Person.fromMap(json)) + .map((m) => Person.fromMap(m)) .toList(); - _streamController.add(list); + + // Ordenar por nome + filtered.sort((a, b) => a.name.compareTo(b.name)); + + _streamController.add(filtered); } + // 2. APAGAR LOCALMENTE void deletePerson(String teamId, String personId) { - TeamController.members.removeWhere((m) => m['id'] == personId); - _emitMembers(teamId); + _mockMembers.removeWhere((m) => m['id'] == personId); + _emitMembers(teamId); // Atualiza o ecrã } + // 3. DIÁLOGOS void showAddPersonDialog(BuildContext context, String teamId) { _showForm(context, teamId: teamId); } @@ -220,7 +173,7 @@ class StatsController { final isEdit = person != null; final nameCtrl = TextEditingController(text: person?.name ?? ''); final numCtrl = TextEditingController(text: person?.number ?? ''); - String type = person?.type ?? 'Jogador'; + String selectedType = person?.type ?? 'Jogador'; showDialog( context: context, @@ -230,41 +183,80 @@ class StatsController { content: Column( mainAxisSize: MainAxisSize.min, children: [ - TextField(controller: nameCtrl, decoration: const InputDecoration(labelText: "Nome")), - DropdownButton( - value: type, - isExpanded: true, - items: ["Jogador", "Treinador"].map((e) => DropdownMenuItem(value: e, child: Text(e))).toList(), - onChanged: (v) => setState(() => type = v!), + TextField( + controller: nameCtrl, + decoration: const InputDecoration(labelText: "Nome"), + textCapitalization: TextCapitalization.sentences, ), - if (type == "Jogador") - TextField(controller: numCtrl, decoration: const InputDecoration(labelText: "Número"), keyboardType: TextInputType.number), + const SizedBox(height: 10), + 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); + }, + ), + if (selectedType == "Jogador") ...[ + const SizedBox(height: 10), + TextField( + controller: numCtrl, + decoration: const InputDecoration(labelText: "Número da Camisola"), + keyboardType: TextInputType.text, + ), + ] ], ), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("Sair")), + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text("Cancelar") + ), ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF00C853)), onPressed: () { + if (nameCtrl.text.trim().isEmpty) return; + + String? numeroFinal = (selectedType == "Treinador") + ? null + : (numCtrl.text.trim().isEmpty ? null : numCtrl.text.trim()); + if (isEdit) { - final idx = TeamController.members.indexWhere((m) => m['id'] == person.id); - TeamController.members[idx] = {'id': person.id, 'team_id': teamId, 'name': nameCtrl.text, 'type': type, 'number': numCtrl.text}; + // ATUALIZAR NA LISTA LOCAL + final index = _mockMembers.indexWhere((m) => m['id'] == person!.id); + if (index != -1) { + _mockMembers[index] = { + 'id': person!.id, + 'team_id': teamId, + 'name': nameCtrl.text.trim(), + 'type': selectedType, + 'number': numeroFinal, + }; + } } else { - TeamController.members.add({ - 'id': DateTime.now().toString(), + // CRIAR NA LISTA LOCAL (Gera ID com a data atual) + _mockMembers.add({ + 'id': DateTime.now().millisecondsSinceEpoch.toString(), 'team_id': teamId, - 'name': nameCtrl.text, - 'type': type, - 'number': numCtrl.text + 'name': nameCtrl.text.trim(), + 'type': selectedType, + 'number': numeroFinal, }); } + + // Atualiza a UI _emitMembers(teamId); Navigator.pop(ctx); }, - child: const Text("Guardar"), + child: const Text("Guardar", style: TextStyle(color: Colors.white)), ) ], ), ), ); - } - } + } + + void dispose() { + _streamController.close(); + } +} \ No newline at end of file diff --git a/lib/widgets/game_widgets.dart b/lib/widgets/game_widgets.dart new file mode 100644 index 0000000..71e4089 --- /dev/null +++ b/lib/widgets/game_widgets.dart @@ -0,0 +1,220 @@ +import 'package:flutter/material.dart'; +import 'package:playmaker/pages/PlacarPage.dart'; +import '../controllers/team_controller.dart'; + +// --- CARD DE EXIBIÇÃO DO JOGO --- +class GameResultCard extends StatelessWidget { + final String gameId; // Adicionado para identificar o jogo no retorno + final String myTeam, opponentTeam, myScore, opponentScore, status, season; + + 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, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10)], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildTeamInfo(myTeam, const Color(0xFFE74C3C)), + // Agora passamos o context e o gameId para o centro + _buildScoreCenter(context, gameId), + _buildTeamInfo(opponentTeam, Colors.black87), + ], + ), + ); + } + + Widget _buildTeamInfo(String name, Color color) { + return Column( + children: [ + CircleAvatar( + backgroundColor: color, + child: const Icon(Icons.shield, color: Colors.white) + ), + const SizedBox(height: 4), + Text(name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12)), + ], + ); + } + + Widget _buildScoreCenter(BuildContext context, String id) { + return Column( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _scoreBox(myScore, Colors.green), + const Text(" : ", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)), + _scoreBox(opponentScore, Colors.grey), + ], + ), + const SizedBox(height: 8), + + // BOTÃO PARA RETORNAR AO JOGO + TextButton.icon( + onPressed: () { + print("Navegando para o marcador do jogo: $id"); + // Aqui faremos a navegação para a página do marcador em breve + }, + icon: const Icon(Icons.play_circle_fill, size: 16, color: Color(0xFFE74C3C)), + label: const Text( + "RETORNAR", + style: TextStyle( + fontSize: 10, + color: Color(0xFFE74C3C), + fontWeight: FontWeight.bold + ), + ), + style: TextButton.styleFrom( + backgroundColor: const Color(0xFFE74C3C).withOpacity(0.1), + padding: const EdgeInsets.symmetric(horizontal: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + visualDensity: VisualDensity.compact, + ), + ), + + const SizedBox(height: 4), + Text(status, style: const TextStyle(fontSize: 10, color: Colors.blue, fontWeight: FontWeight.bold)), + ], + ); + } + + Widget _scoreBox(String pts, Color c) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(8)), + child: Text(pts, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + ); +} + +// --- POPUP DE CRIAÇÃO --- +class CreateGameDialogManual extends StatefulWidget { + final TeamController controller; + final Function(String, String, String) onConfirm; + + const CreateGameDialogManual({super.key, required this.controller, required this.onConfirm}); + + @override + State createState() => _CreateGameDialogManualState(); +} + +class _CreateGameDialogManualState extends State { + late TextEditingController _seasonController; + String _myTeamName = ""; + String _opponentName = ""; + + @override + void initState() { + super.initState(); + _seasonController = TextEditingController(text: _calculateSeason()); + } + + @override + void dispose() { + _seasonController.dispose(); + super.dispose(); + } + + 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)), + title: const Text('Configurar Partida', style: TextStyle(fontWeight: FontWeight.bold)), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _seasonController, + decoration: const InputDecoration( + labelText: 'Temporada', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.calendar_today, size: 20), + ), + ), + const SizedBox(height: 15), + _buildSearch(label: "Minha Equipa", onSelect: (v) => _myTeamName = v), + const Padding(padding: EdgeInsets.symmetric(vertical: 8), child: Text("VS", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey))), + _buildSearch(label: "Adversário", onSelect: (v) => _opponentName = v), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('CANCELAR')), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFE74C3C), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + onPressed: () { + if (_myTeamName.isNotEmpty && _opponentName.isNotEmpty) { + // 1. Criar um ID único para este jogo + final String newGameId = DateTime.now().toString(); + + // 2. Notificar a GamePage para criar o card (via onConfirm) + widget.onConfirm(_myTeamName, _opponentName, _seasonController.text); + + // 3. Fechar o popup de criação + Navigator.pop(context); + + // 4. Ir direto para a tela do marcador de pontos + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PlacarPage( + gameId: newGameId, + myTeam: _myTeamName, + opponentTeam: _opponentName, + ), + ), + ); + } + }, + child: const Text('CRIAR', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), +), + ], + ); + } + + Widget _buildSearch({required String label, required Function(String) onSelect}) { + return StreamBuilder>>( + stream: widget.controller.teamsStream, + builder: (context, snapshot) { + List teamList = snapshot.hasData ? snapshot.data!.map((t) => t['name'].toString()).toList() : []; + return Autocomplete( + optionsBuilder: (val) => val.text.isEmpty ? [] : teamList.where((t) => t.toLowerCase().contains(val.text.toLowerCase())), + fieldViewBuilder: (ctx, ctrl, node, submit) => TextField( + controller: ctrl, + focusNode: node, + onChanged: onSelect, + decoration: InputDecoration( + labelText: label, + prefixIcon: const Icon(Icons.search), + border: const OutlineInputBorder() + ), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/widgets/stats_widgets.dart b/lib/widgets/stats_widgets.dart new file mode 100644 index 0000000..aeab62e --- /dev/null +++ b/lib/widgets/stats_widgets.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import '../models/team_model.dart'; +import '../models/person_model.dart'; + +// --- CABEÇALHO --- +class StatsHeader extends StatelessWidget { + final Team team; + + const StatsHeader({super.key, required this.team}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.only(top: 50, left: 20, right: 20, bottom: 20), + decoration: const BoxDecoration( + color: Color(0xFF2C3E50), + borderRadius: BorderRadius.only(bottomLeft: Radius.circular(30), bottomRight: Radius.circular(30)), + ), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 10), + CircleAvatar( + backgroundColor: Colors.white24, + child: Text(team.imageUrl.isNotEmpty ? "📷" : "🛡️"), + ), + const SizedBox(width: 15), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(team.name, style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold)), + Text(team.season, style: const TextStyle(color: Colors.white70, fontSize: 14)), + ], + ), + ], + ), + ); + } +} + +// --- CARD DE RESUMO --- +class StatsSummaryCard extends StatelessWidget { + final int total; + + const StatsSummaryCard({super.key, required this.total}); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient(colors: [Colors.blue.shade700, Colors.blue.shade400]), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text("Total de Membros", style: TextStyle(color: Colors.white, fontSize: 16)), + Text("$total", style: const TextStyle(color: Colors.white, fontSize: 28, fontWeight: FontWeight.bold)), + ], + ), + ), + ); + } +} + +// --- TÍTULO DE SECÇÃO --- +class StatsSectionTitle extends StatelessWidget { + final String title; + + const StatsSectionTitle({super.key, required this.title}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Color(0xFF2C3E50))), + const Divider(), + ], + ); + } +} + +// --- CARD DA PESSOA (JOGADOR/TREINADOR) --- +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, + }); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(top: 12), + elevation: 2, + color: isCoach ? const Color(0xFFFFF9C4) : Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), + child: ListTile( + leading: isCoach + ? const CircleAvatar(backgroundColor: Colors.orange, child: Icon(Icons.person, color: Colors.white)) + : Container( + width: 45, + 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)), + ), + title: Text(person.name, style: const TextStyle(fontWeight: FontWeight.bold)), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit_outlined, color: Colors.blue), + onPressed: onEdit, + ), + IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.red), + onPressed: onDelete, + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/team_widgets.dart b/lib/widgets/team_widgets.dart index 8f85cf0..5f2f4fc 100644 --- a/lib/widgets/team_widgets.dart +++ b/lib/widgets/team_widgets.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:playmaker/screens/team_stats_page.dart'; import '../models/team_model.dart'; import '../controllers/team_controller.dart'; -import '../screens/team_stats_page.dart'; + class TeamCard extends StatelessWidget { final Team team; final TeamController controller; @@ -17,40 +18,37 @@ class TeamCard extends StatelessWidget { @override Widget build(BuildContext context) { return Card( - color: Colors.white, + color: Colors.white, elevation: 3, margin: const EdgeInsets.only(bottom: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), child: ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - leading: Stack( - clipBehavior: Clip.none, // Permite que a estrela flutue ligeiramente fora do círculo + // --- 1. IMAGEM + FAVORITO --- + leading: Stack( + clipBehavior: Clip.none, children: [ - // 1. IMAGEM DA EQUIPA CircleAvatar( radius: 28, backgroundColor: Colors.grey[200], - backgroundImage: (team.imageUrl.isNotEmpty && team.imageUrl.startsWith('http')) - ? NetworkImage(team.imageUrl) + backgroundImage: (team.imageUrl.isNotEmpty && team.imageUrl.startsWith('http')) + ? NetworkImage(team.imageUrl) : null, - child: (team.imageUrl.isEmpty || !team.imageUrl.startsWith('http')) + child: (team.imageUrl.isEmpty || !team.imageUrl.startsWith('http')) ? Text( team.imageUrl.isEmpty ? "🏀" : team.imageUrl, style: const TextStyle(fontSize: 24), - ) + ) : null, ), - - // 2. BOTÃO DA ESTRELA (Favorito) Positioned( - left: -15, // Posiciona à esquerda da imagem + left: -15, top: -10, child: IconButton( - // O segredo está em colocar o shadow dentro do Icon: icon: Icon( team.isFavorite ? Icons.star : Icons.star_border, - color: team.isFavorite ? Colors.amber : Colors.black.withOpacity(0.1), // Transparente se não favorito + color: team.isFavorite ? Colors.amber : Colors.black.withOpacity(0.1), size: 28, shadows: [ Shadow( @@ -65,13 +63,13 @@ class TeamCard extends StatelessWidget { ], ), - // --- NOME DA EQUIPA --- + // --- 2. TÍTULO --- title: Text( team.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), ), - // --- SUBTÍTULO (CONTAGEM E TEMPORADA) --- + // --- 3. SUBTÍTULO (Contagem + Época) --- subtitle: Padding( padding: const EdgeInsets.only(top: 6.0), child: Row( @@ -102,9 +100,9 @@ class TeamCard extends StatelessWidget { ), ), - // --- BOTÕES DE ACÇÃO À DIREITA --- + // --- 4. BOTÕES (Estatísticas e Apagar) --- trailing: SizedBox( - width: 80, + width: 96, // Aumentei um pouco para caberem bem os dois botões child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ @@ -112,6 +110,7 @@ class TeamCard extends StatelessWidget { tooltip: 'Ver Estatísticas', icon: const Icon(Icons.bar_chart_rounded, color: Colors.blue), onPressed: () { + // CORRIGIDO: Agora chama a classe TeamStatsPage corretamente Navigator.push( context, MaterialPageRoute( @@ -131,6 +130,8 @@ class TeamCard extends StatelessWidget { ), ); } + + // Função de confirmação de exclusão void _confirmDelete(BuildContext context) { showDialog( context: context, @@ -139,22 +140,23 @@ class TeamCard extends StatelessWidget { content: Text('Tens a certeza que queres eliminar "${team.name}"?'), actions: [ TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancelar') + onPressed: () => Navigator.pop(context), + child: const Text('Cancelar'), ), TextButton( onPressed: () { - controller.deleteTeam(team.id); + controller.deleteTeam(team.id); Navigator.pop(context); }, - child: const Text('Eliminar', style: TextStyle(color: Colors.red)) -), + child: const Text('Eliminar', style: TextStyle(color: Colors.red)), + ), ], ), ); } } +// --- DIALOG DE CRIAÇÃO --- class CreateTeamDialog extends StatefulWidget { final Function(String name, String season, String imageUrl) onConfirm; @@ -206,16 +208,16 @@ class _CreateTeamDialogState extends State { TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancelar')), ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFFE74C3C)), -onPressed: () { - if (_nameController.text.trim().isNotEmpty) { - widget.onConfirm( - _nameController.text.trim(), - _selectedSeason, - _imageController.text.trim() - ); - Navigator.pop(context); - } -}, + onPressed: () { + if (_nameController.text.trim().isNotEmpty) { + widget.onConfirm( + _nameController.text.trim(), + _selectedSeason, + _imageController.text.trim(), + ); + Navigator.pop(context); + } + }, child: const Text('Criar', style: TextStyle(color: Colors.white)), ), ], diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig index c2efd0b..4b81f9b 100644 --- a/macos/Flutter/Flutter-Debug.xcconfig +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig index c2efd0b..5caa9d1 100644 --- a/macos/Flutter/Flutter-Release.xcconfig +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end