This commit is contained in:
2026-02-03 17:33:40 +00:00
parent 2a9661978c
commit 74453f7cbc
22 changed files with 1082 additions and 639 deletions

View File

@@ -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"))
}
}

View File

@@ -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,6 +26,7 @@ subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
@@ -22,9 +34,3 @@ subprojects {
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
buildscript {
dependencies {
// Adicione esta linha:
classpath 'com.google.gms:google-services:4.4.0'
}
}

View File

@@ -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
}

View File

@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"

View File

@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"

43
ios/Podfile Normal file
View File

@@ -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

View File

@@ -0,0 +1,25 @@
import 'dart:async';
import '../models/game_model.dart';
class GameController {
final List<Game> _games = [];
final _gameStreamController = StreamController<List<Game>>.broadcast();
Stream<List<Game>> 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();
}
}

View File

@@ -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<List<Person>> 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<void> deletePerson(String teamId, String personId) async {
// 2. APAGAR
Future<void> 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<String>(
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<String>(
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)),
)
],
),
),
);
}
}

View File

@@ -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<Map<String, dynamic>> _teams = [];
static final List<Map<String, dynamic>> _members = [];
// Instância do cliente Supabase
final _supabase = Supabase.instance.client;
static List<Map<String, dynamic>> get members => _members;
// StreamController broadcast para permitir múltiplos ouvintes (ex: Home e TeamsPage)
final _streamController = StreamController<List<Map<String, dynamic>>>.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<Map>
Stream<List<Map<String, dynamic>>> get teamsStream {
_notifyListeners();
return _streamController.stream;
return _supabase
.from('teams')
.stream(primaryKey: ['id'])
.order('name', ascending: true)
.map((data) => List<Map<String, dynamic>>.from(data));
}
// 2. CRIAR
Future<void> 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<void> 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<void> 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<void> 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<void> 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<int> 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() {}
}

View File

@@ -1 +0,0 @@
// TODO Implement this library.

View File

@@ -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,
});
}

View File

@@ -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<String, dynamic> 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<String, dynamic> toMap() {
return {
'id': id,
'name': name,
'season': season,
'image_url': imageUrl,

139
lib/pages/PlacarPage.dart Normal file
View File

@@ -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<PlacarPage> createState() => _PlacarPageState();
}
class _PlacarPageState extends State<PlacarPage> {
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(),
),
],
),
);
}
}

View File

@@ -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<GamePage> {
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<String> 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<GamePage> {
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<List<Game>>(
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),
),
);
}
void _showCreateDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => CreateGameDialogManual(
controller: teamController,
onConfirm: (my, opp, sea) {
gameController.addGame(my, opp, sea);
},
),
);
}
}
class CreateGameDialogManual extends StatefulWidget {
final TeamController controller;
const CreateGameDialogManual({super.key, required this.controller});
@override
State<CreateGameDialogManual> createState() => _CreateGameDialogManualState();
}
class _CreateGameDialogManualState extends State<CreateGameDialogManual> {
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<String> options,
required Function(String) onSelected,
}) {
return Autocomplete<String>(
optionsBuilder: (TextEditingValue textEditingValue) {
if (textEditingValue.text.isEmpty) {
return const Iterable<String>.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<List<Map<String, dynamic>>>(
stream: widget.controller.teamsStream,
builder: (context, snapshot) {
// Lista de nomes das equipas que vêm da TeamsPage
List<String> 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)),
),
],
);
},
);
}
}

View File

@@ -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<TeamsPage> {
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<TeamsPage> {
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<TeamsPage> {
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<TeamsPage> {
return StreamBuilder<List<Map<String, dynamic>>>(
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<Map<String, dynamic>>.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<TeamsPage> {
),
);
}
}

View File

@@ -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<TeamStatsPage> createState() => _TeamStatsPageState();
}
class _TeamStatsPageState extends State<TeamStatsPage> {
// 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<List<Person>>(
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<List<Person>>.broadcast();
// Base de dados simulada na memória (Estática para não perder dados ao mudar de ecrã)
static final List<Map<String, dynamic>> _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<List<Person>> _streamController = StreamController<List<Person>>.broadcast();
// 1. LER (Filtra a lista local)
Stream<List<Person>> 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<String>(
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<String>(
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();
}
}

View File

@@ -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<CreateGameDialogManual> createState() => _CreateGameDialogManualState();
}
class _CreateGameDialogManualState extends State<CreateGameDialogManual> {
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<List<Map<String, dynamic>>>(
stream: widget.controller.teamsStream,
builder: (context, snapshot) {
List<String> teamList = snapshot.hasData ? snapshot.data!.map((t) => t['name'].toString()).toList() : [];
return Autocomplete<String>(
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()
),
),
);
},
);
}
}

View File

@@ -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,
),
],
),
),
);
}
}

View File

@@ -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;
@@ -24,10 +25,10 @@ class TeamCard extends StatelessWidget {
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],
@@ -41,16 +42,13 @@ class TeamCard extends StatelessWidget {
)
: 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,
@@ -140,21 +141,22 @@ class TeamCard extends StatelessWidget {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancelar')
child: const Text('Cancelar'),
),
TextButton(
onPressed: () {
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<CreateTeamDialog> {
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)),
),
],

View File

@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"

View File

@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"

42
macos/Podfile Normal file
View File

@@ -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