From 9cf7915d12cd75f10bd9089cd282931808b21616 Mon Sep 17 00:00:00 2001 From: 230404 <230404@epvc.pt> Date: Sun, 22 Mar 2026 16:16:08 +0000 Subject: [PATCH] domingo --- lib/pages/gamePage.dart | 46 ++++++++++-- lib/pages/home.dart | 107 +++++++++++++++++---------- lib/pages/settings_screen.dart | 119 ++++++++++++++++++------------- lib/pages/status_page.dart | 37 ++++++++-- lib/pages/teamPage.dart | 62 ++++++++-------- lib/screens/team_stats_page.dart | 95 +++++++++++++++--------- 6 files changed, 303 insertions(+), 163 deletions(-) diff --git a/lib/pages/gamePage.dart b/lib/pages/gamePage.dart index fcd733d..5cd6398 100644 --- a/lib/pages/gamePage.dart +++ b/lib/pages/gamePage.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:playmaker/pages/PlacarPage.dart'; import 'package:playmaker/classe/theme.dart'; +import 'package:cached_network_image/cached_network_image.dart'; // 👇 A MAGIA DO CACHE AQUI import '../controllers/team_controller.dart'; import '../controllers/game_controller.dart'; import '../models/game_model.dart'; @@ -43,14 +44,31 @@ class GameResultCard extends StatelessWidget { ); } + // 👇 AVATAR OTIMIZADO COM CACHE 👇 Widget _buildTeamInfo(String name, Color color, String? logoUrl, double sf, Color textColor) { + final double avatarSize = 48 * sf; // 2 * radius (24) + return Column( children: [ - CircleAvatar( - radius: 24 * sf, - backgroundColor: color, - backgroundImage: (logoUrl != null && logoUrl.isNotEmpty) ? NetworkImage(logoUrl) : null, - child: (logoUrl == null || logoUrl.isEmpty) ? Icon(Icons.shield, color: Colors.white, size: 24 * sf) : null, + ClipOval( + child: Container( + width: avatarSize, + height: avatarSize, + color: color.withOpacity(0.1), // Fundo suave para não ser agressivo + child: (logoUrl != null && logoUrl.isNotEmpty) + ? CachedNetworkImage( + imageUrl: logoUrl, + fit: BoxFit.cover, + fadeInDuration: Duration.zero, // Fica instantâneo + placeholder: (context, url) => Center( + child: Icon(Icons.shield, color: color, size: 24 * sf) + ), + errorWidget: (context, url, error) => Center( + child: Icon(Icons.shield, color: color, size: 24 * sf) + ), + ) + : Center(child: Icon(Icons.shield, color: color, size: 24 * sf)), + ), ), SizedBox(height: 6 * sf), Text(name, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13 * sf, color: textColor), textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, maxLines: 2), @@ -161,6 +179,7 @@ class _CreateGameDialogManualState extends State { ); } + // 👇 PESQUISA COM CACHE 👇 Widget _buildSearch(BuildContext context, String label, TextEditingController controller) { return StreamBuilder>>( stream: widget.teamController.teamsStream, @@ -188,7 +207,22 @@ class _CreateGameDialogManualState extends State { final String name = option['name'].toString(); final String? imageUrl = option['image_url']; return ListTile( - leading: CircleAvatar(radius: 20 * widget.sf, backgroundColor: Colors.grey.withOpacity(0.2), backgroundImage: (imageUrl != null && imageUrl.isNotEmpty) ? NetworkImage(imageUrl) : null, child: (imageUrl == null || imageUrl.isEmpty) ? Icon(Icons.shield, color: Colors.grey, size: 20 * widget.sf) : null), + leading: ClipOval( + child: Container( + width: 40 * widget.sf, + height: 40 * widget.sf, + color: Colors.grey.withOpacity(0.2), + child: (imageUrl != null && imageUrl.isNotEmpty) + ? CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + fadeInDuration: Duration.zero, + placeholder: (context, url) => Icon(Icons.shield, color: Colors.grey, size: 20 * widget.sf), + errorWidget: (context, url, error) => Icon(Icons.shield, color: Colors.grey, size: 20 * widget.sf), + ) + : Icon(Icons.shield, color: Colors.grey, size: 20 * widget.sf), + ), + ), title: Text(name, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14 * widget.sf, color: Theme.of(context).colorScheme.onSurface)), onTap: () { onSelected(option); }, ); diff --git a/lib/pages/home.dart b/lib/pages/home.dart index 5d87b46..4acbe00 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -6,9 +6,10 @@ import 'package:playmaker/pages/teamPage.dart'; import 'package:playmaker/controllers/team_controller.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:playmaker/pages/status_page.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../utils/size_extension.dart'; import 'settings_screen.dart'; -// import 'home_widgets.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -29,17 +30,29 @@ class _HomeScreenState extends State { final _supabase = Supabase.instance.client; - // 👇 NOVA VARIÁVEL PARA GUARDAR A FOTO String? _avatarUrl; + bool _isMemoryLoaded = false; // 👈 A variável mágica que impede o "piscar" inicial @override void initState() { super.initState(); - _loadUserAvatar(); // Vai buscar a foto logo quando a Home abre! + _loadUserAvatar(); } - // 👇 FUNÇÃO PARA LER A FOTO DA BASE DE DADOS + // 👇 FUNÇÃO OTIMIZADA: Carrega da memória instantaneamente e atualiza em background Future _loadUserAvatar() async { + // 1. LÊ DA MEMÓRIA RÁPIDA PRIMEIRO + final prefs = await SharedPreferences.getInstance(); + final savedUrl = prefs.getString('meu_avatar_guardado'); + + if (mounted) { + setState(() { + if (savedUrl != null) _avatarUrl = savedUrl; + _isMemoryLoaded = true; // Avisa o ecrã que a memória já respondeu! + }); + } + + // 2. VAI AO SUPABASE VERIFICAR SE TROCASTE DE FOTO final userId = _supabase.auth.currentUser?.id; if (userId == null) return; @@ -51,12 +64,18 @@ class _HomeScreenState extends State { .maybeSingle(); if (mounted && data != null && data['avatar_url'] != null) { - setState(() { - _avatarUrl = data['avatar_url']; - }); + final urlDoSupabase = data['avatar_url']; + + // Se a foto na base de dados for nova, ele guarda e atualiza! + if (urlDoSupabase != savedUrl) { + await prefs.setString('meu_avatar_guardado', urlDoSupabase); + setState(() { + _avatarUrl = urlDoSupabase; + }); + } } } catch (e) { - print("Erro ao carregar avatar na Home: $e"); + debugPrint("Erro ao carregar avatar na Home: $e"); } } @@ -72,33 +91,47 @@ class _HomeScreenState extends State { return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, appBar: AppBar( - title: Text('PlayMaker', style: TextStyle(fontSize: 20 * context.sf)), + title: Text('PlayMaker', style: TextStyle(fontSize: 20 * context.sf, fontWeight: FontWeight.bold)), backgroundColor: AppTheme.primaryRed, foregroundColor: Colors.white, + elevation: 0, - // 👇 AQUI ESTÁ A MÁGICA DA TUA FOTO NA APPBAR 👇 leading: Padding( - padding: EdgeInsets.all(10.0 * context.sf), // Dá um espacinho para não colar aos bordos + padding: EdgeInsets.all(10.0 * context.sf), child: InkWell( borderRadius: BorderRadius.circular(100), onTap: () async { - // O 'await' faz com que a Home espere que tu feches os settings... await Navigator.push( context, MaterialPageRoute(builder: (context) => const SettingsScreen()), ); - // ... e quando voltas, ele recarrega a foto logo! _loadUserAvatar(); }, - child: CircleAvatar( - backgroundColor: Colors.white.withOpacity(0.2), // Fundo suave caso não haja foto - backgroundImage: _avatarUrl != null && _avatarUrl!.isNotEmpty - ? NetworkImage(_avatarUrl!) - : null, - child: _avatarUrl == null || _avatarUrl!.isEmpty - ? Icon(Icons.person, color: Colors.white, size: 20 * context.sf) - : null, // Só mostra o ícone se não houver foto - ), + // 👇 SÓ MOSTRA A IMAGEM OU O BONECO DEPOIS DE LER A MEMÓRIA + child: !_isMemoryLoaded + // Nos primeiros 0.05 segs, mostra só o círculo de fundo (sem boneco) + ? CircleAvatar(backgroundColor: Colors.white.withOpacity(0.2)) + + // Depois da memória responder: + : _avatarUrl != null && _avatarUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: _avatarUrl!, + fadeInDuration: Duration.zero, // Corta o atraso visual! + imageBuilder: (context, imageProvider) => CircleAvatar( + backgroundColor: Colors.white.withOpacity(0.2), + backgroundImage: imageProvider, + ), + placeholder: (context, url) => CircleAvatar(backgroundColor: Colors.white.withOpacity(0.2)), + errorWidget: (context, url, error) => CircleAvatar( + backgroundColor: Colors.white.withOpacity(0.2), + child: Icon(Icons.person, color: Colors.white, size: 20 * context.sf), + ), + ) + // Se não tiver foto nenhuma, aí sim mostra o boneco + : CircleAvatar( + backgroundColor: Colors.white.withOpacity(0.2), + child: Icon(Icons.person, color: Colors.white, size: 20 * context.sf), + ), ), ), ), @@ -144,14 +177,15 @@ class _HomeScreenState extends State { itemBuilder: (context, index) { final team = teams[index]; return ListTile( - title: Text(team['name'], style: TextStyle(color: Theme.of(context).colorScheme.onSurface)), + leading: const Icon(Icons.shield, color: AppTheme.primaryRed), + title: Text(team['name'], style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold)), onTap: () { setState(() { - _selectedTeamId = team['id']; + _selectedTeamId = team['id'].toString(); _selectedTeamName = team['name']; - _teamWins = team['wins'] != null ? int.tryParse(team['wins'].toString()) ?? 0 : 0; - _teamLosses = team['losses'] != null ? int.tryParse(team['losses'].toString()) ?? 0 : 0; - _teamDraws = team['draws'] != null ? int.tryParse(team['draws'].toString()) ?? 0 : 0; + _teamWins = int.tryParse(team['wins']?.toString() ?? '0') ?? 0; + _teamLosses = int.tryParse(team['losses']?.toString() ?? '0') ?? 0; + _teamDraws = int.tryParse(team['draws']?.toString() ?? '0') ?? 0; }); Navigator.pop(context); }, @@ -251,9 +285,7 @@ class _HomeScreenState extends State { color: Theme.of(context).cardTheme.color ?? Colors.white, borderRadius: BorderRadius.circular(16 * context.sf), border: Border.all(color: Colors.grey.withOpacity(0.1)), - boxShadow: [ - BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 10, offset: const Offset(0, 4)), - ], + boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 10, offset: const Offset(0, 4))], ), child: Column( children: [ @@ -263,10 +295,7 @@ class _HomeScreenState extends State { child: Icon(Icons.shield_outlined, color: AppTheme.primaryRed, size: 42 * context.sf), ), SizedBox(height: 20 * context.sf), - Text( - "Nenhuma Equipa Ativa", - style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface), - ), + Text("Nenhuma Equipa Ativa", style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold, color: textColor)), SizedBox(height: 8 * context.sf), Text( "Escolha uma equipa no seletor acima para ver as estatísticas e o histórico.", @@ -293,8 +322,7 @@ class _HomeScreenState extends State { ), ) : StreamBuilder>>( - stream: _supabase.from('games').stream(primaryKey: ['id']) - .order('game_date', ascending: false), + stream: _supabase.from('games').stream(primaryKey: ['id']).order('game_date', ascending: false), builder: (context, gameSnapshot) { if (gameSnapshot.hasError) return Text("Erro: ${gameSnapshot.error}", style: const TextStyle(color: Colors.red)); if (gameSnapshot.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator()); @@ -309,6 +337,7 @@ class _HomeScreenState extends State { if (gamesList.isEmpty) { return Container( + width: double.infinity, padding: EdgeInsets.all(20 * context.sf), decoration: BoxDecoration(color: Theme.of(context).cardTheme.color, borderRadius: BorderRadius.circular(14)), alignment: Alignment.center, @@ -362,9 +391,9 @@ class _HomeScreenState extends State { for (var row in data) { String pid = row['member_id'].toString(); namesMap[pid] = row['player_name']?.toString() ?? "Desconhecido"; - ptsMap[pid] = (ptsMap[pid] ?? 0) + (row['pts'] as int? ?? 0); - astMap[pid] = (astMap[pid] ?? 0) + (row['ast'] as int? ?? 0); - rbsMap[pid] = (rbsMap[pid] ?? 0) + (row['rbs'] as int? ?? 0); + ptsMap[pid] = (ptsMap[pid] ?? 0) + (int.tryParse(row['pts']?.toString() ?? '0') ?? 0); + astMap[pid] = (astMap[pid] ?? 0) + (int.tryParse(row['ast']?.toString() ?? '0') ?? 0); + rbsMap[pid] = (rbsMap[pid] ?? 0) + (int.tryParse(row['rbs']?.toString() ?? '0') ?? 0); } if (ptsMap.isEmpty) return {'pts_name': '---', 'pts_val': 0, 'ast_name': '---', 'ast_val': 0, 'rbs_name': '---', 'rbs_val': 0}; String getBest(Map map) { var bestId = map.entries.reduce((a, b) => a.value > b.value ? a : b).key; return namesMap[bestId]!; } diff --git a/lib/pages/settings_screen.dart b/lib/pages/settings_screen.dart index 94de52d..6f2c492 100644 --- a/lib/pages/settings_screen.dart +++ b/lib/pages/settings_screen.dart @@ -4,10 +4,12 @@ import 'package:flutter/material.dart'; import 'package:playmaker/classe/theme.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:cached_network_image/cached_network_image.dart'; // 👇 IMPORTAÇÃO PARA CACHE +import 'package:shared_preferences/shared_preferences.dart'; // 👇 IMPORTAÇÃO PARA MEMÓRIA RÁPIDA import '../utils/size_extension.dart'; -import 'login.dart'; // 👇 Necessário para o redirecionamento do logout -import '../main.dart'; // 👇 OBRIGATÓRIO PARA LER A VARIÁVEL "themeNotifier" +import 'login.dart'; +import '../main.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -18,10 +20,10 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { - // 👇 VARIÁVEIS DE ESTADO PARA FOTO DE PERFIL File? _localImageFile; String? _uploadedImageUrl; bool _isUploadingImage = false; + bool _isMemoryLoaded = false; // 👇 VARIÁVEL MÁGICA CONTRA O PISCAR final supabase = Supabase.instance.client; @@ -31,41 +33,52 @@ class _SettingsScreenState extends State { _loadUserAvatar(); } - // 👇 LÊ A IMAGEM ATUAL DA BASE DE DADOS (Tabela 'profiles') - void _loadUserAvatar() async { + // 👇 LÊ A IMAGEM DA MEMÓRIA INSTANTANEAMENTE E CONFIRMA NA BD + Future _loadUserAvatar() async { + // 1. Lê da memória rápida primeiro! + final prefs = await SharedPreferences.getInstance(); + final savedUrl = prefs.getString('meu_avatar_guardado'); + + if (mounted) { + setState(() { + if (savedUrl != null) _uploadedImageUrl = savedUrl; + _isMemoryLoaded = true; // Avisa que já leu a memória + }); + } + final userId = supabase.auth.currentUser?.id; if (userId == null) return; try { - // ⚠️ NOTA: Ajusta 'profiles' e 'avatar_url' se os nomes na tua BD forem diferentes! final data = await supabase .from('profiles') .select('avatar_url') .eq('id', userId) - .maybeSingle(); // maybeSingle evita erro se o perfil ainda não existir + .maybeSingle(); if (mounted && data != null && data['avatar_url'] != null) { - setState(() { - _uploadedImageUrl = data['avatar_url']; - }); + final urlDoSupabase = data['avatar_url']; + + // Atualiza a memória se a foto na base de dados for diferente + if (urlDoSupabase != savedUrl) { + await prefs.setString('meu_avatar_guardado', urlDoSupabase); + setState(() { + _uploadedImageUrl = urlDoSupabase; + }); + } } } catch (e) { print("Erro ao carregar avatar: $e"); } } - // ========================================================================= - // 👇 A MÁGICA DE ESCOLHER E FAZER UPLOAD DA FOTO 👇 - // ========================================================================= Future _handleImageChange() async { final ImagePicker picker = ImagePicker(); - // 1. ABRIR GALERIA final XFile? pickedFile = await picker.pickImage(source: ImageSource.gallery); if (pickedFile == null || !mounted) return; try { - // 2. MOSTRAR IMAGEM LOCAL E ATIVAR LOADING setState(() { _localImageFile = File(pickedFile.path); _isUploadingImage = true; @@ -76,26 +89,25 @@ class _SettingsScreenState extends State { final String storagePath = '$userId/profile_picture.png'; - // 3. FAZER UPLOAD (Método direto e seguro!) await supabase.storage.from('avatars').upload( storagePath, - _localImageFile!, // Envia o ficheiro File diretamente! + _localImageFile!, fileOptions: const FileOptions(cacheControl: '3600', upsert: true) ); - // 4. OBTER URL PÚBLICO final String publicUrl = supabase.storage.from('avatars').getPublicUrl(storagePath); - // 5. ATUALIZAR NA BASE DE DADOS - // ⚠️ NOTA: Garante que a tabela 'profiles' existe e tem o teu user_id await supabase .from('profiles') .upsert({ - 'id': userId, // Garante que atualiza o perfil certo ou cria um novo + 'id': userId, 'avatar_url': publicUrl }); - // 6. SUCESSO! + // 👇 MÁGICA: GUARDA LOGO O NOVO URL NA MEMÓRIA PARA A HOME SABER! + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('meu_avatar_guardado', publicUrl); + if (mounted) { setState(() { _uploadedImageUrl = publicUrl; @@ -151,9 +163,6 @@ class _SettingsScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // ========================================== - // CARTÃO DE PERFIL - // ========================================== Container( padding: EdgeInsets.all(20 * context.sf), decoration: BoxDecoration( @@ -166,7 +175,6 @@ class _SettingsScreenState extends State { ), child: Row( children: [ - // 👇 IMAGEM TAPPABLE AQUI 👇 _buildTappableProfileAvatar(context, primaryRed), SizedBox(width: 16 * context.sf), Expanded( @@ -191,9 +199,6 @@ class _SettingsScreenState extends State { SizedBox(height: 32 * context.sf), - // ========================================== - // SECÇÃO: DEFINIÇÕES - // ========================================== Padding( padding: EdgeInsets.only(left: 4 * context.sf, bottom: 12 * context.sf), child: Text( @@ -233,9 +238,6 @@ class _SettingsScreenState extends State { SizedBox(height: 32 * context.sf), - // ========================================== - // SECÇÃO: CONTA - // ========================================== Padding( padding: EdgeInsets.only(left: 4 * context.sf, bottom: 12 * context.sf), child: Text( @@ -265,9 +267,6 @@ class _SettingsScreenState extends State { SizedBox(height: 50 * context.sf), - // ========================================== - // VERSÃO DA APP - // ========================================== Center( child: Text( "PlayMaker v1.0.0", @@ -281,27 +280,44 @@ class _SettingsScreenState extends State { ); } - // 👇 O WIDGET DA FOTO DE PERFIL (Protegido com GestureDetector) + // 👇 AVATAR OTIMIZADO: SEM LAG, COM CACHE E MEMÓRIA Widget _buildTappableProfileAvatar(BuildContext context, Color primaryRed) { return GestureDetector( onTap: () { - print("CLIQUEI NA FOTO! A abrir galeria..."); // 👇 Vê na consola se isto aparece _handleImageChange(); }, child: Stack( alignment: Alignment.center, children: [ - CircleAvatar( - radius: 36 * context.sf, - backgroundColor: primaryRed.withOpacity(0.1), - backgroundImage: _isUploadingImage && _localImageFile != null - ? FileImage(_localImageFile!) - : (_uploadedImageUrl != null && _uploadedImageUrl!.isNotEmpty - ? NetworkImage(_uploadedImageUrl!) - : null), - child: (_uploadedImageUrl == null && !(_isUploadingImage && _localImageFile != null)) - ? Icon(Icons.person, color: primaryRed, size: 36 * context.sf) - : null, + Container( + width: 72 * context.sf, + height: 72 * context.sf, + decoration: BoxDecoration( + color: primaryRed.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: ClipOval( + child: _isUploadingImage && _localImageFile != null + // 1. Mostrar imagem local (galeria) ENQUANTO está a fazer upload + ? Image.file(_localImageFile!, fit: BoxFit.cover) + + // 2. Antes da memória carregar, fica só o fundo (evita piscar) + : !_isMemoryLoaded + ? const SizedBox() + + // 3. Depois da memória carregar, se houver URL, desenha com Cache! + : _uploadedImageUrl != null && _uploadedImageUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: _uploadedImageUrl!, + fit: BoxFit.cover, + fadeInDuration: Duration.zero, // Fica instantâneo! + placeholder: (context, url) => const SizedBox(), + errorWidget: (context, url, error) => Icon(Icons.person, color: primaryRed, size: 36 * context.sf), + ) + + // 4. Se não houver URL, mete o boneco + : Icon(Icons.person, color: primaryRed, size: 36 * context.sf), + ), ), // ÍCONE DE LÁPIS @@ -319,7 +335,7 @@ class _SettingsScreenState extends State { ), ), - // LOADING OVERLAY + // LOADING OVERLAY (Enquanto faz o upload) if (_isUploadingImage) Positioned.fill( child: Container( @@ -335,7 +351,6 @@ class _SettingsScreenState extends State { ); } - // 👇 FUNÇÃO DE LOGOUT void _confirmLogout(BuildContext context) { showDialog( context: context, @@ -349,6 +364,10 @@ class _SettingsScreenState extends State { ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryRed, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: () async { + // Limpa a memória do Avatar ao sair para não aparecer na conta de outra pessoa! + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('meu_avatar_guardado'); + await Supabase.instance.client.auth.signOut(); if (ctx.mounted) { Navigator.of(ctx).pushAndRemoveUntil( diff --git a/lib/pages/status_page.dart b/lib/pages/status_page.dart index 6f0cbd3..31bd56b 100644 --- a/lib/pages/status_page.dart +++ b/lib/pages/status_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:playmaker/classe/theme.dart'; +import 'package:cached_network_image/cached_network_image.dart'; // 👇 A MAGIA DO CACHE import '../controllers/team_controller.dart'; import '../utils/size_extension.dart'; @@ -67,7 +68,7 @@ class _StatusPageState extends State { stream: _supabase.from('members').stream(primaryKey: ['id']).eq('team_id', _selectedTeamId!), builder: (context, membersSnapshot) { if (statsSnapshot.connectionState == ConnectionState.waiting || gamesSnapshot.connectionState == ConnectionState.waiting || membersSnapshot.connectionState == ConnectionState.waiting) { - return Center(child: CircularProgressIndicator(color: AppTheme.primaryRed)); + return const Center(child: CircularProgressIndicator(color: AppTheme.primaryRed)); } final membersData = membersSnapshot.data ?? []; @@ -98,15 +99,17 @@ class _StatusPageState extends State { ); } + // 👇 AGORA GUARDA TAMBÉM O IMAGE_URL DO MEMBRO PARA MOSTRAR NA TABELA List> _aggregateStats(List stats, List games, List members) { Map> aggregated = {}; for (var member in members) { String name = member['name']?.toString() ?? "Desconhecido"; - aggregated[name] = {'name': name, 'j': 0, 'pts': 0, 'ast': 0, 'rbs': 0, 'stl': 0, 'blk': 0, 'mvp': 0, 'def': 0}; + String? imageUrl = member['image_url']?.toString(); // 👈 CAPTURA A IMAGEM AQUI + aggregated[name] = {'name': name, 'image_url': imageUrl, 'j': 0, 'pts': 0, 'ast': 0, 'rbs': 0, 'stl': 0, 'blk': 0, 'mvp': 0, 'def': 0}; } for (var row in stats) { String name = row['player_name']?.toString() ?? "Desconhecido"; - if (!aggregated.containsKey(name)) aggregated[name] = {'name': name, 'j': 0, 'pts': 0, 'ast': 0, 'rbs': 0, 'stl': 0, 'blk': 0, 'mvp': 0, 'def': 0}; + if (!aggregated.containsKey(name)) aggregated[name] = {'name': name, 'image_url': null, 'j': 0, 'pts': 0, 'ast': 0, 'rbs': 0, 'stl': 0, 'blk': 0, 'mvp': 0, 'def': 0}; aggregated[name]!['j'] += 1; aggregated[name]!['pts'] += (row['pts'] ?? 0); @@ -132,7 +135,7 @@ class _StatusPageState extends State { for (var p in players) { tPts += (p['pts'] as int); tAst += (p['ast'] as int); tRbs += (p['rbs'] as int); tStl += (p['stl'] as int); tBlk += (p['blk'] as int); tMvp += (p['mvp'] as int); tDef += (p['def'] as int); } - return {'name': 'TOTAL EQUIPA', 'j': teamGames, 'pts': tPts, 'ast': tAst, 'rbs': tRbs, 'stl': tStl, 'blk': tBlk, 'mvp': tMvp, 'def': tDef}; + return {'name': 'TOTAL EQUIPA', 'image_url': null, 'j': teamGames, 'pts': tPts, 'ast': tAst, 'rbs': tRbs, 'stl': tStl, 'blk': tBlk, 'mvp': tMvp, 'def': tDef}; } Widget _buildStatsGrid(BuildContext context, List> players, Map teamTotals, Color bgColor, Color textColor) { @@ -160,7 +163,31 @@ class _StatusPageState extends State { ], rows: [ ...players.map((player) => DataRow(cells: [ - DataCell(Row(children: [CircleAvatar(radius: 15 * context.sf, backgroundColor: Colors.grey.withOpacity(0.2), child: Icon(Icons.person, size: 18 * context.sf, color: Colors.grey)), SizedBox(width: 10 * context.sf), Text(player['name'], style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13 * context.sf, color: textColor))])), + DataCell( + Row( + children: [ + // 👇 FOTO DO JOGADOR NA TABELA (COM CACHE!) 👇 + ClipOval( + child: Container( + width: 30 * context.sf, + height: 30 * context.sf, + color: Colors.grey.withOpacity(0.2), + child: (player['image_url'] != null && player['image_url'].toString().isNotEmpty) + ? CachedNetworkImage( + imageUrl: player['image_url'], + fit: BoxFit.cover, + fadeInDuration: Duration.zero, + placeholder: (context, url) => Icon(Icons.person, size: 18 * context.sf, color: Colors.grey), + errorWidget: (context, url, error) => Icon(Icons.person, size: 18 * context.sf, color: Colors.grey), + ) + : Icon(Icons.person, size: 18 * context.sf, color: Colors.grey), + ), + ), + SizedBox(width: 10 * context.sf), + Text(player['name'], style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13 * context.sf, color: textColor)) + ] + ) + ), DataCell(Center(child: Text(player['j'].toString(), style: TextStyle(color: textColor)))), _buildStatCell(context, player['pts'], textColor, isHighlight: true), _buildStatCell(context, player['ast'], textColor), diff --git a/lib/pages/teamPage.dart b/lib/pages/teamPage.dart index 881dc2c..4704cd2 100644 --- a/lib/pages/teamPage.dart +++ b/lib/pages/teamPage.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:image_cropper/image_cropper.dart'; +import 'package:cached_network_image/cached_network_image.dart'; // 👇 A MAGIA DO CACHE AQUI import 'package:playmaker/screens/team_stats_page.dart'; import 'package:playmaker/classe/theme.dart'; import '../controllers/team_controller.dart'; @@ -172,7 +173,6 @@ class _TeamsPageState extends State { ); } - // 👇 AGORA USA FUTUREBUILDER E É MUITO MAIS RÁPIDO 👇 Widget _buildTeamsList() { return FutureBuilder>>( future: controller.getTeamsWithStats(), @@ -196,7 +196,7 @@ class _TeamsPageState extends State { return RefreshIndicator( color: AppTheme.primaryRed, - onRefresh: () async => setState(() {}), // Puxa para baixo para recarregar + onRefresh: () async => setState(() {}), child: ListView.builder( padding: EdgeInsets.symmetric(horizontal: 16 * context.sf), itemCount: data.length, @@ -209,9 +209,9 @@ class _TeamsPageState extends State { controller: controller, onFavoriteTap: () async { await controller.toggleFavorite(team.id, team.isFavorite); - setState(() {}); // Atualiza a estrela na hora + setState(() {}); }, - onDelete: () => setState(() {}), // Atualiza a lista quando apaga + onDelete: () => setState(() {}), sf: context.sf, ), ); @@ -229,7 +229,7 @@ class _TeamsPageState extends State { sf: context.sf, onConfirm: (name, season, imageFile) async { await controller.createTeam(name, season, imageFile); - setState(() {}); // 👇 Atualiza a lista quando acaba de criar a equipa! + setState(() {}); } ), ); @@ -241,7 +241,7 @@ class TeamCard extends StatelessWidget { final Team team; final TeamController controller; final VoidCallback onFavoriteTap; - final VoidCallback onDelete; // 👇 Avisa o pai quando é apagado + final VoidCallback onDelete; final double sf; const TeamCard({ @@ -257,6 +257,7 @@ class TeamCard extends StatelessWidget { Widget build(BuildContext context) { final bgColor = Theme.of(context).cardTheme.color ?? Theme.of(context).colorScheme.surface; final textColor = Theme.of(context).colorScheme.onSurface; + final double avatarSize = 56 * sf; // 2 * radius (28) return Container( margin: EdgeInsets.only(bottom: 12 * sf), @@ -274,18 +275,29 @@ class TeamCard extends StatelessWidget { leading: Stack( clipBehavior: Clip.none, children: [ - CircleAvatar( - radius: 28 * sf, - backgroundColor: Colors.grey.withOpacity(0.2), - backgroundImage: (team.imageUrl.isNotEmpty && team.imageUrl.startsWith('http')) - ? NetworkImage(team.imageUrl) - : null, - child: (team.imageUrl.isEmpty || !team.imageUrl.startsWith('http')) - ? Text( - team.imageUrl.isEmpty ? "🏀" : team.imageUrl, - style: TextStyle(fontSize: 24 * sf), - ) - : null, + // 👇 AVATAR DA EQUIPA OTIMIZADO COM CACHE 👇 + ClipOval( + child: Container( + width: avatarSize, + height: avatarSize, + color: Colors.grey.withOpacity(0.2), + child: (team.imageUrl.isNotEmpty && team.imageUrl.startsWith('http')) + ? CachedNetworkImage( + imageUrl: team.imageUrl, + fit: BoxFit.cover, + fadeInDuration: Duration.zero, // Fica instantâneo no scroll + placeholder: (context, url) => const SizedBox(), // Fica só o fundo cinza + errorWidget: (context, url, error) => Center( + child: Text("🏀", style: TextStyle(fontSize: 24 * sf)), + ), + ) + : Center( + child: Text( + team.imageUrl.isEmpty ? "🏀" : team.imageUrl, + style: TextStyle(fontSize: 24 * sf), + ), + ), + ), ), Positioned( left: -15 * sf, @@ -313,8 +325,6 @@ class TeamCard extends StatelessWidget { children: [ Icon(Icons.groups_outlined, size: 16 * sf, color: Colors.grey), SizedBox(width: 4 * sf), - - // 👇 ESTATÍSTICA MUITO MAIS LEVE. LÊ O VALOR DIRETAMENTE! 👇 Text( "${team.playerCount} Jogs.", style: TextStyle( @@ -323,7 +333,6 @@ class TeamCard extends StatelessWidget { fontSize: 13 * sf, ), ), - SizedBox(width: 8 * sf), Expanded( child: Text("| ${team.season}", style: TextStyle(color: Colors.grey, fontSize: 13 * sf), overflow: TextOverflow.ellipsis), @@ -337,7 +346,7 @@ class TeamCard extends StatelessWidget { IconButton( tooltip: 'Ver Estatísticas', icon: Icon(Icons.bar_chart_rounded, color: Colors.blue, size: 24 * sf), - onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (context) => TeamStatsPage(team: team))).then((_) => onDelete()), // Atualiza se algo mudou + onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (context) => TeamStatsPage(team: team))).then((_) => onDelete()), ), IconButton( tooltip: 'Eliminar Equipa', @@ -351,7 +360,7 @@ class TeamCard extends StatelessWidget { ); } -void _confirmDelete(BuildContext context, double sf, Color cardColor, Color textColor) { + void _confirmDelete(BuildContext context, double sf, Color cardColor, Color textColor) { showDialog( context: context, builder: (ctx) => AlertDialog( @@ -366,12 +375,8 @@ void _confirmDelete(BuildContext context, double sf, Color cardColor, Color text ), TextButton( onPressed: () { - // ⚡ 1. FECHA LOGO O POP-UP! Navigator.pop(ctx); - // ⚡ 2. AVISA O PAI PARA ESCONDER A EQUIPA DO ECRÃ NA HORA! onDelete(); - - // 3. APAGA NO FUNDO (Sem o utilizador ficar à espera) controller.deleteTeam(team.id).catchError((e) { if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Erro ao eliminar: $e'), backgroundColor: Colors.red)); }); @@ -401,7 +406,7 @@ class _CreateTeamDialogState extends State { File? _selectedImage; bool _isLoading = false; - bool _isPickerActive = false; // 👇 ESCUDO ANTI-DUPLO-CLIQUE + bool _isPickerActive = false; Future _pickImage() async { if (_isPickerActive) return; @@ -412,7 +417,6 @@ class _CreateTeamDialogState extends State { final XFile? pickedFile = await picker.pickImage(source: ImageSource.gallery); if (pickedFile != null) { - // 👇 USA O CROPPER QUE CONFIGURASTE PARA AS CARAS CroppedFile? croppedFile = await ImageCropper().cropImage( sourcePath: pickedFile.path, aspectRatio: const CropAspectRatio(ratioX: 1, ratioY: 1), diff --git a/lib/screens/team_stats_page.dart b/lib/screens/team_stats_page.dart index d6bc2dd..28a2099 100644 --- a/lib/screens/team_stats_page.dart +++ b/lib/screens/team_stats_page.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:image_cropper/image_cropper.dart'; import 'package:shimmer/shimmer.dart'; -import 'package:cached_network_image/cached_network_image.dart'; +import 'package:cached_network_image/cached_network_image.dart'; // 👇 MAGIA DO CACHE AQUI import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:playmaker/classe/theme.dart'; import '../models/team_model.dart'; @@ -11,7 +11,7 @@ import '../models/person_model.dart'; import '../utils/size_extension.dart'; // ========================================== -// 1. CABEÇALHO (AGORA COM CACHE DE IMAGEM) +// 1. CABEÇALHO (AGORA COM CACHE DE IMAGEM INSTANTÂNEO) // ========================================== class StatsHeader extends StatelessWidget { final Team team; @@ -51,15 +51,27 @@ class StatsHeader extends StatelessWidget { child: Stack( alignment: Alignment.center, children: [ - CircleAvatar( - radius: 28 * context.sf, - backgroundColor: Colors.white24, - backgroundImage: (currentImageUrl != null && currentImageUrl!.isNotEmpty && currentImageUrl!.startsWith('http')) - ? CachedNetworkImageProvider(currentImageUrl!) - : null, - child: (currentImageUrl == null || currentImageUrl!.isEmpty || !currentImageUrl!.startsWith('http')) - ? Text((currentImageUrl != null && currentImageUrl!.isNotEmpty) ? currentImageUrl! : "🛡️", style: TextStyle(fontSize: 24 * context.sf)) - : null, + // 👇 AVATAR DA EQUIPA SEM LAG 👇 + ClipOval( + child: Container( + width: 56 * context.sf, + height: 56 * context.sf, + color: Colors.white24, + child: (currentImageUrl != null && currentImageUrl!.isNotEmpty && currentImageUrl!.startsWith('http')) + ? CachedNetworkImage( + imageUrl: currentImageUrl!, + fit: BoxFit.cover, + fadeInDuration: Duration.zero, // Corta o atraso + placeholder: (context, url) => Center(child: Text("🛡️", style: TextStyle(fontSize: 24 * context.sf))), + errorWidget: (context, url, error) => Center(child: Text("🛡️", style: TextStyle(fontSize: 24 * context.sf))), + ) + : Center( + child: Text( + (currentImageUrl != null && currentImageUrl!.isNotEmpty) ? currentImageUrl! : "🛡️", + style: TextStyle(fontSize: 24 * context.sf) + ), + ), + ), ), Positioned( bottom: 0, right: 0, @@ -144,7 +156,7 @@ class StatsSectionTitle extends StatelessWidget { } } -// --- CARD DA PESSOA (FOTO + NÚMERO + NOME E CACHE) --- +// --- CARD DA PESSOA (FOTO SEM LAG) --- class PersonCard extends StatelessWidget { final Person person; final bool isCoach; @@ -158,6 +170,7 @@ class PersonCard extends StatelessWidget { final Color defaultBg = Theme.of(context).brightness == Brightness.dark ? const Color(0xFF1E1E1E) : Colors.white; final Color coachBg = Theme.of(context).brightness == Brightness.dark ? AppTheme.warningAmber.withOpacity(0.1) : const Color(0xFFFFF9C4); final String? pImage = person.imageUrl; + final Color iconColor = isCoach ? Colors.white : AppTheme.primaryRed; return Card( margin: EdgeInsets.only(top: 12 * context.sf), @@ -168,11 +181,22 @@ class PersonCard extends StatelessWidget { padding: EdgeInsets.symmetric(horizontal: 16 * context.sf, vertical: 12 * context.sf), child: Row( children: [ - CircleAvatar( - radius: 22 * context.sf, - backgroundColor: isCoach ? AppTheme.warningAmber : AppTheme.primaryRed.withOpacity(0.1), - backgroundImage: (pImage != null && pImage.isNotEmpty) ? CachedNetworkImageProvider(pImage) : null, - child: (pImage == null || pImage.isEmpty) ? Icon(Icons.person, color: isCoach ? Colors.white : AppTheme.primaryRed, size: 24 * context.sf) : null, + // 👇 FOTO DO JOGADOR/TREINADOR INSTANTÂNEA 👇 + ClipOval( + child: Container( + width: 44 * context.sf, + height: 44 * context.sf, + color: isCoach ? AppTheme.warningAmber : AppTheme.primaryRed.withOpacity(0.1), + child: (pImage != null && pImage.isNotEmpty) + ? CachedNetworkImage( + imageUrl: pImage, + fit: BoxFit.cover, + fadeInDuration: Duration.zero, + placeholder: (context, url) => Icon(Icons.person, color: iconColor, size: 24 * context.sf), + errorWidget: (context, url, error) => Icon(Icons.person, color: iconColor, size: 24 * context.sf), + ) + : Icon(Icons.person, color: iconColor, size: 24 * context.sf), + ), ), SizedBox(width: 12 * context.sf), Expanded( @@ -385,12 +409,9 @@ void _confirmDelete(BuildContext context, Person person) { TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("Cancelar", style: TextStyle(color: Colors.grey))), TextButton( onPressed: () { - // ⚡ FECHA LOGO O POP-UP! Navigator.pop(ctx); - // Mostra um aviso rápido para o utilizador saber que a app está a trabalhar ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("A remover ${person.name}..."), duration: const Duration(seconds: 1))); - // APAGA NO FUNDO _controller.deletePerson(person).catchError((e) { if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Erro: $e"), backgroundColor: AppTheme.primaryRed)); }); @@ -480,7 +501,6 @@ class StatsController { bool isPickerActive = false; String? currentImageUrl = isEdit ? person.imageUrl : null; - // 👇 VARIÁVEIS PARA O TEXTO PEQUENO VERMELHO (ESTILO LOGIN) 👇 String? nameError; String? numError; @@ -512,15 +532,24 @@ class StatsController { child: Stack( alignment: Alignment.center, children: [ - CircleAvatar( - radius: 40 * context.sf, - backgroundColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.05), - backgroundImage: selectedImage != null - ? FileImage(selectedImage!) - : (currentImageUrl != null && currentImageUrl!.isNotEmpty ? CachedNetworkImageProvider(currentImageUrl!) : null) as ImageProvider?, - child: (selectedImage == null && (currentImageUrl == null || currentImageUrl!.isEmpty)) - ? Icon(Icons.add_a_photo, size: 30 * context.sf, color: Colors.grey) - : null, + // 👇 PREVIEW DA FOTO NO POPUP SEM LAG 👇 + ClipOval( + child: Container( + width: 80 * context.sf, + height: 80 * context.sf, + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.05), + child: selectedImage != null + ? Image.file(selectedImage!, fit: BoxFit.cover) + : (currentImageUrl != null && currentImageUrl!.isNotEmpty) + ? CachedNetworkImage( + imageUrl: currentImageUrl!, + fit: BoxFit.cover, + fadeInDuration: Duration.zero, + placeholder: (context, url) => Icon(Icons.add_a_photo, size: 30 * context.sf, color: Colors.grey), + errorWidget: (context, url, error) => Icon(Icons.add_a_photo, size: 30 * context.sf, color: Colors.grey), + ) + : Icon(Icons.add_a_photo, size: 30 * context.sf, color: Colors.grey), + ), ), Positioned( bottom: 0, right: 0, @@ -540,7 +569,7 @@ class StatsController { style: TextStyle(color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration( labelText: "Nome Completo", - errorText: nameError, // 👇 ERRO PEQUENO AQUI + errorText: nameError, ), textCapitalization: TextCapitalization.words, ), @@ -560,7 +589,7 @@ class StatsController { style: TextStyle(color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration( labelText: "Número da Camisola", - errorText: numError, // 👇 ERRO PEQUENO AQUI + errorText: numError, ), keyboardType: TextInputType.number, ), @@ -574,7 +603,6 @@ class StatsController { style: ElevatedButton.styleFrom(backgroundColor: AppTheme.successGreen, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8 * context.sf))), onPressed: isUploading ? null : () async { - // Limpa os erros antes de tentar de novo setState(() { nameError = null; numError = null; @@ -621,7 +649,6 @@ class StatsController { } if (ctx.mounted) Navigator.pop(ctx); } catch (e) { - // 👇 AGORA OS ERROS VÃO DIRETOS PARA OS CAMPOS (ESTILO LOGIN) 👇 setState(() { isUploading = false; if (e is PostgrestException && e.code == '23505') {