sabado
This commit is contained in:
@@ -1,23 +1,38 @@
|
||||
import 'dart:async';
|
||||
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 DO TEMA!
|
||||
import 'package:playmaker/classe/theme.dart';
|
||||
import '../models/team_model.dart';
|
||||
import '../models/person_model.dart';
|
||||
import '../utils/size_extension.dart'; // 👇 SUPERPODER SF
|
||||
import '../utils/size_extension.dart';
|
||||
|
||||
// --- CABEÇALHO ---
|
||||
// ==========================================
|
||||
// 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});
|
||||
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, // 👇 Usando a cor oficial
|
||||
color: AppTheme.primaryRed,
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(30 * context.sf),
|
||||
bottomRight: Radius.circular(30 * context.sf)
|
||||
@@ -26,23 +41,42 @@ class StatsHeader extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: Colors.white, size: 24 * context.sf),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: Icon(Icons.arrow_back, color: Colors.white, size: 24 * context.sf),
|
||||
onPressed: () => Navigator.pop(context)
|
||||
),
|
||||
SizedBox(width: 10 * context.sf),
|
||||
|
||||
CircleAvatar(
|
||||
radius: 24 * context.sf,
|
||||
backgroundColor: Colors.white24,
|
||||
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: 20 * 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)),
|
||||
)
|
||||
: null,
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(width: 15 * context.sf),
|
||||
@@ -50,15 +84,8 @@ class StatsHeader extends StatelessWidget {
|
||||
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)
|
||||
),
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -71,41 +98,28 @@ class StatsHeader extends StatelessWidget {
|
||||
// --- CARD DE RESUMO ---
|
||||
class StatsSummaryCard extends StatelessWidget {
|
||||
final int total;
|
||||
|
||||
const StatsSummaryCard({super.key, required this.total});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 👇 Adapta-se ao Modo Claro/Escuro
|
||||
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)),
|
||||
),
|
||||
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), // 👇 Cor do tema
|
||||
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 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)
|
||||
),
|
||||
Text("$total", style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 28 * context.sf, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -116,7 +130,6 @@ class StatsSummaryCard extends StatelessWidget {
|
||||
// --- TÍTULO DE SECÇÃO ---
|
||||
class StatsSectionTitle extends StatelessWidget {
|
||||
final String title;
|
||||
|
||||
const StatsSectionTitle({super.key, required this.title});
|
||||
|
||||
@override
|
||||
@@ -124,79 +137,107 @@ class StatsSectionTitle extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface)
|
||||
),
|
||||
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 (JOGADOR/TREINADOR) ---
|
||||
// --- 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,
|
||||
});
|
||||
const PersonCard({super.key, required this.person, required this.isCoach, required this.onEdit, required this.onDelete});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 👇 Adapta as cores do Card ao Modo Escuro e ao Tema
|
||||
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,
|
||||
elevation: 2,
|
||||
color: isCoach ? coachBg : defaultBg,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * context.sf)),
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16 * context.sf, vertical: 4 * context.sf),
|
||||
leading: isCoach
|
||||
? CircleAvatar(
|
||||
radius: 22 * context.sf,
|
||||
backgroundColor: AppTheme.warningAmber, // 👇 Cor do tema
|
||||
child: Icon(Icons.person, color: Colors.white, size: 24 * context.sf)
|
||||
)
|
||||
: Container(
|
||||
width: 45 * context.sf,
|
||||
height: 45 * context.sf,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryRed.withOpacity(0.1), // 👇 Cor do tema
|
||||
borderRadius: BorderRadius.circular(10 * context.sf)
|
||||
),
|
||||
child: Text(
|
||||
person.number ?? "J",
|
||||
style: TextStyle(color: AppTheme.primaryRed, fontWeight: FontWeight.bold, fontSize: 16 * context.sf)
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
person.name,
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16 * context.sf, color: Theme.of(context).colorScheme.onSurface)
|
||||
),
|
||||
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16 * context.sf, vertical: 12 * context.sf),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.edit_outlined, color: Colors.blue, size: 22 * context.sf),
|
||||
onPressed: onEdit,
|
||||
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,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.delete_outline, color: AppTheme.primaryRed, size: 22 * context.sf), // 👇 Cor do tema
|
||||
onPressed: onDelete,
|
||||
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)),
|
||||
),
|
||||
]
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -207,10 +248,8 @@ class PersonCard extends StatelessWidget {
|
||||
// ==========================================
|
||||
// 2. PÁGINA PRINCIPAL
|
||||
// ==========================================
|
||||
|
||||
class TeamStatsPage extends StatefulWidget {
|
||||
final Team team;
|
||||
|
||||
const TeamStatsPage({super.key, required this.team});
|
||||
|
||||
@override
|
||||
@@ -219,31 +258,79 @@ class TeamStatsPage extends StatefulWidget {
|
||||
|
||||
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, // 👇 Adapta-se ao Modo Escuro
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
body: Column(
|
||||
children: [
|
||||
StatsHeader(team: widget.team),
|
||||
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 Center(child: CircularProgressIndicator(color: AppTheme.primaryRed));
|
||||
return const SkeletonLoadingStats();
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text("Erro ao carregar: ${snapshot.error}", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)));
|
||||
}
|
||||
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();
|
||||
final players = members.where((m) => m.type == 'Jogador').toList();
|
||||
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,
|
||||
@@ -257,32 +344,17 @@ class _TeamStatsPageState extends State<TeamStatsPage> {
|
||||
StatsSummaryCard(total: members.length),
|
||||
SizedBox(height: 30 * context.sf),
|
||||
|
||||
// TREINADORES
|
||||
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),
|
||||
)),
|
||||
...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),
|
||||
],
|
||||
|
||||
// JOGADORES
|
||||
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)),
|
||||
)
|
||||
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),
|
||||
)),
|
||||
...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),
|
||||
],
|
||||
),
|
||||
@@ -296,13 +368,13 @@ class _TeamStatsPageState extends State<TeamStatsPage> {
|
||||
floatingActionButton: FloatingActionButton(
|
||||
heroTag: 'fab_team_${widget.team.id}',
|
||||
onPressed: () => _controller.showAddPersonDialog(context, widget.team.id),
|
||||
backgroundColor: AppTheme.successGreen, // 👇 Cor de sucesso do tema
|
||||
backgroundColor: AppTheme.successGreen,
|
||||
child: Icon(Icons.add, color: Colors.white, size: 24 * context.sf),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDelete(BuildContext context, Person person) {
|
||||
void _confirmDelete(BuildContext context, Person person) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
@@ -310,53 +382,91 @@ class _TeamStatsPageState extends State<TeamStatsPage> {
|
||||
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),
|
||||
child: const Text("Cancelar", style: TextStyle(color: Colors.grey))
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await _controller.deletePerson(person.id);
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
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: Text("Eliminar", style: TextStyle(color: AppTheme.primaryRed)), // 👇 Cor oficial
|
||||
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)
|
||||
.order('name', ascending: true)
|
||||
.map((data) => data.map((json) => Person.fromMap(json)).toList());
|
||||
return _supabase.from('members').stream(primaryKey: ['id']).eq('team_id', teamId).map((data) => data.map((json) => Person.fromMap(json)).toList());
|
||||
}
|
||||
|
||||
Future<void> deletePerson(String personId) async {
|
||||
try {
|
||||
await _supabase.from('members').delete().eq('id', personId);
|
||||
} catch (e) {
|
||||
debugPrint("Erro ao eliminar: $e");
|
||||
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 showAddPersonDialog(BuildContext context, String teamId) { _showForm(context, teamId: teamId); }
|
||||
void showEditPersonDialog(BuildContext context, String teamId, Person person) { _showForm(context, teamId: teamId, person: person); }
|
||||
|
||||
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}) {
|
||||
@@ -364,6 +474,15 @@ class StatsController {
|
||||
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,
|
||||
@@ -371,18 +490,58 @@ class StatsController {
|
||||
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)
|
||||
),
|
||||
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: const InputDecoration(labelText: "Nome Completo"),
|
||||
decoration: InputDecoration(
|
||||
labelText: "Nome Completo",
|
||||
errorText: nameError, // 👇 ERRO PEQUENO AQUI
|
||||
),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
),
|
||||
SizedBox(height: 15 * context.sf),
|
||||
@@ -391,19 +550,18 @@ class StatsController {
|
||||
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);
|
||||
},
|
||||
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: const InputDecoration(labelText: "Número da Camisola"),
|
||||
decoration: InputDecoration(
|
||||
labelText: "Número da Camisola",
|
||||
errorText: numError, // 👇 ERRO PEQUENO AQUI
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
]
|
||||
@@ -411,29 +569,46 @@ class StatsController {
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text("Cancelar", style: TextStyle(color: Colors.grey))
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("Cancelar", style: TextStyle(color: Colors.grey))),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.successGreen, // 👇 Cor verde do tema
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8 * context.sf))
|
||||
),
|
||||
onPressed: () async {
|
||||
if (nameCtrl.text.trim().isEmpty) return;
|
||||
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;
|
||||
});
|
||||
|
||||
String? numeroFinal = (selectedType == "Treinador")
|
||||
? null
|
||||
: (numCtrl.text.trim().isEmpty ? null : numCtrl.text.trim());
|
||||
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({
|
||||
@@ -441,23 +616,25 @@ class StatsController {
|
||||
'name': nameCtrl.text.trim(),
|
||||
'type': selectedType,
|
||||
'number': numeroFinal,
|
||||
'image_url': finalImageUrl,
|
||||
});
|
||||
}
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
} catch (e) {
|
||||
debugPrint("Erro Supabase: $e");
|
||||
if (ctx.mounted) {
|
||||
String errorMsg = "Erro ao guardar: $e";
|
||||
if (e.toString().contains('unique')) {
|
||||
errorMsg = "Já existe um membro com este numero na equipa.";
|
||||
// 👇 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.";
|
||||
}
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(content: Text(errorMsg), backgroundColor: AppTheme.primaryRed) // 👇 Cor oficial para erro
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
child: const Text("Guardar"),
|
||||
child: isUploading ? SizedBox(width: 16 * context.sf, height: 16 * context.sf, child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : const Text("Guardar"),
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user