diff --git a/lib/controllers/team_controller.dart b/lib/controllers/team_controller.dart index 565bb39..51f43d8 100644 --- a/lib/controllers/team_controller.dart +++ b/lib/controllers/team_controller.dart @@ -2,30 +2,31 @@ import 'dart:async'; 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 = []; - - // Lista de jogadores (necessária para a contagem funcionar) static final List> _members = []; - // Getter para partilhar com outros controllers 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 Stream>> get teamsStream { - Future.microtask(() => _streamController.add(_teams)); + _notifyListeners(); return _streamController.stream; } // 2. CRIAR Future createTeam(String name, String season, String imageUrl) async { - await Future.delayed(const Duration(milliseconds: 100)); + 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(); @@ -34,20 +35,43 @@ class TeamController { // 3. ELIMINAR Future deleteTeam(String id) async { _teams.removeWhere((team) => team['id'] == id); - // Remove também os jogadores dessa equipa para não ficarem "órfãos" _members.removeWhere((member) => member['team_id'] == id); _notifyListeners(); } - // 4. CONTAR JOGADORES (Lógica Local) - Future getPlayerCount(String teamId) async { - // Filtra a lista _members e conta quantos pertencem a esta equipa - final count = _members.where((m) => m['team_id'] == teamId).length; - return count; + // 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(); + } } + // 5. CONTAR JOGADORES + Future getPlayerCount(String teamId) async { + return _members.where((m) => m['team_id'] == teamId).length; + } + + // 6. NOTIFICAR E ORDENAR (Única versão corrigida) void _notifyListeners() { - _streamController.add(_teams); + 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() { diff --git a/lib/models/team_model.dart b/lib/models/team_model.dart index b30efff..c5d51ac 100644 --- a/lib/models/team_model.dart +++ b/lib/models/team_model.dart @@ -3,12 +3,14 @@ class Team { final String name; final String season; final String imageUrl; + final bool isFavorite; Team({ required this.id, required this.name, required this.season, required this.imageUrl, + this.isFavorite = false }); // Converte de Mapa (o formato da nossa "memória") para Objeto @@ -18,6 +20,7 @@ class Team { name: map['name'] ?? '', season: map['season'] ?? '', imageUrl: map['image_url'] ?? '', + isFavorite: map['is_favorite'] ?? false, ); } @@ -28,6 +31,7 @@ class Team { 'name': name, 'season': season, 'image_url': imageUrl, + 'is_favorite': isFavorite, }; } } \ No newline at end of file diff --git a/lib/pages/teamPage.dart b/lib/pages/teamPage.dart index 31d01e5..e18917e 100644 --- a/lib/pages/teamPage.dart +++ b/lib/pages/teamPage.dart @@ -12,72 +12,223 @@ class TeamsPage extends StatefulWidget { class _TeamsPageState extends State { final TeamController controller = TeamController(); + final TextEditingController _searchController = TextEditingController(); + + String _selectedSeason = 'Todas'; + String _currentSort = 'Recentes'; + String _searchQuery = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + // --- POPUP DE FILTROS (ESTILO DIALOG CENTRAL) --- + void _showFilterDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setModalState) { + return AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + "Filtros de pesquisa", + style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold), + ), + IconButton( + icon: const Icon(Icons.close, color: Colors.white, size: 20), + onPressed: () => Navigator.pop(context), + ) + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Divider(color: Colors.white24), + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Coluna Temporada + Expanded( + child: _buildPopupColumn( + title: "TEMPORADA", + options: ['Todas', '2023/24', '2024/25', '2025/26'], + currentValue: _selectedSeason, + onSelect: (val) { + setState(() => _selectedSeason = val); + setModalState(() {}); + }, + ), + ), + const SizedBox(width: 20), + // Coluna Ordenar + Expanded( + child: _buildPopupColumn( + title: "ORDENAR POR", + options: ['Recentes', 'Nome', 'Tamanho'], + currentValue: _currentSort, + onSelect: (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({ + required String title, + required List options, + required String currentValue, + required Function(String) onSelect, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(color: Colors.grey, fontSize: 11, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + ...options.map((opt) { + final isSelected = currentValue == opt; + return InkWell( + onTap: () => onSelect(opt), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + opt, + style: TextStyle( + color: isSelected ? const Color(0xFFE74C3C) : Colors.black, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ); + }).toList(), + ], + ); + } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF5F7FA), - - // Título simples no topo (opcional, já tens a BottomNavBar) - // appBar: AppBar(title: Text("Minhas Equipas"), automaticallyImplyLeading: false), - - body: StreamBuilder>>( - stream: controller.teamsStream, - builder: (context, snapshot) { - // Estado de Loading - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - - // Estado Vazio - if (!snapshot.hasData || snapshot.data!.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - Icon(Icons.sports_basketball_outlined, size: 60, color: Colors.grey), - SizedBox(height: 16), - Text( - 'Ainda não tens equipas.', - style: TextStyle(color: Colors.grey, fontSize: 16), - ), - Text( - 'Clica no + para criar.', - style: TextStyle(color: Colors.grey, fontSize: 14), - ), - ], - ), - ); - } - - final teamsData = snapshot.data!; - - // Lista de Equipas - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: teamsData.length, - itemBuilder: (context, index) { - final team = Team.fromMap(teamsData[index]); - return TeamCard(team: team, controller: controller); - }, - ); - }, + appBar: AppBar( + title: const Text("Minhas Equipas", style: TextStyle(fontWeight: FontWeight.bold)), + backgroundColor: const Color(0xFFF5F7FA), + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.filter_list, color: Color(0xFFE74C3C)), + onPressed: () => _showFilterDialog(context), + ), + ], + ), + body: Column( + children: [ + _buildSearchBar(), + Expanded(child: _buildTeamsList()), + ], ), - - // --- O BOTÃO FLUTUANTE QUE PEDISTE --- floatingActionButton: FloatingActionButton( - onPressed: () { - showDialog( - context: context, - builder: (context) => CreateTeamDialog( - onConfirm: (name, season, imageUrl) { - controller.createTeam(name, season, imageUrl); - }, - ), - ); - }, - backgroundColor: const Color(0xFFE74C3C), // Cor Vermelha + backgroundColor: const Color(0xFFE74C3C), child: const Icon(Icons.add, color: Colors.white), + onPressed: () => _showCreateDialog(context), + ), + ); + } + + Widget _buildSearchBar() { + return Padding( + padding: const EdgeInsets.all(16.0), + child: TextField( + controller: _searchController, + onChanged: (v) => setState(() => _searchQuery = v.toLowerCase()), + decoration: InputDecoration( + hintText: 'Pesquisar equipa...', + prefixIcon: const Icon(Icons.search, color: Color(0xFFE74C3C)), + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(15), borderSide: BorderSide.none), + ), + ), + ); + } + + Widget _buildTeamsList() { + return StreamBuilder>>( + stream: controller.teamsStream, + builder: (context, snapshot) { + if (!snapshot.hasData) return const Center(child: CircularProgressIndicator()); + + var data = snapshot.data!; + + // 1. Filtro Temporada + 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); + }); + } + + if (data.isEmpty) { + return const Center(child: Text("Nenhuma equipa encontrada.")); + } + + 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), + ); + }, + ); + }, + ); + } + + void _showCreateDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => CreateTeamDialog( + onConfirm: (name, season, imageUrl) => controller.createTeam(name, season, imageUrl), ), ); } diff --git a/lib/widgets/team_widgets.dart b/lib/widgets/team_widgets.dart index c243a72..8f85cf0 100644 --- a/lib/widgets/team_widgets.dart +++ b/lib/widgets/team_widgets.dart @@ -1,65 +1,91 @@ import 'package:flutter/material.dart'; import '../models/team_model.dart'; import '../controllers/team_controller.dart'; -// Importa a tua página de stats (verifica se o caminho está correto) import '../screens/team_stats_page.dart'; class TeamCard extends StatelessWidget { final Team team; final TeamController controller; + final VoidCallback onFavoriteTap; const TeamCard({ - super.key, - required this.team, + super.key, + required this.team, required this.controller, + required this.onFavoriteTap, }); - @override + @override Widget build(BuildContext context) { return Card( + 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: CircleAvatar( - backgroundColor: Colors.grey[200], - backgroundImage: (team.imageUrl.isNotEmpty && team.imageUrl.startsWith('http')) - ? NetworkImage(team.imageUrl) - : null, - child: (team.imageUrl.isEmpty || !team.imageUrl.startsWith('http')) - ? Text( - team.imageUrl.isEmpty ? "🏀" : team.imageUrl, - style: const TextStyle(fontSize: 20), - ) - : null, -), - // 2. NOME DA EQUIPA + leading: Stack( + clipBehavior: Clip.none, // Permite que a estrela flutue ligeiramente fora do círculo + children: [ + // 1. IMAGEM DA EQUIPA + CircleAvatar( + radius: 28, + backgroundColor: Colors.grey[200], + backgroundImage: (team.imageUrl.isNotEmpty && team.imageUrl.startsWith('http')) + ? NetworkImage(team.imageUrl) + : null, + child: (team.imageUrl.isEmpty || !team.imageUrl.startsWith('http')) + ? Text( + team.imageUrl.isEmpty ? "🏀" : team.imageUrl, + style: const TextStyle(fontSize: 24), + ) + : null, + ), + + // 2. BOTÃO DA ESTRELA (Favorito) + Positioned( + left: -15, // Posiciona à esquerda da imagem + 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 + size: 28, + shadows: [ + Shadow( + color: Colors.black.withOpacity(team.isFavorite ? 0.3 : 0.1), + blurRadius: 4, + ), + ], + ), + onPressed: onFavoriteTap, + ), + ), + ], + ), + + // --- NOME DA EQUIPA --- title: Text( team.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), ), - // 3. SUBTÍTULO (CONTAGEM DE JOGADORES) + // --- SUBTÍTULO (CONTAGEM E TEMPORADA) --- subtitle: Padding( padding: const EdgeInsets.only(top: 6.0), child: Row( children: [ - // Ícone de grupo const Icon(Icons.groups_outlined, size: 16, color: Colors.grey), const SizedBox(width: 4), - - // Contagem assíncrona FutureBuilder( - future: controller.getPlayerCount(team.id), - initialData: 0, + future: controller.getPlayerCount(team.id), + initialData: 0, builder: (context, snapshot) { final count = snapshot.data ?? 0; return Text( "$count Jogadores", style: TextStyle( - // Verde se tiver jogadores, Laranja se estiver vazio color: count > 0 ? Colors.green[700] : Colors.orange, fontWeight: FontWeight.bold, fontSize: 13, @@ -67,31 +93,26 @@ class TeamCard extends StatelessWidget { ); }, ), - const SizedBox(width: 10), - - // Temporada Text( - "| ${team.season}", + "| ${team.season}", style: const TextStyle(color: Colors.grey, fontSize: 13), ), ], ), ), - // 4. BOTÕES DE AÇÃO (Lado Direito) + // --- BOTÕES DE ACÇÃO À DIREITA --- trailing: SizedBox( - width: 96, // Espaço suficiente para 2 botões + width: 80, child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - // --- BOTÃO DE STATUS (STATS) --- IconButton( tooltip: 'Ver Estatísticas', icon: const Icon(Icons.bar_chart_rounded, color: Colors.blue), onPressed: () { - // Navega para a página de gestão da equipa - Navigator.push( + Navigator.push( context, MaterialPageRoute( builder: (context) => TeamStatsPage(team: team), @@ -99,8 +120,6 @@ class TeamCard extends StatelessWidget { ); }, ), - - // --- BOTÃO ELIMINAR --- IconButton( tooltip: 'Eliminar Equipa', icon: const Icon(Icons.delete_outline, color: Color(0xFFE74C3C)), @@ -112,7 +131,6 @@ class TeamCard extends StatelessWidget { ), ); } - void _confirmDelete(BuildContext context) { showDialog( context: context, @@ -129,15 +147,14 @@ class TeamCard extends StatelessWidget { 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)) +), ], ), ); } } -// (O CreateTeamDialog mantém-se igual ao que já tens) class CreateTeamDialog extends StatefulWidget { final Function(String name, String season, String imageUrl) onConfirm; @@ -194,7 +211,7 @@ onPressed: () { widget.onConfirm( _nameController.text.trim(), _selectedSeason, - _imageController.text.trim() // Trim remove espaços extras + _imageController.text.trim() ); Navigator.pop(context); }