Files
PlayMaker/lib/screens/team_stats_page.dart
2026-03-22 01:40:29 +00:00

644 lines
27 KiB
Dart

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';
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)
// ==========================================
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: [
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,
),
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 + NÚMERO + NOME E CACHE) ---
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;
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: [
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,
),
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<TeamStatsPage> createState() => _TeamStatsPageState();
}
class _TeamStatsPageState extends State<TeamStatsPage> {
final StatsController _controller = StatsController();
late String _teamImageUrl;
bool _isUploadingTeamPhoto = false;
bool _isPickerActive = false;
@override
void initState() {
super.initState();
_teamImageUrl = widget.team.imageUrl;
}
Future<void> _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<List<Person>>(
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: () {
// ⚡ 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));
});
},
child: const Text("Eliminar", style: TextStyle(color: AppTheme.primaryRed)),
),
],
),
);
}
}
// ==========================================
// 3. CONTROLLER
// ==========================================
class StatsController {
final _supabase = Supabase.instance.client;
Stream<List<Person>> 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<void> 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<File?> 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;
// 👇 VARIÁVEIS PARA O TEXTO PEQUENO VERMELHO (ESTILO LOGIN) 👇
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: [
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,
),
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, // 👇 ERRO PEQUENO AQUI
),
textCapitalization: TextCapitalization.words,
),
SizedBox(height: 15 * context.sf),
DropdownButtonFormField<String>(
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, // 👇 ERRO PEQUENO AQUI
),
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 {
// Limpa os erros antes de tentar de novo
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) {
// 👇 AGORA OS ERROS VÃO DIRETOS PARA OS CAMPOS (ESTILO LOGIN) 👇
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"),
)
],
),
),
);
}
}