import 'dart:io'; 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'; // 👇 MAGIA DO CACHE AQUI import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:playmaker/classe/theme.dart'; import '../models/team_model.dart'; import '../models/person_model.dart'; import '../utils/size_extension.dart'; // ========================================== // 1. CABEÇALHO (AGORA COM CACHE DE IMAGEM INSTANTÂNEO) // ========================================== class StatsHeader extends StatelessWidget { final Team team; final String? currentImageUrl; final VoidCallback onEditPhoto; final bool isUploading; const StatsHeader({ super.key, required this.team, required this.currentImageUrl, required this.onEditPhoto, required this.isUploading, }); @override Widget build(BuildContext context) { return Container( padding: EdgeInsets.only(top: 50 * context.sf, left: 20 * context.sf, right: 20 * context.sf, bottom: 20 * context.sf), decoration: BoxDecoration( color: AppTheme.primaryRed, borderRadius: BorderRadius.only( bottomLeft: Radius.circular(30 * context.sf), bottomRight: Radius.circular(30 * context.sf) ), ), child: Row( children: [ IconButton( icon: Icon(Icons.arrow_back, color: Colors.white, size: 24 * context.sf), onPressed: () => Navigator.pop(context) ), SizedBox(width: 10 * context.sf), GestureDetector( onTap: onEditPhoto, child: Stack( alignment: Alignment.center, children: [ // 👇 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, child: Container( padding: EdgeInsets.all(4 * context.sf), decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle), child: Icon(Icons.edit, color: AppTheme.primaryRed, size: 12 * context.sf), ), ), if (isUploading) Container( width: 56 * context.sf, height: 56 * context.sf, decoration: const BoxDecoration(color: Colors.black45, shape: BoxShape.circle), child: const Padding(padding: EdgeInsets.all(12.0), child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)), ) ], ), ), SizedBox(width: 15 * context.sf), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(team.name, style: TextStyle(color: Colors.white, fontSize: 20 * context.sf, fontWeight: FontWeight.bold), overflow: TextOverflow.ellipsis), Text(team.season, style: TextStyle(color: Colors.white70, fontSize: 14 * context.sf)), ], ), ), ], ), ); } } // --- CARD DE RESUMO --- class StatsSummaryCard extends StatelessWidget { final int total; const StatsSummaryCard({super.key, required this.total}); @override Widget build(BuildContext context) { final Color bgColor = Theme.of(context).brightness == Brightness.dark ? const Color(0xFF1E1E1E) : Colors.white; return Card( elevation: 4, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20 * context.sf)), child: Container( padding: EdgeInsets.all(20 * context.sf), decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(20 * context.sf), border: Border.all(color: Colors.grey.withOpacity(0.15))), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ Icon(Icons.groups, color: AppTheme.primaryRed, size: 28 * context.sf), SizedBox(width: 10 * context.sf), Text("Total de Membros", style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 16 * context.sf, fontWeight: FontWeight.w600)), ], ), Text("$total", style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 28 * context.sf, 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: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface)), Divider(color: Colors.grey.withOpacity(0.2)), ], ); } } // --- CARD DA PESSOA (FOTO SEM LAG) --- 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) { 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), elevation: 2, color: isCoach ? coachBg : defaultBg, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * context.sf)), child: Padding( padding: EdgeInsets.symmetric(horizontal: 16 * context.sf, vertical: 12 * context.sf), child: Row( children: [ // 👇 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( child: Row( children: [ if (!isCoach && person.number != null && person.number!.isNotEmpty) ...[ Container( padding: EdgeInsets.symmetric(horizontal: 8 * context.sf, vertical: 4 * context.sf), decoration: BoxDecoration(color: AppTheme.primaryRed.withOpacity(0.1), borderRadius: BorderRadius.circular(6 * context.sf)), child: Text(person.number!, style: TextStyle(color: AppTheme.primaryRed, fontWeight: FontWeight.bold, fontSize: 14 * context.sf)), ), SizedBox(width: 10 * context.sf), ], Expanded( child: Text(person.name, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16 * context.sf, color: Theme.of(context).colorScheme.onSurface), overflow: TextOverflow.ellipsis) ), ], ), ), Row( mainAxisSize: MainAxisSize.min, children: [ IconButton(icon: Icon(Icons.edit_outlined, color: Colors.blue, size: 22 * context.sf), onPressed: onEdit, padding: EdgeInsets.zero, constraints: const BoxConstraints()), SizedBox(width: 16 * context.sf), IconButton(icon: Icon(Icons.delete_outline, color: AppTheme.primaryRed, size: 22 * context.sf), onPressed: onDelete, padding: EdgeInsets.zero, constraints: const BoxConstraints()), ], ), ], ), ), ); } } // ========================================== // WIDGET NOVO: SKELETON LOADING (SHIMMER) // ========================================== class SkeletonLoadingStats extends StatelessWidget { const SkeletonLoadingStats({super.key}); @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; final baseColor = isDark ? Colors.grey[800]! : Colors.grey[300]!; final highlightColor = isDark ? Colors.grey[700]! : Colors.grey[100]!; return Shimmer.fromColors( baseColor: baseColor, highlightColor: highlightColor, child: SingleChildScrollView( padding: EdgeInsets.all(16.0 * context.sf), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container(height: 80 * context.sf, width: double.infinity, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20 * context.sf))), SizedBox(height: 30 * context.sf), Container(height: 20 * context.sf, width: 150 * context.sf, color: Colors.white), SizedBox(height: 10 * context.sf), for (int i = 0; i < 3; i++) ...[ Container( height: 60 * context.sf, width: double.infinity, margin: EdgeInsets.only(top: 12 * context.sf), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(15 * context.sf)), ), ] ], ), ), ); } } // ========================================== // 2. PÁGINA PRINCIPAL // ========================================== class TeamStatsPage extends StatefulWidget { final Team team; const TeamStatsPage({super.key, required this.team}); @override State createState() => _TeamStatsPageState(); } class _TeamStatsPageState extends State { final StatsController _controller = StatsController(); late String _teamImageUrl; bool _isUploadingTeamPhoto = false; bool _isPickerActive = false; @override void initState() { super.initState(); _teamImageUrl = widget.team.imageUrl; } Future _updateTeamPhoto() async { if (_isPickerActive) return; setState(() => _isPickerActive = true); try { final File? croppedFile = await _controller.pickAndCropImage(context); if (croppedFile == null) return; setState(() => _isUploadingTeamPhoto = true); final fileName = 'team_${widget.team.id}_${DateTime.now().millisecondsSinceEpoch}.png'; final supabase = Supabase.instance.client; await supabase.storage.from('avatars').upload(fileName, croppedFile, fileOptions: const FileOptions(upsert: true)); final publicUrl = supabase.storage.from('avatars').getPublicUrl(fileName); await supabase.from('teams').update({'image_url': publicUrl}).eq('id', widget.team.id); if (_teamImageUrl.isNotEmpty && _teamImageUrl.startsWith('http')) { final oldPath = _controller.extractPathFromUrl(_teamImageUrl, 'avatars'); if (oldPath != null) await supabase.storage.from('avatars').remove([oldPath]); } if (mounted) setState(() => _teamImageUrl = publicUrl); } catch (e) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Erro: $e"), backgroundColor: AppTheme.primaryRed)); } finally { if (mounted) { setState(() { _isUploadingTeamPhoto = false; _isPickerActive = false; }); } } } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: Column( children: [ StatsHeader(team: widget.team, currentImageUrl: _teamImageUrl, onEditPhoto: _updateTeamPhoto, isUploading: _isUploadingTeamPhoto), Expanded( child: StreamBuilder>( stream: _controller.getMembers(widget.team.id), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const SkeletonLoadingStats(); } if (snapshot.hasError) return Center(child: Text("Erro ao carregar: ${snapshot.error}", style: TextStyle(color: Theme.of(context).colorScheme.onSurface))); final members = snapshot.data ?? []; final coaches = members.where((m) => m.type == 'Treinador').toList()..sort((a, b) => a.name.compareTo(b.name)); final players = members.where((m) => m.type == 'Jogador').toList()..sort((a, b) { int numA = int.tryParse(a.number ?? '999') ?? 999; int numB = int.tryParse(b.number ?? '999') ?? 999; return numA.compareTo(numB); }); return RefreshIndicator( color: AppTheme.primaryRed, onRefresh: () async => setState(() {}), child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), padding: EdgeInsets.all(16.0 * context.sf), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ StatsSummaryCard(total: members.length), SizedBox(height: 30 * context.sf), if (coaches.isNotEmpty) ...[ const StatsSectionTitle(title: "Treinadores"), ...coaches.map((c) => PersonCard(person: c, isCoach: true, onEdit: () => _controller.showEditPersonDialog(context, widget.team.id, c), onDelete: () => _confirmDelete(context, c))), SizedBox(height: 30 * context.sf), ], const StatsSectionTitle(title: "Jogadores"), if (players.isEmpty) Padding(padding: EdgeInsets.only(top: 20 * context.sf), child: Text("Nenhum jogador nesta equipa.", style: TextStyle(color: Colors.grey, fontSize: 16 * context.sf))) else ...players.map((p) => PersonCard(person: p, isCoach: false, onEdit: () => _controller.showEditPersonDialog(context, widget.team.id, p), onDelete: () => _confirmDelete(context, p))), SizedBox(height: 80 * context.sf), ], ), ), ); }, ), ), ], ), floatingActionButton: FloatingActionButton( heroTag: 'fab_team_${widget.team.id}', onPressed: () => _controller.showAddPersonDialog(context, widget.team.id), backgroundColor: AppTheme.successGreen, child: Icon(Icons.add, color: Colors.white, size: 24 * context.sf), ), ); } void _confirmDelete(BuildContext context, Person person) { showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: Theme.of(context).colorScheme.surface, title: Text("Eliminar Membro?", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)), content: Text("Tens a certeza que queres remover ${person.name}?", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("Cancelar", style: TextStyle(color: Colors.grey))), TextButton( onPressed: () { Navigator.pop(ctx); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("A remover ${person.name}..."), duration: const Duration(seconds: 1))); _controller.deletePerson(person).catchError((e) { if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Erro: $e"), backgroundColor: AppTheme.primaryRed)); }); }, child: const Text("Eliminar", style: TextStyle(color: AppTheme.primaryRed)), ), ], ), ); } } // ========================================== // 3. CONTROLLER // ========================================== class StatsController { final _supabase = Supabase.instance.client; Stream> getMembers(String teamId) { return _supabase.from('members').stream(primaryKey: ['id']).eq('team_id', teamId).map((data) => data.map((json) => Person.fromMap(json)).toList()); } String? extractPathFromUrl(String url, String bucket) { if (url.isEmpty) return null; final parts = url.split('/$bucket/'); if (parts.length > 1) return parts.last; return null; } Future deletePerson(Person person) async { try { await _supabase.from('members').delete().eq('id', person.id); if (person.imageUrl != null && person.imageUrl!.isNotEmpty) { final path = extractPathFromUrl(person.imageUrl!, 'avatars'); if (path != null) await _supabase.storage.from('avatars').remove([path]); } } catch (e) { debugPrint("Erro ao eliminar: $e"); } } void showAddPersonDialog(BuildContext context, String teamId) { _showForm(context, teamId: teamId); } void showEditPersonDialog(BuildContext context, String teamId, Person person) { _showForm(context, teamId: teamId, person: person); } Future pickAndCropImage(BuildContext context) async { final picker = ImagePicker(); final pickedFile = await picker.pickImage(source: ImageSource.gallery); if (pickedFile == null) return null; CroppedFile? croppedFile = await ImageCropper().cropImage( sourcePath: pickedFile.path, aspectRatio: const CropAspectRatio(ratioX: 1, ratioY: 1), uiSettings: [ AndroidUiSettings( toolbarTitle: 'Recortar Foto', toolbarColor: AppTheme.primaryRed, toolbarWidgetColor: Colors.white, initAspectRatio: CropAspectRatioPreset.square, lockAspectRatio: true, hideBottomControls: true, ), IOSUiSettings( title: 'Recortar Foto', aspectRatioLockEnabled: true, resetButtonHidden: true, ), ], ); if (croppedFile != null) { return File(croppedFile.path); } return null; } 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 ?? ''); String selectedType = person?.type ?? 'Jogador'; File? selectedImage; bool isUploading = false; bool isPickerActive = false; String? currentImageUrl = isEdit ? person.imageUrl : null; String? nameError; String? numError; showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, setState) => AlertDialog( backgroundColor: Theme.of(context).colorScheme.surface, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * context.sf)), title: Text(isEdit ? "Editar Membro" : "Novo Membro", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ GestureDetector( onTap: () async { if (isPickerActive) return; setState(() => isPickerActive = true); try { final File? croppedFile = await pickAndCropImage(context); if (croppedFile != null) { setState(() => selectedImage = croppedFile); } } finally { setState(() => isPickerActive = false); } }, child: Stack( alignment: Alignment.center, children: [ // 👇 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, child: Container( padding: EdgeInsets.all(6 * context.sf), decoration: BoxDecoration(color: AppTheme.primaryRed, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2)), child: Icon(Icons.edit, color: Colors.white, size: 14 * context.sf), ), ), ], ), ), SizedBox(height: 20 * context.sf), TextField( controller: nameCtrl, style: TextStyle(color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration( labelText: "Nome Completo", errorText: nameError, ), textCapitalization: TextCapitalization.words, ), SizedBox(height: 15 * context.sf), DropdownButtonFormField( value: selectedType, dropdownColor: Theme.of(context).colorScheme.surface, style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 16 * context.sf), 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") ...[ SizedBox(height: 15 * context.sf), TextField( controller: numCtrl, style: TextStyle(color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration( labelText: "Número da Camisola", errorText: numError, ), keyboardType: TextInputType.number, ), ] ], ), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("Cancelar", style: TextStyle(color: Colors.grey))), ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: AppTheme.successGreen, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8 * context.sf))), onPressed: isUploading ? null : () async { setState(() { nameError = null; numError = null; }); if (nameCtrl.text.trim().isEmpty) { setState(() => nameError = "O nome é obrigatório"); return; } setState(() => isUploading = true); String? numeroFinal = (selectedType == "Treinador") ? null : (numCtrl.text.trim().isEmpty ? null : numCtrl.text.trim()); try { String? finalImageUrl = currentImageUrl; if (selectedImage != null) { final fileName = 'person_${DateTime.now().millisecondsSinceEpoch}.png'; await _supabase.storage.from('avatars').upload(fileName, selectedImage!, fileOptions: const FileOptions(upsert: true)); finalImageUrl = _supabase.storage.from('avatars').getPublicUrl(fileName); if (currentImageUrl != null && currentImageUrl!.isNotEmpty) { final oldPath = extractPathFromUrl(currentImageUrl!, 'avatars'); if (oldPath != null) await _supabase.storage.from('avatars').remove([oldPath]); } } if (isEdit) { await _supabase.from('members').update({ 'name': nameCtrl.text.trim(), 'type': selectedType, 'number': numeroFinal, 'image_url': finalImageUrl, }).eq('id', person.id); } else { await _supabase.from('members').insert({ 'team_id': teamId, 'name': nameCtrl.text.trim(), 'type': selectedType, 'number': numeroFinal, 'image_url': finalImageUrl, }); } if (ctx.mounted) Navigator.pop(ctx); } catch (e) { setState(() { isUploading = false; if (e is PostgrestException && e.code == '23505') { numError = "Este número já está em uso!"; } else if (e.toString().toLowerCase().contains('unique') || e.toString().toLowerCase().contains('duplicate')) { numError = "Este número já está em uso!"; } else { nameError = "Erro ao guardar. Tente novamente."; } }); } }, child: isUploading ? SizedBox(width: 16 * context.sf, height: 16 * context.sf, child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : const Text("Guardar"), ) ], ), ), ); } }