Compare commits

...

13 Commits

Author SHA1 Message Date
ce25fe6499 sabado 2026-04-18 02:56:44 +01:00
4f2a220cd6 corrigir os pixel no botao de faltas 2026-04-15 12:46:54 +01:00
fb85566e3f fazer pdf 2026-04-14 17:19:21 +01:00
2544e52636 eliminar e tentativa de partilhar 2026-04-04 01:28:47 +01:00
1b08ed7d07 tempo e reservas e outos 2026-04-03 01:33:29 +01:00
c6255759c5 assist 2026-03-25 09:48:25 +00:00
9cf7915d12 domingo 2026-03-22 16:16:08 +00:00
be103c66b0 orientação 2026-03-22 15:28:56 +00:00
00fee30792 sabado 2026-03-22 01:40:29 +00:00
6c89b7ab8c nao aparecer para outro utilizador 2026-03-19 10:40:53 +00:00
8adea3f7b6 bora vamos 2026-03-18 12:39:03 +00:00
b77ae2eac6 new branch 2026-03-18 01:52:29 +00:00
ed4cff34f6 Reinício do projeto: Estado atual completo das páginas 2026-03-18 01:36:30 +00:00
34 changed files with 3997 additions and 2269 deletions

View File

@@ -9,7 +9,6 @@ android {
namespace = "com.example.playmaker" namespace = "com.example.playmaker"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
//ndkVersion = flutter.ndkVersion //ndkVersion = flutter.ndkVersion
ndkVersion = "26.1.10909125"
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11

View File

@@ -1,8 +1,18 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.INTERNET"/>
<application <application
android:label="playmaker" android:label="playmaker"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity
android:name="com.yalantis.ucrop.UCropActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.AppCompat.Light.NoActionBar"/>
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 564 KiB

BIN
assets/playmaker-logos.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -45,5 +45,12 @@
<true/> <true/>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>A PlayMaker precisa de aceder à tua galeria para poderes escolher uma foto de perfil.</string>
<key>NSCameraUsageDescription</key>
<string>A PlayMaker precisa de aceder à câmara para poderes tirar uma foto de perfil.</string>
</dict>
</dict> </dict>
</plist> </plist>

View File

@@ -4,50 +4,44 @@ import '../models/game_model.dart';
class GameController { class GameController {
final _supabase = Supabase.instance.client; final _supabase = Supabase.instance.client;
// 1. LER JOGOS (Stream em Tempo Real) String get myUserId => _supabase.auth.currentUser?.id ?? '';
// LER JOGOS
Stream<List<Game>> get gamesStream { Stream<List<Game>> get gamesStream {
return _supabase return _supabase
.from('games') // 1. Fica à escuta da tabela original (Garante o Tempo Real!) .from('games')
.stream(primaryKey: ['id']) .stream(primaryKey: ['id'])
.eq('user_id', myUserId)
.asyncMap((event) async { .asyncMap((event) async {
// 2. Sempre que a tabela 'games' mudar (novo jogo, alteração de resultado), final data = await _supabase
// vamos buscar os dados já misturados com as imagens à nossa View. .from('games')
final viewData = await _supabase
.from('games_with_logos')
.select() .select()
.eq('user_id', myUserId)
.order('game_date', ascending: false); .order('game_date', ascending: false);
// 3. Convertemos para a nossa lista de objetos Game // O Game.fromMap agora faz o trabalho sujo todo!
return viewData.map((json) => Game.fromMap(json)).toList(); return data.map((json) => Game.fromMap(json)).toList();
}); });
} }
// ========================================================================= // LER JOGOS COM FILTROS
// 👇 NOVO: LER JOGOS COM FILTROS DE EQUIPA E TEMPORADA (MANTÉM OS LOGOS)
// =========================================================================
// =========================================================================
// 👇 LER JOGOS COM FILTROS DE EQUIPA E TEMPORADA (SEM ERROS DE QUERY)
// =========================================================================
Stream<List<Game>> getFilteredGames({required String teamFilter, required String seasonFilter}) { Stream<List<Game>> getFilteredGames({required String teamFilter, required String seasonFilter}) {
return _supabase return _supabase
.from('games') .from('games')
.stream(primaryKey: ['id']) .stream(primaryKey: ['id'])
.eq('user_id', myUserId)
.asyncMap((event) async { .asyncMap((event) async {
// 1. Começamos a query APENAS com o select (Sem o order ainda!) var query = _supabase.from('games').select().eq('user_id', myUserId);
var query = _supabase.from('games_with_logos').select();
// 2. Se a temporada não for "Todas", aplicamos o filtro AQUI
if (seasonFilter != 'Todas') { if (seasonFilter != 'Todas') {
query = query.eq('season', seasonFilter); query = query.eq('season', seasonFilter);
} }
// 3. Executamos a query e aplicamos o ORDER BY no final final data = await query.order('game_date', ascending: false);
final viewData = await query.order('game_date', ascending: false);
List<Game> games = viewData.map((json) => Game.fromMap(json)).toList(); List<Game> games = data.map((json) => Game.fromMap(json)).toList();
// 4. Filtramos a equipa em memória
if (teamFilter != 'Todas') { if (teamFilter != 'Todas') {
games = games.where((g) => g.myTeam == teamFilter || g.opponentTeam == teamFilter).toList(); games = games.where((g) => g.myTeam == teamFilter || g.opponentTeam == teamFilter).toList();
} }
@@ -55,28 +49,49 @@ class GameController {
return games; return games;
}); });
} }
// 2. CRIAR JOGO
// Retorna o ID do jogo criado para podermos navegar para o placar // CRIAR JOGO
Future<String?> createGame(String myTeam, String opponent, String season) async { Future<String?> createGame(String myTeam, String opponent, String season) async {
try { try {
final response = await _supabase.from('games').insert({ final response = await _supabase.from('games').insert({
'user_id': myUserId,
'my_team': myTeam, 'my_team': myTeam,
'opponent_team': opponent, 'opponent_team': opponent,
'season': season, 'season': season,
'my_score': 0, 'my_score': 0,
'opponent_score': 0, 'opponent_score': 0,
'status': 'Decorrer', // Começa como "Decorrer" 'status': 'Decorrer',
'game_date': DateTime.now().toIso8601String(), 'game_date': DateTime.now().toIso8601String(),
}).select().single(); // .select().single() retorna o objeto criado // 👇 Preenchemos logo com os valores iniciais da tua Base de Dados
'remaining_seconds': 600, // Assume 10 minutos (600s)
'my_timeouts': 0,
'opp_timeouts': 0,
'current_quarter': 1,
'top_pts_name': '---',
'top_ast_name': '---',
'top_rbs_name': '---',
'top_def_name': '---',
'mvp_name': '---',
}).select().single();
return response['id']; // Retorna o UUID gerado pelo Supabase return response['id']?.toString();
} catch (e) { } catch (e) {
print("Erro ao criar jogo: $e"); print("Erro ao criar jogo: $e");
return null; return null;
} }
}
void dispose() { }
// Não é necessário fechar streams do Supabase manualmente aqui // ELIMINAR JOGO
Future<bool> deleteGame(String gameId) async {
try {
await _supabase.from('games').delete().eq('id', gameId);
// Como o Supabase tem Cascade Delete (se configurado), vai apagar também
// as stats e shot_locations associadas a este game_id automaticamente.
return true;
} catch (e) {
print("Erro ao eliminar jogo: $e");
return false;
} }
} }
void dispose() {}
}

View File

@@ -1,12 +1,15 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart';
class ShotRecord { class ShotRecord {
final double relativeX; final double relativeX;
final double relativeY; final double relativeY;
final bool isMake; final bool isMake;
final String playerId;
final String playerName; final String playerName;
final String? zone; final String? zone;
final int? points; final int? points;
@@ -15,28 +18,36 @@ class ShotRecord {
required this.relativeX, required this.relativeX,
required this.relativeY, required this.relativeY,
required this.isMake, required this.isMake,
required this.playerId,
required this.playerName, required this.playerName,
this.zone, this.zone,
this.points, this.points,
}); });
Map<String, dynamic> toJson() => {
'relativeX': relativeX, 'relativeY': relativeY, 'isMake': isMake,
'playerId': playerId, 'playerName': playerName, 'zone': zone, 'points': points,
};
factory ShotRecord.fromJson(Map<String, dynamic> json) => ShotRecord(
relativeX: json['relativeX'], relativeY: json['relativeY'], isMake: json['isMake'],
playerId: json['playerId'], playerName: json['playerName'], zone: json['zone'], points: json['points'],
);
} }
class PlacarController { class PlacarController extends ChangeNotifier {
final String gameId; final String gameId;
final String myTeam; final String myTeam;
final String opponentTeam; final String opponentTeam;
final VoidCallback onUpdate;
PlacarController({ PlacarController({
required this.gameId, required this.gameId,
required this.myTeam, required this.myTeam,
required this.opponentTeam, required this.opponentTeam,
required this.onUpdate
}); });
bool isLoading = true; bool isLoading = true;
bool isSaving = false; bool isSaving = false;
bool gameWasAlreadyFinished = false; bool gameWasAlreadyFinished = false;
int myScore = 0; int myScore = 0;
@@ -55,19 +66,21 @@ class PlacarController {
List<String> oppCourt = []; List<String> oppCourt = [];
List<String> oppBench = []; List<String> oppBench = [];
Map<String, String> playerNames = {};
Map<String, String> playerNumbers = {}; Map<String, String> playerNumbers = {};
Map<String, Map<String, int>> playerStats = {}; Map<String, Map<String, int>> playerStats = {};
Map<String, String> playerDbIds = {};
bool showMyBench = false; bool showMyBench = false;
bool showOppBench = false; bool showOppBench = false;
bool isSelectingShotLocation = false; bool isSelectingShotLocation = false;
String? pendingAction; String? pendingAction;
String? pendingPlayer; String? pendingPlayerId;
List<ShotRecord> matchShots = []; List<ShotRecord> matchShots = [];
Duration duration = const Duration(minutes: 10); List<String> playByPlay = [];
ValueNotifier<Duration> durationNotifier = ValueNotifier(const Duration(minutes: 10));
Timer? timer; Timer? timer;
bool isRunning = false; bool isRunning = false;
@@ -81,16 +94,9 @@ class PlacarController {
try { try {
await Future.delayed(const Duration(milliseconds: 1500)); await Future.delayed(const Duration(milliseconds: 1500));
myCourt.clear(); myCourt.clear(); myBench.clear(); oppCourt.clear(); oppBench.clear();
myBench.clear(); playerNames.clear(); playerStats.clear(); playerNumbers.clear();
oppCourt.clear(); matchShots.clear(); playByPlay.clear(); myFouls = 0; opponentFouls = 0;
oppBench.clear();
playerStats.clear();
playerNumbers.clear();
playerDbIds.clear();
matchShots.clear(); // Limpa as bolas do último jogo
myFouls = 0;
opponentFouls = 0;
final gameResponse = await supabase.from('games').select().eq('id', gameId).single(); final gameResponse = await supabase.from('games').select().eq('id', gameId).single();
@@ -98,7 +104,7 @@ class PlacarController {
opponentScore = int.tryParse(gameResponse['opponent_score']?.toString() ?? '0') ?? 0; opponentScore = int.tryParse(gameResponse['opponent_score']?.toString() ?? '0') ?? 0;
int totalSeconds = int.tryParse(gameResponse['remaining_seconds']?.toString() ?? '600') ?? 600; int totalSeconds = int.tryParse(gameResponse['remaining_seconds']?.toString() ?? '600') ?? 600;
duration = Duration(seconds: totalSeconds); durationNotifier.value = Duration(seconds: totalSeconds);
myTimeoutsUsed = int.tryParse(gameResponse['my_timeouts']?.toString() ?? '0') ?? 0; myTimeoutsUsed = int.tryParse(gameResponse['my_timeouts']?.toString() ?? '0') ?? 0;
opponentTimeoutsUsed = int.tryParse(gameResponse['opp_timeouts']?.toString() ?? '0') ?? 0; opponentTimeoutsUsed = int.tryParse(gameResponse['opp_timeouts']?.toString() ?? '0') ?? 0;
@@ -106,6 +112,12 @@ class PlacarController {
gameWasAlreadyFinished = gameResponse['status'] == 'Terminado'; gameWasAlreadyFinished = gameResponse['status'] == 'Terminado';
if (gameResponse['play_by_play'] != null) {
playByPlay = List<String>.from(gameResponse['play_by_play']);
} else {
playByPlay = [];
}
final teamsResponse = await supabase.from('teams').select('id, name').inFilter('name', [myTeam, opponentTeam]); final teamsResponse = await supabase.from('teams').select('id, name').inFilter('name', [myTeam, opponentTeam]);
for (var t in teamsResponse) { for (var t in teamsResponse) {
if (t['name'] == myTeam) myTeamDbId = t['id']; if (t['name'] == myTeam) myTeamDbId = t['id'];
@@ -128,12 +140,7 @@ class PlacarController {
if (savedStats.containsKey(dbId)) { if (savedStats.containsKey(dbId)) {
var s = savedStats[dbId]; var s = savedStats[dbId];
playerStats[name] = { _loadSavedPlayerStats(dbId, s);
"pts": s['pts'] ?? 0, "rbs": s['rbs'] ?? 0, "ast": s['ast'] ?? 0,
"stl": s['stl'] ?? 0, "tov": s['tov'] ?? 0, "blk": s['blk'] ?? 0,
"fls": s['fls'] ?? 0, "fgm": s['fgm'] ?? 0, "fga": s['fga'] ?? 0,
"ftm": s['ftm'] ?? 0, "fta": s['fta'] ?? 0, "orb": s['orb'] ?? 0, "drb": s['drb'] ?? 0,
};
myFouls += (s['fls'] as int? ?? 0); myFouls += (s['fls'] as int? ?? 0);
} }
} }
@@ -147,55 +154,68 @@ class PlacarController {
if (savedStats.containsKey(dbId)) { if (savedStats.containsKey(dbId)) {
var s = savedStats[dbId]; var s = savedStats[dbId];
playerStats[name] = { _loadSavedPlayerStats(dbId, s);
"pts": s['pts'] ?? 0, "rbs": s['rbs'] ?? 0, "ast": s['ast'] ?? 0,
"stl": s['stl'] ?? 0, "tov": s['tov'] ?? 0, "blk": s['blk'] ?? 0,
"fls": s['fls'] ?? 0, "fgm": s['fgm'] ?? 0, "fga": s['fga'] ?? 0,
"ftm": s['ftm'] ?? 0, "fta": s['fta'] ?? 0, "orb": s['orb'] ?? 0, "drb": s['drb'] ?? 0,
};
opponentFouls += (s['fls'] as int? ?? 0); opponentFouls += (s['fls'] as int? ?? 0);
} }
} }
_padTeam(oppCourt, oppBench, "Adversário", isMyTeam: false); _padTeam(oppCourt, oppBench, "Adversário", isMyTeam: false);
// 👇 CARREGA AS BOLINHAS ANTIGAS (MAPA DE CALOR DO JOGO ATUAL) 👇
final shotsResponse = await supabase.from('shot_locations').select().eq('game_id', gameId); final shotsResponse = await supabase.from('shot_locations').select().eq('game_id', gameId);
for (var shotData in shotsResponse) { for (var shotData in shotsResponse) {
matchShots.add(ShotRecord( matchShots.add(ShotRecord(
relativeX: double.parse(shotData['relative_x'].toString()), relativeX: double.parse(shotData['relative_x'].toString()),
relativeY: double.parse(shotData['relative_y'].toString()), relativeY: double.parse(shotData['relative_y'].toString()),
isMake: shotData['is_make'] == true, isMake: shotData['is_make'] == true,
playerId: shotData['member_id'].toString(),
playerName: shotData['player_name'].toString(), playerName: shotData['player_name'].toString(),
zone: shotData['zone']?.toString(), zone: shotData['zone']?.toString(),
points: shotData['points'] != null ? int.parse(shotData['points'].toString()) : null, points: shotData['points'] != null ? int.parse(shotData['points'].toString()) : null,
)); ));
} }
await _loadLocalBackup();
isLoading = false; isLoading = false;
onUpdate(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint("Erro ao retomar jogo: $e"); debugPrint("Erro ao retomar jogo: $e");
_padTeam(myCourt, myBench, "Falha", isMyTeam: true);
_padTeam(oppCourt, oppBench, "Falha Opp", isMyTeam: false);
isLoading = false; isLoading = false;
onUpdate(); notifyListeners();
} }
} }
void _loadSavedPlayerStats(String dbId, Map<String, dynamic> s) {
playerStats[dbId] = {
"pts": s['pts'] ?? 0, "rbs": s['rbs'] ?? 0, "ast": s['ast'] ?? 0,
"stl": s['stl'] ?? 0, "tov": s['tov'] ?? 0, "blk": s['blk'] ?? 0,
"fls": s['fls'] ?? 0, "fgm": s['fgm'] ?? 0, "fga": s['fga'] ?? 0,
"ftm": s['ftm'] ?? 0, "fta": s['fta'] ?? 0, "orb": s['orb'] ?? 0, "drb": s['drb'] ?? 0,
"p2m": s['p2m'] ?? 0, "p2a": s['p2a'] ?? 0, "p3m": s['p3m'] ?? 0, "p3a": s['p3a'] ?? 0,
"so": s['so'] ?? 0, "il": s['il'] ?? 0, "li": s['li'] ?? 0,
"pa": s['pa'] ?? 0, "tres_s": s['tres_seg'] ?? 0, "dr": s['dr'] ?? 0,
"min": s['minutos_jogados'] ?? 0,
"sec": (s['minutos_jogados'] ?? 0) * 60,
};
}
void _registerPlayer({required String name, required String number, String? dbId, required bool isMyTeam, required bool isCourt}) { void _registerPlayer({required String name, required String number, String? dbId, required bool isMyTeam, required bool isCourt}) {
if (playerNumbers.containsKey(name)) name = "$name (Opp)"; String id = dbId ?? "fake_${DateTime.now().millisecondsSinceEpoch}_${math.Random().nextInt(9999)}";
playerNumbers[name] = number;
if (dbId != null) playerDbIds[name] = dbId;
playerStats[name] = { playerNames[id] = name;
playerNumbers[id] = number;
playerStats[id] = {
"pts": 0, "rbs": 0, "ast": 0, "stl": 0, "tov": 0, "blk": 0, "pts": 0, "rbs": 0, "ast": 0, "stl": 0, "tov": 0, "blk": 0,
"fls": 0, "fgm": 0, "fga": 0, "ftm": 0, "fta": 0, "orb": 0, "drb": 0 "fls": 0, "fgm": 0, "fga": 0, "ftm": 0, "fta": 0, "orb": 0, "drb": 0,
"p2m": 0, "p2a": 0, "p3m": 0, "p3a": 0,
"so": 0, "il": 0, "li": 0, "pa": 0, "tres_s": 0, "dr": 0,
"min": 0, "sec": 0
}; };
if (isMyTeam) { if (isMyTeam) {
if (isCourt) myCourt.add(name); else myBench.add(name); if (isCourt) myCourt.add(id); else myBench.add(id);
} else { } else {
if (isCourt) oppCourt.add(name); else oppBench.add(name); if (isCourt) oppCourt.add(id); else oppBench.add(id);
} }
} }
@@ -205,33 +225,93 @@ class PlacarController {
} }
} }
Future<void> _saveLocalBackup() async {
try {
final prefs = await SharedPreferences.getInstance();
final backupData = {
'myScore': myScore, 'opponentScore': opponentScore,
'myFouls': myFouls, 'opponentFouls': opponentFouls,
'currentQuarter': currentQuarter, 'duration': durationNotifier.value.inSeconds,
'myTimeoutsUsed': myTimeoutsUsed, 'opponentTimeoutsUsed': opponentTimeoutsUsed,
'playerStats': playerStats,
'myCourt': myCourt, 'myBench': myBench, 'oppCourt': oppCourt, 'oppBench': oppBench,
'matchShots': matchShots.map((s) => s.toJson()).toList(),
'playByPlay': playByPlay,
};
await prefs.setString('backup_$gameId', jsonEncode(backupData));
} catch (e) {
debugPrint("Erro no Auto-Save: $e");
}
}
Future<void> _loadLocalBackup() async {
try {
final prefs = await SharedPreferences.getInstance();
final String? backupString = prefs.getString('backup_$gameId');
if (backupString != null) {
final data = jsonDecode(backupString);
myScore = data['myScore']; opponentScore = data['opponentScore'];
myFouls = data['myFouls']; opponentFouls = data['opponentFouls'];
currentQuarter = data['currentQuarter']; durationNotifier.value = Duration(seconds: data['duration']);
myTimeoutsUsed = data['myTimeoutsUsed']; opponentTimeoutsUsed = data['opponentTimeoutsUsed'];
myCourt = List<String>.from(data['myCourt']); myBench = List<String>.from(data['myBench']);
oppCourt = List<String>.from(data['oppCourt']); oppBench = List<String>.from(data['oppBench']);
Map<String, dynamic> decodedStats = data['playerStats'];
playerStats = decodedStats.map((k, v) => MapEntry(k, Map<String, int>.from(v)));
List<dynamic> decodedShots = data['matchShots'];
matchShots = decodedShots.map((s) => ShotRecord.fromJson(s)).toList();
playByPlay = List<String>.from(data['playByPlay'] ?? []);
}
} catch (e) {
debugPrint("Erro ao carregar Auto-Save: $e");
}
}
void toggleTimer(BuildContext context) { void toggleTimer(BuildContext context) {
if (isRunning) { if (isRunning) {
timer?.cancel(); timer?.cancel();
_saveLocalBackup();
} else { } else {
timer = Timer.periodic(const Duration(seconds: 1), (timer) { timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (duration.inSeconds > 0) { if (durationNotifier.value.inSeconds > 0) {
duration -= const Duration(seconds: 1); durationNotifier.value -= const Duration(seconds: 1);
void addTimeToCourt(List<String> court) {
for (String id in court) {
if (playerStats.containsKey(id)) {
int currentSec = playerStats[id]!["sec"] ?? 0;
playerStats[id]!["sec"] = currentSec + 1;
playerStats[id]!["min"] = (currentSec + 1) ~/ 60;
}
}
}
addTimeToCourt(myCourt);
addTimeToCourt(oppCourt);
notifyListeners();
} else { } else {
timer.cancel(); timer.cancel();
isRunning = false; isRunning = false;
if (currentQuarter < 4) { if (currentQuarter < 4) {
currentQuarter++; currentQuarter++;
duration = const Duration(minutes: 10); durationNotifier.value = const Duration(minutes: 10);
myFouls = 0; myFouls = 0; opponentFouls = 0;
opponentFouls = 0; myTimeoutsUsed = 0; opponentTimeoutsUsed = 0;
myTimeoutsUsed = 0; _saveLocalBackup();
opponentTimeoutsUsed = 0;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Período $currentQuarter iniciado. Faltas e Timeouts resetados!'), backgroundColor: Colors.blue));
} else {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('FIM DO JOGO! Clica em Guardar para fechar a partida.'), backgroundColor: Colors.red));
} }
notifyListeners();
} }
onUpdate();
}); });
} }
isRunning = !isRunning; isRunning = !isRunning;
onUpdate(); notifyListeners();
} }
void useTimeout(bool isOpponent) { void useTimeout(bool isOpponent) {
@@ -242,14 +322,14 @@ class PlacarController {
} }
isRunning = false; isRunning = false;
timer?.cancel(); timer?.cancel();
onUpdate(); _saveLocalBackup();
notifyListeners();
} }
String formatTime() => "${duration.inMinutes.toString().padLeft(2, '0')}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}";
void handleActionDrag(BuildContext context, String action, String playerData) { void handleActionDrag(BuildContext context, String action, String playerData) {
String name = playerData.replaceAll("player_my_", "").replaceAll("player_opp_", ""); String playerId = playerData.replaceAll("player_my_", "").replaceAll("player_opp_", "");
final stats = playerStats[name]!; final stats = playerStats[playerId]!;
final name = playerNames[playerId]!;
if (stats["fls"]! >= 5 && action != "sub_foul") { if (stats["fls"]! >= 5 && action != "sub_foul") {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('🛑 $name atingiu 5 faltas e está expulso!'), backgroundColor: Colors.red)); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('🛑 $name atingiu 5 faltas e está expulso!'), backgroundColor: Colors.red));
@@ -258,91 +338,54 @@ class PlacarController {
if (action == "add_pts_2" || action == "add_pts_3" || action == "miss_2" || action == "miss_3") { if (action == "add_pts_2" || action == "add_pts_3" || action == "miss_2" || action == "miss_3") {
pendingAction = action; pendingAction = action;
pendingPlayer = playerData; pendingPlayerId = playerData;
isSelectingShotLocation = true; isSelectingShotLocation = true;
} else { } else {
commitStat(action, playerData); commitStat(action, playerData);
} }
onUpdate(); notifyListeners();
} }
void handleSubbing(BuildContext context, String action, String courtPlayerName, bool isOpponent) { void handleSubbing(BuildContext context, String action, String courtPlayerId, bool isOpponent) {
if (action.startsWith("bench_my_") && !isOpponent) { if (action.startsWith("bench_my_") && !isOpponent) {
String benchPlayer = action.replaceAll("bench_my_", ""); String benchPlayerId = action.replaceAll("bench_my_", "");
if (playerStats[benchPlayer]!["fls"]! >= 5) return; if (playerStats[benchPlayerId]!["fls"]! >= 5) return;
int courtIndex = myCourt.indexOf(courtPlayerName); int courtIndex = myCourt.indexOf(courtPlayerId);
int benchIndex = myBench.indexOf(benchPlayer); int benchIndex = myBench.indexOf(benchPlayerId);
myCourt[courtIndex] = benchPlayer; myCourt[courtIndex] = benchPlayerId;
myBench[benchIndex] = courtPlayerName; myBench[benchIndex] = courtPlayerId;
showMyBench = false; showMyBench = false;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Sai $courtPlayerName, Entra $benchPlayer')));
} }
if (action.startsWith("bench_opp_") && isOpponent) { if (action.startsWith("bench_opp_") && isOpponent) {
String benchPlayer = action.replaceAll("bench_opp_", ""); String benchPlayerId = action.replaceAll("bench_opp_", "");
if (playerStats[benchPlayer]!["fls"]! >= 5) return; if (playerStats[benchPlayerId]!["fls"]! >= 5) return;
int courtIndex = oppCourt.indexOf(courtPlayerName); int courtIndex = oppCourt.indexOf(courtPlayerId);
int benchIndex = oppBench.indexOf(benchPlayer); int benchIndex = oppBench.indexOf(benchPlayerId);
oppCourt[courtIndex] = benchPlayer; oppCourt[courtIndex] = benchPlayerId;
oppBench[benchIndex] = courtPlayerName; oppBench[benchIndex] = courtPlayerId;
showOppBench = false; showOppBench = false;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Sai $courtPlayerName, Entra $benchPlayer')));
} }
onUpdate(); _saveLocalBackup();
notifyListeners();
} }
// =========================================================================
// 👇 REGISTA PONTOS VINDO DO POP-UP AMARELO (E MARCA A BOLINHA)
// =========================================================================
void registerShotFromPopup(BuildContext context, String action, String targetPlayer, String zone, int points, double relativeX, double relativeY) { void registerShotFromPopup(BuildContext context, String action, String targetPlayer, String zone, int points, double relativeX, double relativeY) {
// 💡 AVISO AMIGÁVEL REMOVIDO. Agora podes marcar pontos mesmo com o tempo parado! String playerId = targetPlayer.replaceAll("player_my_", "").replaceAll("player_opp_", "");
String name = targetPlayer.replaceAll("player_my_", "").replaceAll("player_opp_", "");
bool isMyTeam = targetPlayer.startsWith("player_my_");
bool isMake = action.startsWith("add_"); bool isMake = action.startsWith("add_");
String name = playerNames[playerId] ?? "Jogador";
// 1. ATUALIZA A ESTATÍSTICA DO JOGADOR
if (playerStats.containsKey(name)) {
playerStats[name]!['fga'] = playerStats[name]!['fga']! + 1;
if (isMake) {
playerStats[name]!['fgm'] = playerStats[name]!['fgm']! + 1;
playerStats[name]!['pts'] = playerStats[name]!['pts']! + points;
// 2. ATUALIZA O PLACAR DA EQUIPA
if (isMyTeam) {
myScore += points;
} else {
opponentScore += points;
}
}
}
// 3. CRIA A BOLINHA PARA APARECER NO CAMPO
matchShots.add(ShotRecord( matchShots.add(ShotRecord(
relativeX: relativeX, relativeX: relativeX, relativeY: relativeY, isMake: isMake,
relativeY: relativeY, playerId: playerId, playerName: name, zone: zone, points: points
isMake: isMake,
playerName: name,
zone: zone,
points: points,
)); ));
// 4. MANDA UMA MENSAGEM NO ECRÃ String finalAction = isMake ? "add_pts_$points" : "miss_$points";
ScaffoldMessenger.of(context).showSnackBar( commitStat(finalAction, targetPlayer);
SnackBar( notifyListeners();
content: Text(isMake ? '🔥 $name MARCOU de $zone!' : '$name FALHOU de $zone!'),
backgroundColor: isMake ? Colors.green : Colors.red,
duration: const Duration(seconds: 2),
)
);
// 5. ATUALIZA O ECRÃ
onUpdate();
} }
// MANTIDO PARA CASO USES A MARCAÇÃO CLÁSSICA DIRETAMENTE NO CAMPO ESCURO
void registerShotLocation(BuildContext context, Offset position, Size size) { void registerShotLocation(BuildContext context, Offset position, Size size) {
if (pendingAction == null || pendingPlayer == null) return; if (pendingAction == null || pendingPlayerId == null) return;
bool is3Pt = pendingAction!.contains("_3"); bool is3Pt = pendingAction!.contains("_3");
bool is2Pt = pendingAction!.contains("_2"); bool is2Pt = pendingAction!.contains("_2");
@@ -355,21 +398,15 @@ class PlacarController {
bool isMake = pendingAction!.startsWith("add_pts_"); bool isMake = pendingAction!.startsWith("add_pts_");
double relX = position.dx / size.width; double relX = position.dx / size.width;
double relY = position.dy / size.height; double relY = position.dy / size.height;
String name = pendingPlayer!.replaceAll("player_my_", "").replaceAll("player_opp_", ""); String pId = pendingPlayerId!.replaceAll("player_my_", "").replaceAll("player_opp_", "");
matchShots.add(ShotRecord( matchShots.add(ShotRecord(relativeX: relX, relativeY: relY, isMake: isMake, playerId: pId, playerName: playerNames[pId]!));
relativeX: relX,
relativeY: relY,
isMake: isMake,
playerName: name
));
commitStat(pendingAction!, pendingPlayer!); commitStat(pendingAction!, pendingPlayerId!);
isSelectingShotLocation = false; isSelectingShotLocation = false; pendingAction = null; pendingPlayerId = null;
pendingAction = null; _saveLocalBackup();
pendingPlayer = null; notifyListeners();
onUpdate();
} }
bool _validateShotZone(Offset position, Size size, bool is3Pt) { bool _validateShotZone(Offset position, Size size, bool is3Pt) {
@@ -400,191 +437,213 @@ class PlacarController {
} }
void cancelShotLocation() { void cancelShotLocation() {
isSelectingShotLocation = false; pendingAction = null; pendingPlayer = null; onUpdate(); isSelectingShotLocation = false; pendingAction = null; pendingPlayerId = null; notifyListeners();
}
void registerFoul(String committerData, String foulType, String victimData) {
bool isOpponent = committerData.startsWith("player_opp_");
String committerId = committerData.replaceAll("player_my_", "").replaceAll("player_opp_", "");
final committerStats = playerStats[committerId]!;
final committerName = playerNames[committerId] ?? "Jogador";
committerStats["fls"] = committerStats["fls"]! + 1;
if (isOpponent) opponentFouls++; else myFouls++;
if (foulType == "Desqualificante") {
committerStats["fls"] = 5;
}
String logText = "cometeu Falta $foulType";
if (victimData.isNotEmpty) {
String victimId = victimData.replaceAll("player_my_", "").replaceAll("player_opp_", "");
final victimStats = playerStats[victimId]!;
final victimName = playerNames[victimId] ?? "Jogador";
victimStats["so"] = victimStats["so"]! + 1;
logText += " sobre $victimName ⚠️";
} else {
logText += " (Equipa/Banco) ⚠️";
}
String time = "${durationNotifier.value.inMinutes.toString().padLeft(2, '0')}:${durationNotifier.value.inSeconds.remainder(60).toString().padLeft(2, '0')}";
playByPlay.insert(0, "P$currentQuarter - $time: $committerName $logText");
_saveLocalBackup();
notifyListeners();
} }
void commitStat(String action, String playerData) { void commitStat(String action, String playerData) {
bool isOpponent = playerData.startsWith("player_opp_"); bool isOpponent = playerData.startsWith("player_opp_");
String name = playerData.replaceAll("player_my_", "").replaceAll("player_opp_", ""); String playerId = playerData.replaceAll("player_my_", "").replaceAll("player_opp_", "");
final stats = playerStats[name]!; final stats = playerStats[playerId]!;
final name = playerNames[playerId] ?? "Jogador";
String logText = "";
if (action.startsWith("add_pts_")) { if (action.startsWith("add_pts_")) {
int pts = int.parse(action.split("_").last); int pts = int.parse(action.split("_").last);
if (isOpponent) opponentScore += pts; else myScore += pts; if (isOpponent) opponentScore += pts; else myScore += pts;
stats["pts"] = stats["pts"]! + pts; stats["pts"] = stats["pts"]! + pts;
if (pts == 2 || pts == 3) { stats["fgm"] = stats["fgm"]! + 1; stats["fga"] = stats["fga"]! + 1; } if (pts == 2) { stats["fgm"] = stats["fgm"]! + 1; stats["fga"] = stats["fga"]! + 1; stats["p2m"] = stats["p2m"]! + 1; stats["p2a"] = stats["p2a"]! + 1; }
if (pts == 3) { stats["fgm"] = stats["fgm"]! + 1; stats["fga"] = stats["fga"]! + 1; stats["p3m"] = stats["p3m"]! + 1; stats["p3a"] = stats["p3a"]! + 1; }
if (pts == 1) { stats["ftm"] = stats["ftm"]! + 1; stats["fta"] = stats["fta"]! + 1; } if (pts == 1) { stats["ftm"] = stats["ftm"]! + 1; stats["fta"] = stats["fta"]! + 1; }
logText = "marcou $pts pontos 🏀";
} }
else if (action.startsWith("sub_pts_")) { else if (action.startsWith("sub_pts_")) {
int pts = int.parse(action.split("_").last); int ptsToAnul = int.parse(action.split("_").last);
if (isOpponent) { opponentScore = (opponentScore - pts < 0) ? 0 : opponentScore - pts; }
else { myScore = (myScore - pts < 0) ? 0 : myScore - pts; } int lastShotIndex = matchShots.lastIndexWhere((s) =>
stats["pts"] = (stats["pts"]! - pts < 0) ? 0 : stats["pts"]! - pts; s.playerId == playerId &&
if (pts == 2 || pts == 3) { s.isMake == true &&
s.points == ptsToAnul
);
if (lastShotIndex != -1) {
matchShots.removeAt(lastShotIndex);
if (isOpponent) opponentScore -= ptsToAnul; else myScore -= ptsToAnul;
stats["pts"] = stats["pts"]! - ptsToAnul;
if (ptsToAnul == 2) {
if(stats["fgm"]! > 0) stats["fgm"] = stats["fgm"]! - 1; if(stats["fgm"]! > 0) stats["fgm"] = stats["fgm"]! - 1;
if(stats["fga"]! > 0) stats["fga"] = stats["fga"]! - 1; if(stats["fga"]! > 0) stats["fga"] = stats["fga"]! - 1;
} if(stats["p2m"]! > 0) stats["p2m"] = stats["p2m"]! - 1;
if (pts == 1) { if(stats["p2a"]! > 0) stats["p2a"] = stats["p2a"]! - 1;
} else if (ptsToAnul == 3) {
if(stats["fgm"]! > 0) stats["fgm"] = stats["fgm"]! - 1;
if(stats["fga"]! > 0) stats["fga"] = stats["fga"]! - 1;
if(stats["p3m"]! > 0) stats["p3m"] = stats["p3m"]! - 1;
if(stats["p3a"]! > 0) stats["p3a"] = stats["p3a"]! - 1;
} else if (ptsToAnul == 1) {
if(stats["ftm"]! > 0) stats["ftm"] = stats["ftm"]! - 1; if(stats["ftm"]! > 0) stats["ftm"] = stats["ftm"]! - 1;
if(stats["fta"]! > 0) stats["fta"] = stats["fta"]! - 1; if(stats["fta"]! > 0) stats["fta"] = stats["fta"]! - 1;
} }
logText = "anulou cesto de $ptsToAnul pts ⏪";
} else {
return;
} }
else if (action == "miss_1") { stats["fta"] = stats["fta"]! + 1; }
else if (action == "miss_2" || action == "miss_3") { stats["fga"] = stats["fga"]! + 1; }
else if (action == "add_orb") { stats["orb"] = stats["orb"]! + 1; stats["rbs"] = stats["rbs"]! + 1; }
else if (action == "add_drb") { stats["drb"] = stats["drb"]! + 1; stats["rbs"] = stats["rbs"]! + 1; }
else if (action == "add_ast") { stats["ast"] = stats["ast"]! + 1; }
else if (action == "add_stl") { stats["stl"] = stats["stl"]! + 1; }
else if (action == "add_tov") { stats["tov"] = stats["tov"]! + 1; }
else if (action == "add_blk") { stats["blk"] = stats["blk"]! + 1; }
else if (action == "add_foul") {
stats["fls"] = stats["fls"]! + 1;
if (isOpponent) { opponentFouls++; } else { myFouls++; }
} }
else if (action == "miss_1") { stats["fta"] = stats["fta"]! + 1; logText = "falhou lance livre ❌"; }
else if (action == "miss_2") { stats["fga"] = stats["fga"]! + 1; stats["p2a"] = stats["p2a"]! + 1; logText = "falhou lançamento de 2 ❌"; }
else if (action == "miss_3") { stats["fga"] = stats["fga"]! + 1; stats["p3a"] = stats["p3a"]! + 1; logText = "falhou lançamento de 3 ❌"; }
else if (action == "add_orb") { stats["orb"] = stats["orb"]! + 1; stats["rbs"] = stats["rbs"]! + 1; logText = "ganhou ressalto ofensivo 🔄"; }
else if (action == "add_drb") { stats["drb"] = stats["drb"]! + 1; stats["rbs"] = stats["rbs"]! + 1; logText = "ganhou ressalto defensivo 🛡️"; }
else if (action == "add_ast") { stats["ast"] = stats["ast"]! + 1; logText = "fez uma assistência 🤝"; }
else if (action == "add_stl") { stats["stl"] = stats["stl"]! + 1; logText = "roubou a bola 🥷"; }
else if (action == "add_blk") { stats["blk"] = stats["blk"]! + 1; logText = "fez um desarme (bloco) ✋"; }
else if (action == "add_so") { stats["so"] = stats["so"]! + 1; logText = "sofreu uma falta 🤕"; }
else if (action == "add_il") { stats["il"] = stats["il"]! + 1; logText = "intercetou um lançamento 🛑"; }
else if (action == "add_li") { stats["li"] = stats["li"]! + 1; logText = "teve o lançamento intercetado 🚫"; }
else if (action == "add_tov") { stats["tov"] = stats["tov"]! + 1; logText = "fez um passe ruim 🤦"; }
else if (action == "add_pa") { stats["pa"] = stats["pa"]! + 1; stats["tov"] = stats["tov"]! + 1; logText = "cometeu passos 🚶"; }
else if (action == "add_3s") { stats["tres_s"] = stats["tres_s"]! + 1; stats["tov"] = stats["tov"]! + 1; logText = "violação de 3 segundos ⏱️"; }
else if (action == "add_24s") { stats["tov"] = stats["tov"]! + 1; logText = "violação de 24 segundos ⏱️"; }
else if (action == "add_dr") { stats["dr"] = stats["dr"]! + 1; stats["tov"] = stats["tov"]! + 1; logText = "fez drible duplo 🏀"; }
else if (action == "sub_foul") { else if (action == "sub_foul") {
if (stats["fls"]! > 0) stats["fls"] = stats["fls"]! - 1; if (stats["fls"]! > 0) stats["fls"] = stats["fls"]! - 1;
if (isOpponent) { if (opponentFouls > 0) opponentFouls--; } else { if (myFouls > 0) myFouls--; } if (isOpponent) { if (opponentFouls > 0) opponentFouls--; } else { if (myFouls > 0) myFouls--; }
logText = "teve falta anulada 🔄";
} }
if (logText.isNotEmpty) {
String time = "${durationNotifier.value.inMinutes.toString().padLeft(2, '0')}:${durationNotifier.value.inSeconds.remainder(60).toString().padLeft(2, '0')}";
playByPlay.insert(0, "P$currentQuarter - $time: $name $logText");
}
_saveLocalBackup();
notifyListeners();
} }
Future<void> saveGameStats(BuildContext context) async { Future<void> saveGameStats(BuildContext context) async {
final supabase = Supabase.instance.client; final supabase = Supabase.instance.client;
isSaving = true; isSaving = true;
onUpdate(); notifyListeners();
try { try {
bool isGameFinishedNow = currentQuarter >= 4 && duration.inSeconds == 0; bool isGameFinishedNow = currentQuarter >= 4 && durationNotifier.value.inSeconds == 0;
String newStatus = isGameFinishedNow ? 'Terminado' : 'Pausado'; String newStatus = isGameFinishedNow ? 'Terminado' : 'Pausado';
String topPtsName = '---'; int maxPts = -1; String topPtsName = '---'; int maxPts = -1;
String topAstName = '---'; int maxAst = -1; String topAstName = '---'; int maxAst = -1;
String topRbsName = '---'; int maxRbs = -1; String topRbsName = '---'; int maxRbs = -1;
String topDefName = '---'; int maxDef = -1; String mvpName = '---'; double maxMvpScore = -999.0;
String mvpName = '---'; int maxMvpScore = -1;
playerStats.forEach((playerName, stats) { playerStats.forEach((playerId, stats) {
int pts = stats['pts'] ?? 0; int pts = stats['pts'] ?? 0;
int ast = stats['ast'] ?? 0; int ast = stats['ast'] ?? 0;
int rbs = stats['rbs'] ?? 0; int rbs = stats['rbs'] ?? 0;
int stl = stats['stl'] ?? 0;
int blk = stats['blk'] ?? 0;
int defScore = stl + blk; double minJogados = (stats['sec'] ?? 0) / 60.0;
int mvpScore = pts + ast + rbs + defScore; if (minJogados <= 0) minJogados = 40.0;
if (pts > maxPts && pts > 0) { maxPts = pts; topPtsName = '$playerName ($pts)'; } int tr = rbs;
if (ast > maxAst && ast > 0) { maxAst = ast; topAstName = '$playerName ($ast)'; } int br = stats['stl'] ?? 0;
if (rbs > maxRbs && rbs > 0) { maxRbs = rbs; topRbsName = '$playerName ($rbs)'; } int bp = stats['tov'] ?? 0;
if (defScore > maxDef && defScore > 0) { maxDef = defScore; topDefName = '$playerName ($defScore)'; } int lFalhados = (stats['fga'] ?? 0) - (stats['fgm'] ?? 0);
if (mvpScore > maxMvpScore && mvpScore > 0) { maxMvpScore = mvpScore; mvpName = playerName; } int llFalhados = (stats['fta'] ?? 0) - (stats['ftm'] ?? 0);
double mvpScore = ((pts * 0.30) + (tr * 0.20) + (ast * 0.35) + (br * 0.15)) -
((bp * 0.35) + (lFalhados * 0.30) + (llFalhados * 0.35));
mvpScore = mvpScore * (minJogados / 40.0);
String pName = playerNames[playerId] ?? '---';
if (pts > maxPts && pts > 0) { maxPts = pts; topPtsName = '$pName ($pts)'; }
if (ast > maxAst && ast > 0) { maxAst = ast; topAstName = '$pName ($ast)'; }
if (rbs > maxRbs && rbs > 0) { maxRbs = rbs; topRbsName = '$pName ($rbs)'; }
if (mvpScore > maxMvpScore) { maxMvpScore = mvpScore; mvpName = '$pName (${mvpScore.toStringAsFixed(1)})'; }
}); });
await supabase.from('games').update({ await supabase.from('games').update({
'my_score': myScore, 'my_score': myScore, 'opponent_score': opponentScore,
'opponent_score': opponentScore, 'remaining_seconds': durationNotifier.value.inSeconds,
'remaining_seconds': duration.inSeconds, 'my_timeouts': myTimeoutsUsed, 'opp_timeouts': opponentTimeoutsUsed,
'my_timeouts': myTimeoutsUsed, 'current_quarter': currentQuarter, 'status': newStatus,
'opp_timeouts': opponentTimeoutsUsed, 'top_pts_name': topPtsName, 'top_ast_name': topAstName,
'current_quarter': currentQuarter, 'top_rbs_name': topRbsName, 'mvp_name': mvpName,
'status': newStatus, 'play_by_play': playByPlay,
'top_pts_name': topPtsName,
'top_ast_name': topAstName,
'top_rbs_name': topRbsName,
'top_def_name': topDefName,
'mvp_name': mvpName,
}).eq('id', gameId); }).eq('id', gameId);
if (isGameFinishedNow && !gameWasAlreadyFinished && myTeamDbId != null && oppTeamDbId != null) {
final teamsData = await supabase.from('teams').select('id, wins, losses, draws').inFilter('id', [myTeamDbId, oppTeamDbId]);
Map<String, dynamic> myTeamUpdate = {};
Map<String, dynamic> oppTeamUpdate = {};
for(var t in teamsData) {
if(t['id'].toString() == myTeamDbId) myTeamUpdate = Map.from(t);
if(t['id'].toString() == oppTeamDbId) oppTeamUpdate = Map.from(t);
}
if (myScore > opponentScore) {
myTeamUpdate['wins'] = (myTeamUpdate['wins'] ?? 0) + 1;
oppTeamUpdate['losses'] = (oppTeamUpdate['losses'] ?? 0) + 1;
} else if (myScore < opponentScore) {
myTeamUpdate['losses'] = (myTeamUpdate['losses'] ?? 0) + 1;
oppTeamUpdate['wins'] = (oppTeamUpdate['wins'] ?? 0) + 1;
} else {
myTeamUpdate['draws'] = (myTeamUpdate['draws'] ?? 0) + 1;
oppTeamUpdate['draws'] = (oppTeamUpdate['draws'] ?? 0) + 1;
}
await supabase.from('teams').update({
'wins': myTeamUpdate['wins'], 'losses': myTeamUpdate['losses'], 'draws': myTeamUpdate['draws']
}).eq('id', myTeamDbId!);
await supabase.from('teams').update({
'wins': oppTeamUpdate['wins'], 'losses': oppTeamUpdate['losses'], 'draws': oppTeamUpdate['draws']
}).eq('id', oppTeamDbId!);
gameWasAlreadyFinished = true;
}
List<Map<String, dynamic>> batchStats = []; List<Map<String, dynamic>> batchStats = [];
playerStats.forEach((playerName, stats) { playerStats.forEach((playerId, stats) {
String? memberDbId = playerDbIds[playerName]; if (!playerId.startsWith("fake_")) {
if (memberDbId != null && stats.values.any((val) => val > 0)) { bool isMyTeamPlayer = myCourt.contains(playerId) || myBench.contains(playerId);
bool isMyTeamPlayer = myCourt.contains(playerName) || myBench.contains(playerName);
batchStats.add({ batchStats.add({
'game_id': gameId, 'member_id': memberDbId, 'team_id': isMyTeamPlayer ? myTeamDbId! : oppTeamDbId!, 'game_id': gameId, 'member_id': playerId, 'team_id': isMyTeamPlayer ? myTeamDbId! : oppTeamDbId!,
'pts': stats['pts'], 'rbs': stats['rbs'], 'ast': stats['ast'], 'stl': stats['stl'], 'blk': stats['blk'], 'tov': stats['tov'], 'fls': stats['fls'], 'fgm': stats['fgm'], 'fga': stats['fga'], 'ftm': stats['ftm'], 'fta': stats['fta'], 'orb': stats['orb'], 'drb': stats['drb'], 'pts': stats['pts'], 'rbs': stats['rbs'], 'ast': stats['ast'], 'stl': stats['stl'], 'blk': stats['blk'],
'tov': stats['tov'], 'fls': stats['fls'], 'fgm': stats['fgm'], 'fga': stats['fga'], 'ftm': stats['ftm'],
'fta': stats['fta'], 'orb': stats['orb'], 'drb': stats['drb'], 'p2m': stats['p2m'], 'p2a': stats['p2a'],
'p3m': stats['p3m'], 'p3a': stats['p3a'],
'so': stats['so'], 'il': stats['il'], 'li': stats['li'], 'pa': stats['pa'], 'tres_seg': stats['tres_s'],
'dr': stats['dr'], 'minutos_jogados': stats['min'],
}); });
} }
}); });
await supabase.from('player_stats').delete().eq('game_id', gameId); await supabase.from('player_stats').delete().eq('game_id', gameId);
if (batchStats.isNotEmpty) { if (batchStats.isNotEmpty) await supabase.from('player_stats').insert(batchStats);
await supabase.from('player_stats').insert(batchStats);
}
// 👇 6. GUARDA AS BOLINHAS (MAPA DE CALOR) NO SUPABASE 👇 final prefs = await SharedPreferences.getInstance();
List<Map<String, dynamic>> batchShots = []; await prefs.remove('backup_$gameId');
for (var shot in matchShots) {
String? memberDbId = playerDbIds[shot.playerName];
if (memberDbId != null) {
batchShots.add({
'game_id': gameId,
'member_id': memberDbId,
'player_name': shot.playerName,
'relative_x': shot.relativeX,
'relative_y': shot.relativeY,
'is_make': shot.isMake,
'zone': shot.zone ?? 'Desconhecida',
'points': shot.points ?? (shot.isMake ? 2 : 0),
});
}
}
// Apaga os antigos (para não duplicar) e guarda os novos! if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Guardado com Sucesso!'), backgroundColor: Colors.green));
await supabase.from('shot_locations').delete().eq('game_id', gameId);
if (batchShots.isNotEmpty) {
await supabase.from('shot_locations').insert(batchShots);
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Estatísticas, Mapa de Calor e Resultados guardados com Sucesso!'), backgroundColor: Colors.green));
}
} catch (e) { } catch (e) {
debugPrint("Erro ao gravar estatísticas: $e"); debugPrint("Erro ao gravar estatísticas: $e");
if (context.mounted) { if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Erro ao guardar: $e'), backgroundColor: Colors.red));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Erro ao guardar: $e'), backgroundColor: Colors.red));
}
} finally { } finally {
isSaving = false; isSaving = false;
onUpdate(); notifyListeners();
} }
} }
@override
void dispose() { void dispose() {
timer?.cancel(); timer?.cancel();
super.dispose();
} }
} }

View File

@@ -1,158 +0,0 @@
/*import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/person_model.dart';
class StatsController {
final SupabaseClient _supabase = Supabase.instance.client;
// 1. LER
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());
}
// 2. APAGAR
Future<void> deletePerson(String personId) async {
try {
await _supabase.from('members').delete().eq('id', personId);
} catch (e) {
debugPrint("Erro ao eliminar: $e");
}
}
// 3. DIÁLOGOS
void showAddPersonDialog(BuildContext context, String teamId) {
_showForm(context, teamId: teamId);
}
void showEditPersonDialog(BuildContext context, String teamId, Person person) {
_showForm(context, teamId: teamId, person: person);
}
// --- O POPUP ESTÁ AQUI ---
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 ?? '');
// Define o valor inicial
String selectedType = person?.type ?? 'Jogador';
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setState) => AlertDialog(
title: Text(isEdit ? "Editar" : "Adicionar"),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
// NOME
TextField(
controller: nameCtrl,
decoration: const InputDecoration(labelText: "Nome"),
textCapitalization: TextCapitalization.sentences,
),
const SizedBox(height: 10),
// FUNÇÃO
DropdownButtonFormField<String>(
value: selectedType,
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);
},
),
// NÚMERO (Só aparece se for Jogador)
if (selectedType == "Jogador") ...[
const SizedBox(height: 10),
TextField(
controller: numCtrl,
decoration: const InputDecoration(labelText: "Número da Camisola"),
keyboardType: TextInputType.text, // Aceita texto para evitar erros
),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text("Cancelar")
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF00C853)),
onPressed: () async {
print("--- 1. CLICOU EM GUARDAR ---");
// Validação Simples
if (nameCtrl.text.trim().isEmpty) {
print("ERRO: Nome vazio");
return;
}
// Lógica do Número:
// Se for Treinador -> envia NULL
// Se for Jogador e estiver vazio -> envia NULL
// Se tiver texto -> envia o Texto
String? numeroFinal;
if (selectedType == "Treinador") {
numeroFinal = null;
} else {
numeroFinal = numCtrl.text.trim().isEmpty ? null : numCtrl.text.trim();
}
print("--- 2. DADOS A ENVIAR ---");
print("Nome: ${nameCtrl.text}");
print("Tipo: $selectedType");
print("Número: $numeroFinal");
try {
if (isEdit) {
await _supabase.from('members').update({
'name': nameCtrl.text.trim(),
'type': selectedType,
'number': numeroFinal,
}).eq('id', person!.id);
} else {
await _supabase.from('members').insert({
'team_id': teamId, // Verifica se este teamId é válido!
'name': nameCtrl.text.trim(),
'type': selectedType,
'number': numeroFinal,
});
}
print("--- 3. SUCESSO! FECHANDO DIÁLOGO ---");
if (ctx.mounted) Navigator.pop(ctx);
} catch (e) {
print("--- X. ERRO AO GUARDAR ---");
print(e.toString());
// MOSTRA O ERRO NO TELEMÓVEL
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text("Erro: $e"),
backgroundColor: Colors.red,
duration: const Duration(seconds: 4),
),
);
}
}
},
child: const Text("Guardar", style: TextStyle(color: Colors.white)),
)
],
),
),
);
}
}*/

View File

@@ -1,36 +1,61 @@
import 'dart:io';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
class TeamController { class TeamController {
// Instância do cliente Supabase
final _supabase = Supabase.instance.client; final _supabase = Supabase.instance.client;
// 1. STREAM (Realtime) // 1. STREAM (Realtime)
Stream<List<Map<String, dynamic>>> get teamsStream { Stream<List<Map<String, dynamic>>> get teamsStream {
final userId = _supabase.auth.currentUser?.id;
if (userId == null) return const Stream.empty();
return _supabase return _supabase
.from('teams') .from('teams')
.stream(primaryKey: ['id']) .stream(primaryKey: ['id'])
.order('name', ascending: true) .eq('user_id', userId); // ✅ Bem feito, este já estava certo!
.map((data) => List<Map<String, dynamic>>.from(data));
} }
// 2. CRIAR // 2. CRIAR (Agora guarda o dono da equipa!)
Future<void> createTeam(String name, String season, String? imageUrl) async { Future<void> createTeam(String name, String season, File? imageFile) async {
try { try {
final userId = _supabase.auth.currentUser?.id;
if (userId == null) throw Exception("Utilizador não autenticado.");
String? uploadedImageUrl;
// Se o utilizador escolheu uma imagem, fazemos o upload primeiro
if (imageFile != null) {
final fileName = '${userId}_${DateTime.now().millisecondsSinceEpoch}.png';
final storagePath = 'teams/$fileName';
await _supabase.storage.from('avatars').upload(
storagePath,
imageFile,
fileOptions: const FileOptions(cacheControl: '3600', upsert: true)
);
uploadedImageUrl = _supabase.storage.from('avatars').getPublicUrl(storagePath);
}
// Agora insere a equipa na base de dados com o ID DO DONO!
await _supabase.from('teams').insert({ await _supabase.from('teams').insert({
'user_id': userId, // 👈 CRUCIAL: Diz à base de dados de quem é esta equipa!
'name': name, 'name': name,
'season': season, 'season': season,
'image_url': imageUrl, 'image_url': uploadedImageUrl ?? '',
'is_favorite': false, 'is_favorite': false,
}); });
print("✅ Equipa guardada no Supabase!"); print("✅ Equipa guardada no Supabase com dono associado!");
} catch (e) { } catch (e) {
print("❌ Erro ao criar: $e"); print("❌ Erro ao criar equipa: $e");
} }
} }
// 3. ELIMINAR // 3. ELIMINAR
Future<void> deleteTeam(String id) async { Future<void> deleteTeam(String id) async {
try { try {
// Como segurança extra, podemos garantir que só apaga se for o dono (opcional se tiveres RLS no Supabase)
await _supabase.from('teams').delete().eq('id', id); await _supabase.from('teams').delete().eq('id', id);
} catch (e) { } catch (e) {
print("❌ Erro ao eliminar: $e"); print("❌ Erro ao eliminar: $e");
@@ -42,7 +67,7 @@ class TeamController {
try { try {
await _supabase await _supabase
.from('teams') .from('teams')
.update({'is_favorite': !currentStatus}) // Inverte o valor .update({'is_favorite': !currentStatus})
.eq('id', teamId); .eq('id', teamId);
} catch (e) { } catch (e) {
print("❌ Erro ao favoritar: $e"); print("❌ Erro ao favoritar: $e");
@@ -52,28 +77,26 @@ class TeamController {
// 5. CONTAR JOGADORES (LEITURA ÚNICA) // 5. CONTAR JOGADORES (LEITURA ÚNICA)
Future<int> getPlayerCount(String teamId) async { Future<int> getPlayerCount(String teamId) async {
try { try {
final count = await _supabase final count = await _supabase.from('members').count().eq('team_id', teamId);
.from('members')
.count()
.eq('team_id', teamId);
return count; return count;
} catch (e) { } catch (e) {
print("Erro ao contar jogadores: $e");
return 0; return 0;
} }
} }
// 👇 6. A FUNÇÃO QUE RESOLVE O ERRO (EM TEMPO REAL) 👇 // 6. VIEW DAS EQUIPAS (AQUI ESTAVA O TEU ERRO DE LISTAGEM!)
Stream<int> getPlayerCountStream(String teamId) { Future<List<Map<String, dynamic>>> getTeamsWithStats() async {
return _supabase final userId = _supabase.auth.currentUser?.id;
.from('members') if (userId == null) return []; // Retorna lista vazia se não houver login
.stream(primaryKey: ['id'])
.eq('team_id', teamId) final data = await _supabase
.map((membros) => membros .from('teams_with_stats')
.where((membro) => membro['type'] == 'Jogador') .select('*')
.length); .eq('user_id', userId) // 👈 CRUCIAL: Só puxa as estatísticas das tuas equipas!
.order('name', ascending: true);
return List<Map<String, dynamic>>.from(data);
} }
// Mantemos o dispose vazio para não quebrar a chamada na TeamsPage
void dispose() {} void dispose() {}
} }

View File

@@ -1,21 +1,27 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; // Para as orientações
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:playmaker/classe/theme.dart'; // 👇 IMPORTA O TEU TEMA import 'package:playmaker/classe/theme.dart';
import 'pages/login.dart'; import 'pages/login.dart';
// ======================================================== // Variável global para controlar o Tema
// 👇 A VARIÁVEL MÁGICA QUE FALTAVA (Fora do void main) 👇
// ========================================================
final ValueNotifier<ThemeMode> themeNotifier = ValueNotifier(ThemeMode.system); final ValueNotifier<ThemeMode> themeNotifier = ValueNotifier(ThemeMode.system);
void main() async { void main() async {
// 1. Inicializa os bindings do Flutter
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// 2. Inicializa o Supabase
await Supabase.initialize( await Supabase.initialize(
url: 'https://sihwjdshexjyvsbettcd.supabase.co', url: 'https://sihwjdshexjyvsbettcd.supabase.co',
anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InNpaHdqZHNoZXhqeXZzYmV0dGNkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njg5MTQxMjgsImV4cCI6MjA4NDQ5MDEyOH0.gW3AvTJVNyE1Dqa72OTnhrUIKsndexrY3pKxMIAaAy8', anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InNpaHdqZHNoZXhqeXZzYmV0dGNkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njg5MTQxMjgsImV4cCI6MjA4NDQ5MDEyOH0.gW3AvTJVNyE1Dqa72OTnhrUIKsndexrY3pKxMIAaAy8',
); );
// 3. Deixa a orientação livre (Portrait) para o arranque da App
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);
runApp(const MyApp()); runApp(const MyApp());
} }
@@ -24,7 +30,6 @@ class MyApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// FICA À ESCUTA DO THEMENOTIFIER
return ValueListenableBuilder<ThemeMode>( return ValueListenableBuilder<ThemeMode>(
valueListenable: themeNotifier, valueListenable: themeNotifier,
builder: (_, ThemeMode currentMode, __) { builder: (_, ThemeMode currentMode, __) {
@@ -33,7 +38,7 @@ class MyApp extends StatelessWidget {
title: 'PlayMaker', title: 'PlayMaker',
theme: AppTheme.lightTheme, theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme, darkTheme: AppTheme.darkTheme,
themeMode: currentMode, // 👇 ISTO RECEBE O VALOR DO NOTIFIER themeMode: currentMode,
home: const LoginPage(), home: const LoginPage(),
); );
}, },

View File

@@ -1,38 +1,71 @@
class Game { class Game {
final String id; final String id;
final String userId;
final String myTeam; final String myTeam;
final String opponentTeam; final String opponentTeam;
final String? myTeamLogo; // URL da imagem
final String? opponentTeamLogo; // URL da imagem
final String myScore; final String myScore;
final String opponentScore; final String opponentScore;
final String status;
final String season; final String season;
final String status;
final DateTime gameDate;
// Novos campos que estão na tua base de dados
final int remainingSeconds;
final int myTimeouts;
final int oppTimeouts;
final int currentQuarter;
final String topPtsName;
final String topAstName;
final String topRbsName;
final String topDefName;
final String mvpName;
Game({ Game({
required this.id, required this.id,
required this.userId,
required this.myTeam, required this.myTeam,
required this.opponentTeam, required this.opponentTeam,
this.myTeamLogo,
this.opponentTeamLogo,
required this.myScore, required this.myScore,
required this.opponentScore, required this.opponentScore,
required this.status,
required this.season, required this.season,
required this.status,
required this.gameDate,
required this.remainingSeconds,
required this.myTimeouts,
required this.oppTimeouts,
required this.currentQuarter,
required this.topPtsName,
required this.topAstName,
required this.topRbsName,
required this.topDefName,
required this.mvpName,
}); });
// No seu factory, certifique-se de mapear os campos da tabela (ou de um JOIN) // 👇 A MÁGICA ACONTECE AQUI: Lemos os dados e protegemos os NULLs
factory Game.fromMap(Map<String, dynamic> map) { factory Game.fromMap(Map<String, dynamic> json) {
return Game( return Game(
id: map['id'], id: json['id']?.toString() ?? '',
myTeam: map['my_team_name'], userId: json['user_id']?.toString() ?? '',
opponentTeam: map['opponent_team_name'], myTeam: json['my_team']?.toString() ?? 'Minha Equipa',
myTeamLogo: map['my_team_logo'], // Certifique-se que o Supabase retorna isto opponentTeam: json['opponent_team']?.toString() ?? 'Adversário',
opponentTeamLogo: map['opponent_team_logo'], myScore: (json['my_score'] ?? 0).toString(), // Protege NULL e converte Int4 para String
myScore: map['my_score'].toString(), opponentScore: (json['opponent_score'] ?? 0).toString(),
opponentScore: map['opponent_score'].toString(), season: json['season']?.toString() ?? '---',
status: map['status'], status: json['status']?.toString() ?? 'Decorrer',
season: map['season'], gameDate: json['game_date'] != null ? DateTime.tryParse(json['game_date']) ?? DateTime.now() : DateTime.now(),
// Proteção para os Inteiros (se for NULL, assume 0)
remainingSeconds: json['remaining_seconds'] as int? ?? 600, // 600s = 10 minutos
myTimeouts: json['my_timeouts'] as int? ?? 0,
oppTimeouts: json['opp_timeouts'] as int? ?? 0,
currentQuarter: json['current_quarter'] as int? ?? 1,
// Proteção para os Nomes (se for NULL, assume '---')
topPtsName: json['top_pts_name']?.toString() ?? '---',
topAstName: json['top_ast_name']?.toString() ?? '---',
topRbsName: json['top_rbs_name']?.toString() ?? '---',
topDefName: json['top_def_name']?.toString() ?? '---',
mvpName: json['mvp_name']?.toString() ?? '---',
); );
} }
} }

View File

@@ -3,24 +3,43 @@ class Person {
final String teamId; final String teamId;
final String name; final String name;
final String type; // 'Jogador' ou 'Treinador' final String type; // 'Jogador' ou 'Treinador'
final String number; final String? number; // O número é opcional (Treinadores não têm)
// 👇 A NOVA PROPRIEDADE AQUI!
final String? imageUrl;
Person({ Person({
required this.id, required this.id,
required this.teamId, required this.teamId,
required this.name, required this.name,
required this.type, required this.type,
required this.number, this.number,
this.imageUrl, // 👇 ADICIONADO AO CONSTRUTOR
}); });
// Converte o JSON do Supabase para o objeto Person // Lê os dados do Supabase e converte para a classe Person
factory Person.fromMap(Map<String, dynamic> map) { factory Person.fromMap(Map<String, dynamic> map) {
return Person( return Person(
id: map['id'] ?? '', id: map['id']?.toString() ?? '',
teamId: map['team_id'] ?? '', teamId: map['team_id']?.toString() ?? '',
name: map['name'] ?? '', name: map['name']?.toString() ?? 'Desconhecido',
type: map['type'] ?? 'Jogador', type: map['type']?.toString() ?? 'Jogador',
number: map['number']?.toString() ?? '', number: map['number']?.toString(),
// 👇 AGORA ELE JÁ SABE LER O LINK DA IMAGEM DA TUA BASE DE DADOS!
imageUrl: map['image_url']?.toString(),
); );
} }
// Prepara os dados para enviar para o Supabase (se necessário)
Map<String, dynamic> toMap() {
return {
'id': id,
'team_id': teamId,
'name': name,
'type': type,
'number': number,
'image_url': imageUrl, // 👇 TAMBÉM GUARDA A IMAGEM
};
}
} }

View File

@@ -4,26 +4,33 @@ class Team {
final String season; final String season;
final String imageUrl; final String imageUrl;
final bool isFavorite; final bool isFavorite;
final String createdAt;
final int playerCount; // 👇 NOVA VARIÁVEL AQUI
Team({ Team({
required this.id, required this.id,
required this.name, required this.name,
required this.season, required this.season,
required this.imageUrl, required this.imageUrl,
this.isFavorite = false required this.isFavorite,
required this.createdAt,
this.playerCount = 0, // 👇 VALOR POR DEFEITO
}); });
// Mapeia o JSON que vem do Supabase (id costuma ser UUID ou String)
factory Team.fromMap(Map<String, dynamic> map) { factory Team.fromMap(Map<String, dynamic> map) {
return Team( return Team(
id: map['id']?.toString() ?? '', id: map['id']?.toString() ?? '',
name: map['name'] ?? '', name: map['name']?.toString() ?? 'Sem Nome',
season: map['season'] ?? '', season: map['season']?.toString() ?? '',
imageUrl: map['image_url'] ?? '', imageUrl: map['image_url']?.toString() ?? '',
isFavorite: map['is_favorite'] ?? false, isFavorite: map['is_favorite'] ?? false,
createdAt: map['created_at']?.toString() ?? '',
// 👇 AGORA ELE LÊ A CONTAGEM DA TUA NOVA VIEW!
playerCount: map['player_count'] != null ? int.tryParse(map['player_count'].toString()) ?? 0 : 0,
); );
} }
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'name': name, 'name': name,

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,28 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:playmaker/pages/PlacarPage.dart'; import 'package:playmaker/pages/PlacarPage.dart';
import 'package:playmaker/classe/theme.dart'; import 'package:playmaker/classe/theme.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../controllers/team_controller.dart'; import '../controllers/team_controller.dart';
import '../controllers/game_controller.dart'; import '../controllers/game_controller.dart';
import '../models/game_model.dart'; import '../models/game_model.dart';
import '../utils/size_extension.dart'; import '../utils/size_extension.dart';
import 'pdf_export_service.dart';
// --- CARD DE EXIBIÇÃO DO JOGO ---
class GameResultCard extends StatelessWidget { class GameResultCard extends StatelessWidget {
final String gameId, myTeam, opponentTeam, myScore, opponentScore, status, season; final String gameId, myTeam, opponentTeam, myScore, opponentScore, status, season;
final String? myTeamLogo, opponentTeamLogo; final String? myTeamLogo, opponentTeamLogo;
final double sf; final double sf;
final VoidCallback onDelete;
const GameResultCard({ const GameResultCard({
super.key, required this.gameId, required this.myTeam, required this.opponentTeam, super.key, required this.gameId, required this.myTeam, required this.opponentTeam,
required this.myScore, required this.opponentScore, required this.status, required this.season, required this.myScore, required this.opponentScore, required this.status, required this.season,
this.myTeamLogo, this.opponentTeamLogo, required this.sf, this.myTeamLogo, this.opponentTeamLogo, required this.sf, required this.onDelete,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bgColor = Theme.of(context).cardTheme.color; final bgColor = Theme.of(context).cardTheme.color ?? Theme.of(context).colorScheme.surface;
final textColor = Theme.of(context).colorScheme.onSurface; final textColor = Theme.of(context).colorScheme.onSurface;
return Container( return Container(
@@ -32,7 +34,9 @@ class GameResultCard extends StatelessWidget {
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10 * sf)], boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10 * sf)],
border: Border.all(color: Colors.grey.withOpacity(0.1)), border: Border.all(color: Colors.grey.withOpacity(0.1)),
), ),
child: Row( child: Stack(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Expanded(child: _buildTeamInfo(myTeam, AppTheme.primaryRed, myTeamLogo, sf, textColor)), Expanded(child: _buildTeamInfo(myTeam, AppTheme.primaryRed, myTeamLogo, sf, textColor)),
@@ -40,17 +44,87 @@ class GameResultCard extends StatelessWidget {
Expanded(child: _buildTeamInfo(opponentTeam, Colors.grey.shade600, opponentTeamLogo, sf, textColor)), Expanded(child: _buildTeamInfo(opponentTeam, Colors.grey.shade600, opponentTeamLogo, sf, textColor)),
], ],
), ),
Positioned(
top: -10 * sf,
right: -10 * sf,
child: Row(
children: [
IconButton(
icon: Icon(Icons.picture_as_pdf, color: AppTheme.primaryRed.withOpacity(0.8), size: 22 * sf),
splashRadius: 20 * sf,
tooltip: 'Gerar PDF',
onPressed: () async {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('A gerar PDF...'), duration: Duration(seconds: 1)));
await PdfExportService.generateAndPrintBoxScore(
gameId: gameId,
myTeam: myTeam,
opponentTeam: opponentTeam,
myScore: myScore,
opponentScore: opponentScore,
season: season,
);
},
),
IconButton(
icon: Icon(Icons.delete_outline, color: Colors.grey.shade400, size: 22 * sf),
splashRadius: 20 * sf,
tooltip: 'Eliminar Jogo',
onPressed: () => _showDeleteConfirmation(context),
),
],
),
),
],
),
);
}
void _showDeleteConfirmation(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: Theme.of(context).colorScheme.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * sf)),
title: Text('Eliminar Jogo', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16 * sf, color: Theme.of(context).colorScheme.onSurface)),
content: Text('Tem a certeza que deseja eliminar este jogo? Esta ação apagará todas as estatísticas associadas e não pode ser desfeita.', style: TextStyle(fontSize: 14 * sf, color: Theme.of(context).colorScheme.onSurface)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text('CANCELAR', style: TextStyle(color: Colors.grey, fontSize: 14 * sf))
),
TextButton(
onPressed: () {
Navigator.pop(ctx);
onDelete();
},
child: Text('ELIMINAR', style: TextStyle(color: AppTheme.primaryRed, fontWeight: FontWeight.bold, fontSize: 14 * sf))
),
],
)
); );
} }
Widget _buildTeamInfo(String name, Color color, String? logoUrl, double sf, Color textColor) { Widget _buildTeamInfo(String name, Color color, String? logoUrl, double sf, Color textColor) {
final double avatarSize = 48 * sf;
return Column( return Column(
children: [ children: [
CircleAvatar( ClipOval(
radius: 24 * sf, child: Container(
backgroundColor: color, width: avatarSize,
backgroundImage: (logoUrl != null && logoUrl.isNotEmpty) ? NetworkImage(logoUrl) : null, height: avatarSize,
child: (logoUrl == null || logoUrl.isEmpty) ? Icon(Icons.shield, color: Colors.white, size: 24 * sf) : null, color: color.withOpacity(0.1),
child: (logoUrl != null && logoUrl.isNotEmpty)
? CachedNetworkImage(
imageUrl: logoUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
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), SizedBox(height: 6 * sf),
Text(name, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13 * sf, color: textColor), textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, maxLines: 2), Text(name, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13 * sf, color: textColor), textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, maxLines: 2),
@@ -89,7 +163,6 @@ class GameResultCard extends StatelessWidget {
); );
} }
// --- POPUP DE CRIAÇÃO ---
class CreateGameDialogManual extends StatefulWidget { class CreateGameDialogManual extends StatefulWidget {
final TeamController teamController; final TeamController teamController;
final GameController gameController; final GameController gameController;
@@ -188,7 +261,22 @@ class _CreateGameDialogManualState extends State<CreateGameDialogManual> {
final String name = option['name'].toString(); final String name = option['name'].toString();
final String? imageUrl = option['image_url']; final String? imageUrl = option['image_url'];
return ListTile( 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)), title: Text(name, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14 * widget.sf, color: Theme.of(context).colorScheme.onSurface)),
onTap: () { onSelected(option); }, onTap: () { onSelected(option); },
); );
@@ -213,7 +301,6 @@ class _CreateGameDialogManualState extends State<CreateGameDialogManual> {
} }
} }
// --- PÁGINA PRINCIPAL DOS JOGOS ---
class GamePage extends StatefulWidget { class GamePage extends StatefulWidget {
const GamePage({super.key}); const GamePage({super.key});
@@ -270,9 +357,31 @@ class _GamePageState extends State<GamePage> {
if (team['name'] == game.opponentTeam) oppLogo = team['image_url']; if (team['name'] == game.opponentTeam) oppLogo = team['image_url'];
} }
return GameResultCard( return GameResultCard(
gameId: game.id, myTeam: game.myTeam, opponentTeam: game.opponentTeam, myScore: game.myScore, gameId: game.id,
opponentScore: game.opponentScore, status: game.status, season: game.season, myTeamLogo: myLogo, opponentTeamLogo: oppLogo, myTeam: game.myTeam,
opponentTeam: game.opponentTeam,
myScore: game.myScore,
opponentScore: game.opponentScore,
status: game.status,
season: game.season,
myTeamLogo: myLogo,
opponentTeamLogo: oppLogo,
sf: context.sf, sf: context.sf,
onDelete: () async {
bool success = await gameController.deleteGame(game.id);
if (context.mounted) {
if (success) {
setState(() {});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Jogo eliminado com sucesso!'), backgroundColor: Colors.green)
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Erro ao eliminar o jogo.'), backgroundColor: Colors.red)
);
}
}
},
); );
}, },
); );

View File

@@ -6,10 +6,10 @@ import 'package:playmaker/pages/teamPage.dart';
import 'package:playmaker/controllers/team_controller.dart'; import 'package:playmaker/controllers/team_controller.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:playmaker/pages/status_page.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 '../utils/size_extension.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
// 👇 Importa o ficheiro onde meteste o StatCard e o SportGrid
// import 'home_widgets.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@@ -30,6 +30,55 @@ class _HomeScreenState extends State<HomeScreen> {
final _supabase = Supabase.instance.client; final _supabase = Supabase.instance.client;
String? _avatarUrl;
bool _isMemoryLoaded = false; // A variável mágica que impede o "piscar" inicial
@override
void initState() {
super.initState();
_loadUserAvatar();
}
// FUNÇÃO OTIMIZADA: Carrega da memória instantaneamente e atualiza em background
Future<void> _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;
try {
final data = await _supabase
.from('profiles')
.select('avatar_url')
.eq('id', userId)
.maybeSingle();
if (mounted && data != null && data['avatar_url'] != null) {
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) {
debugPrint("Erro ao carregar avatar na Home: $e");
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final List<Widget> pages = [ final List<Widget> pages = [
@@ -42,17 +91,48 @@ class _HomeScreenState extends State<HomeScreen> {
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor, backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: AppBar( 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, backgroundColor: AppTheme.primaryRed,
foregroundColor: Colors.white, foregroundColor: Colors.white,
leading: IconButton( elevation: 0,
icon: Icon(Icons.person, size: 24 * context.sf),
onPressed: () { leading: Padding(
Navigator.push( padding: EdgeInsets.all(10.0 * context.sf),
child: InkWell(
borderRadius: BorderRadius.circular(100),
onTap: () async {
await Navigator.push(
context, context,
MaterialPageRoute(builder: (context) => const SettingsScreen()), MaterialPageRoute(builder: (context) => const SettingsScreen()),
); );
_loadUserAvatar();
}, },
// 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),
),
),
), ),
), ),
@@ -87,8 +167,13 @@ class _HomeScreenState extends State<HomeScreen> {
return StreamBuilder<List<Map<String, dynamic>>>( return StreamBuilder<List<Map<String, dynamic>>>(
stream: _teamController.teamsStream, stream: _teamController.teamsStream,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) return const SizedBox(height: 200, child: Center(child: CircularProgressIndicator())); // Correção: Verifica hasData para evitar piscar tela de loading
if (!snapshot.hasData || snapshot.data!.isEmpty) return SizedBox(height: 200 * context.sf, child: Center(child: Text("Nenhuma equipa criada.", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)))); if (!snapshot.hasData && snapshot.connectionState == ConnectionState.waiting) {
return const SizedBox(height: 200, child: Center(child: CircularProgressIndicator()));
}
if (!snapshot.hasData || snapshot.data!.isEmpty) {
return SizedBox(height: 200 * context.sf, child: Center(child: Text("Nenhuma equipa criada.", style: TextStyle(color: Theme.of(context).colorScheme.onSurface))));
}
final teams = snapshot.data!; final teams = snapshot.data!;
return ListView.builder( return ListView.builder(
@@ -97,14 +182,15 @@ class _HomeScreenState extends State<HomeScreen> {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final team = teams[index]; final team = teams[index];
return ListTile( 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'] ?? 'Sem Nome', style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold)),
onTap: () { onTap: () {
setState(() { setState(() {
_selectedTeamId = team['id']; _selectedTeamId = team['id'].toString();
_selectedTeamName = team['name']; _selectedTeamName = team['name'] ?? 'Desconhecido';
_teamWins = team['wins'] != null ? int.tryParse(team['wins'].toString()) ?? 0 : 0; _teamWins = int.tryParse(team['wins']?.toString() ?? '0') ?? 0;
_teamLosses = team['losses'] != null ? int.tryParse(team['losses'].toString()) ?? 0 : 0; _teamLosses = int.tryParse(team['losses']?.toString() ?? '0') ?? 0;
_teamDraws = team['draws'] != null ? int.tryParse(team['draws'].toString()) ?? 0 : 0; _teamDraws = int.tryParse(team['draws']?.toString() ?? '0') ?? 0;
}); });
Navigator.pop(context); Navigator.pop(context);
}, },
@@ -196,7 +282,6 @@ class _HomeScreenState extends State<HomeScreen> {
Text('Histórico de Jogos', style: TextStyle(fontSize: 20 * context.sf, fontWeight: FontWeight.bold, color: textColor)), Text('Histórico de Jogos', style: TextStyle(fontSize: 20 * context.sf, fontWeight: FontWeight.bold, color: textColor)),
SizedBox(height: 16 * context.sf), SizedBox(height: 16 * context.sf),
// 👇 AQUI ESTÁ O NOVO CARTÃO VAZIO PARA QUANDO NÃO HÁ EQUIPA 👇
_selectedTeamName == "Selecionar Equipa" _selectedTeamName == "Selecionar Equipa"
? Container( ? Container(
width: double.infinity, width: double.infinity,
@@ -205,9 +290,7 @@ class _HomeScreenState extends State<HomeScreen> {
color: Theme.of(context).cardTheme.color ?? Colors.white, color: Theme.of(context).cardTheme.color ?? Colors.white,
borderRadius: BorderRadius.circular(16 * context.sf), borderRadius: BorderRadius.circular(16 * context.sf),
border: Border.all(color: Colors.grey.withOpacity(0.1)), border: Border.all(color: Colors.grey.withOpacity(0.1)),
boxShadow: [ boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 10, offset: const Offset(0, 4))],
BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 10, offset: const Offset(0, 4)),
],
), ),
child: Column( child: Column(
children: [ children: [
@@ -217,10 +300,7 @@ class _HomeScreenState extends State<HomeScreen> {
child: Icon(Icons.shield_outlined, color: AppTheme.primaryRed, size: 42 * context.sf), child: Icon(Icons.shield_outlined, color: AppTheme.primaryRed, size: 42 * context.sf),
), ),
SizedBox(height: 20 * context.sf), SizedBox(height: 20 * context.sf),
Text( Text("Nenhuma Equipa Ativa", style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold, color: textColor)),
"Nenhuma Equipa Ativa",
style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface),
),
SizedBox(height: 8 * context.sf), SizedBox(height: 8 * context.sf),
Text( Text(
"Escolha uma equipa no seletor acima para ver as estatísticas e o histórico.", "Escolha uma equipa no seletor acima para ver as estatísticas e o histórico.",
@@ -247,11 +327,14 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
) )
: StreamBuilder<List<Map<String, dynamic>>>( : StreamBuilder<List<Map<String, dynamic>>>(
stream: _supabase.from('games').stream(primaryKey: ['id']) stream: _supabase.from('games').stream(primaryKey: ['id']).order('game_date', ascending: false),
.order('game_date', ascending: false),
builder: (context, gameSnapshot) { builder: (context, gameSnapshot) {
if (gameSnapshot.hasError) return Text("Erro: ${gameSnapshot.error}", style: const TextStyle(color: Colors.red)); if (gameSnapshot.hasError) return Text("Erro: ${gameSnapshot.error}", style: const TextStyle(color: Colors.red));
if (gameSnapshot.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator());
// Correção: Verifica hasData em vez de ConnectionState para manter a lista na tela enquanto atualiza em plano de fundo
if (!gameSnapshot.hasData && gameSnapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final todosOsJogos = gameSnapshot.data ?? []; final todosOsJogos = gameSnapshot.data ?? [];
final gamesList = todosOsJogos.where((game) { final gamesList = todosOsJogos.where((game) {
@@ -263,6 +346,7 @@ class _HomeScreenState extends State<HomeScreen> {
if (gamesList.isEmpty) { if (gamesList.isEmpty) {
return Container( return Container(
width: double.infinity,
padding: EdgeInsets.all(20 * context.sf), padding: EdgeInsets.all(20 * context.sf),
decoration: BoxDecoration(color: Theme.of(context).cardTheme.color, borderRadius: BorderRadius.circular(14)), decoration: BoxDecoration(color: Theme.of(context).cardTheme.color, borderRadius: BorderRadius.circular(14)),
alignment: Alignment.center, alignment: Alignment.center,
@@ -274,8 +358,8 @@ class _HomeScreenState extends State<HomeScreen> {
children: gamesList.map((game) { children: gamesList.map((game) {
String dbMyTeam = game['my_team']?.toString() ?? ''; String dbMyTeam = game['my_team']?.toString() ?? '';
String dbOppTeam = game['opponent_team']?.toString() ?? ''; String dbOppTeam = game['opponent_team']?.toString() ?? '';
int dbMyScore = int.tryParse(game['my_score'].toString()) ?? 0; int dbMyScore = int.tryParse(game['my_score']?.toString() ?? '0') ?? 0;
int dbOppScore = int.tryParse(game['opponent_score'].toString()) ?? 0; int dbOppScore = int.tryParse(game['opponent_score']?.toString() ?? '0') ?? 0;
String opponent; int myScore; int oppScore; String opponent; int myScore; int oppScore;
@@ -314,16 +398,33 @@ class _HomeScreenState extends State<HomeScreen> {
Map<String, dynamic> _calculateLeaders(List<Map<String, dynamic>> data) { Map<String, dynamic> _calculateLeaders(List<Map<String, dynamic>> data) {
Map<String, int> ptsMap = {}; Map<String, int> astMap = {}; Map<String, int> rbsMap = {}; Map<String, String> namesMap = {}; Map<String, int> ptsMap = {}; Map<String, int> astMap = {}; Map<String, int> rbsMap = {}; Map<String, String> namesMap = {};
for (var row in data) { for (var row in data) {
String pid = row['member_id'].toString(); String pid = row['member_id']?.toString() ?? "unknown";
namesMap[pid] = row['player_name']?.toString() ?? "Desconhecido"; namesMap[pid] = row['player_name']?.toString() ?? "Desconhecido";
ptsMap[pid] = (ptsMap[pid] ?? 0) + (row['pts'] as int? ?? 0); ptsMap[pid] = (ptsMap[pid] ?? 0) + (int.tryParse(row['pts']?.toString() ?? '0') ?? 0);
astMap[pid] = (astMap[pid] ?? 0) + (row['ast'] as int? ?? 0); astMap[pid] = (astMap[pid] ?? 0) + (int.tryParse(row['ast']?.toString() ?? '0') ?? 0);
rbsMap[pid] = (rbsMap[pid] ?? 0) + (row['rbs'] as int? ?? 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<String, int> map) { var bestId = map.entries.reduce((a, b) => a.value > b.value ? a : b).key; return namesMap[bestId]!; } if (ptsMap.isEmpty) {
int getBestVal(Map<String, int> map) => map.values.reduce((a, b) => a > b ? a : b); return {'pts_name': '---', 'pts_val': 0, 'ast_name': '---', 'ast_val': 0, 'rbs_name': '---', 'rbs_val': 0};
return {'pts_name': getBest(ptsMap), 'pts_val': getBestVal(ptsMap), 'ast_name': getBest(astMap), 'ast_val': getBestVal(astMap), 'rbs_name': getBest(rbsMap), 'rbs_val': getBestVal(rbsMap)}; }
String getBest(Map<String, int> map) {
if (map.isEmpty) return '---';
var bestId = map.entries.reduce((a, b) => a.value > b.value ? a : b).key;
return namesMap[bestId] ?? '---';
}
int getBestVal(Map<String, int> map) {
if (map.isEmpty) return 0;
return map.values.reduce((a, b) => a > b ? a : b);
}
return {
'pts_name': getBest(ptsMap), 'pts_val': getBestVal(ptsMap),
'ast_name': getBest(astMap), 'ast_val': getBestVal(astMap),
'rbs_name': getBest(rbsMap), 'rbs_val': getBestVal(rbsMap)
};
} }
Widget _buildStatCard({required BuildContext context, required String title, required String playerName, required String statValue, required String statLabel, required Color color, bool isHighlighted = false}) { Widget _buildStatCard({required BuildContext context, required String title, required String playerName, required String statValue, required String statLabel, required Color color, bool isHighlighted = false}) {

View File

@@ -0,0 +1,374 @@
import 'dart:typed_data';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class PdfExportService {
static Future<void> generateAndPrintBoxScore({
required String gameId,
required String myTeam,
required String opponentTeam,
required String myScore,
required String opponentScore,
required String season,
}) async {
final supabase = Supabase.instance.client;
final gameData = await supabase.from('games').select().eq('id', gameId).single();
final teamsData = await supabase.from('teams').select('id, name').inFilter('name', [myTeam, opponentTeam]);
String? myTeamId, oppTeamId;
for (var t in teamsData) {
if (t['name'] == myTeam) myTeamId = t['id'].toString();
if (t['name'] == opponentTeam) oppTeamId = t['id'].toString();
}
List<dynamic> myPlayers = myTeamId != null ? await supabase.from('members').select().eq('team_id', myTeamId).eq('type', 'Jogador') : [];
List<dynamic> oppPlayers = oppTeamId != null ? await supabase.from('members').select().eq('team_id', oppTeamId).eq('type', 'Jogador') : [];
final statsData = await supabase.from('player_stats').select().eq('game_id', gameId);
Map<String, Map<String, dynamic>> statsMap = {};
for (var s in statsData) {
statsMap[s['member_id'].toString()] = s;
}
List<List<String>> myTeamTable = _buildTeamTableData(myPlayers, statsMap);
List<List<String>> oppTeamTable = _buildTeamTableData(oppPlayers, statsMap);
final pdf = pw.Document();
pdf.addPage(
pw.Page( // 1. Trocado de MultiPage para Page
pageFormat: PdfPageFormat.a4.landscape,
margin: const pw.EdgeInsets.all(16), // Margens ligeiramente reduzidas para aproveitar o espaço
build: (pw.Context context) {
// 2. Envolvemos tudo num FittedBox
return pw.FittedBox(
fit: pw.BoxFit.scaleDown, // Reduz o tamanho apenas se não couber na página
child: pw.Container(
// Fixamos a largura do contentor à largura útil da página
width: PdfPageFormat.a4.landscape.availableWidth,
// 3. Colocamos todos os elementos dentro de uma Column
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('Relatório Estatístico', style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold)),
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text('$myTeam vs $opponentTeam', style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)),
pw.Text('Resultado: $myScore - $opponentScore', style: const pw.TextStyle(fontSize: 14)),
pw.Text('Época: $season', style: const pw.TextStyle(fontSize: 12)),
]
)
]
),
pw.SizedBox(height: 15), // Espaçamentos verticais um pouco mais otimizados
pw.Text('Equipa: $myTeam', style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold, color: const PdfColor.fromInt(0xFFA00000))),
pw.SizedBox(height: 4),
_buildPdfTable(myTeamTable, const PdfColor.fromInt(0xFFA00000)),
pw.SizedBox(height: 15),
pw.Text('Equipa: $opponentTeam', style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold, color: PdfColors.grey700)),
pw.SizedBox(height: 4),
_buildPdfTable(oppTeamTable, PdfColors.grey700),
pw.SizedBox(height: 15),
pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
_buildSummaryBox('Melhor Marcador', gameData['top_pts_name'] ?? '---'),
pw.SizedBox(width: 10),
_buildSummaryBox('Melhor Ressaltador', gameData['top_rbs_name'] ?? '---'),
pw.SizedBox(width: 10),
_buildSummaryBox('Melhor Passador', gameData['top_ast_name'] ?? '---'),
pw.SizedBox(width: 10),
_buildSummaryBox('MVP', gameData['mvp_name'] ?? '---'),
]
),
],
),
),
);
},
),
);
await Printing.layoutPdf(
onLayout: (PdfPageFormat format) async => pdf.save(),
name: 'BoxScore_${myTeam}_vs_${opponentTeam}.pdf',
);
}
static List<List<String>> _buildTeamTableData(List<dynamic> players, Map<String, Map<String, dynamic>> statsMap) {
List<List<String>> tableData = [];
int tPts = 0, tFgm = 0, tFga = 0, tFtm = 0, tFta = 0, tFls = 0;
int tOrb = 0, tDrb = 0, tTr = 0, tStl = 0, tAst = 0, tTov = 0, tBlk = 0;
int tP3m = 0, tP2m = 0, tP3a = 0, tP2a = 0;
players.sort((a, b) {
int numA = int.tryParse(a['number']?.toString() ?? '0') ?? 0;
int numB = int.tryParse(b['number']?.toString() ?? '0') ?? 0;
return numA.compareTo(numB);
});
for (var p in players) {
String id = p['id'].toString();
String name = p['name'] ?? 'Desconhecido';
String number = p['number']?.toString() ?? '-';
var stat = statsMap[id] ?? {};
int pts = stat['pts'] ?? 0;
int fgm = stat['fgm'] ?? 0;
int fga = stat['fga'] ?? 0;
int ftm = stat['ftm'] ?? 0;
int fta = stat['fta'] ?? 0;
int p2m = stat['p2m'] ?? 0;
int p2a = stat['p2a'] ?? 0;
int p3m = stat['p3m'] ?? 0;
int p3a = stat['p3a'] ?? 0;
int fls = stat['fls'] ?? 0;
int orb = stat['orb'] ?? 0;
int drb = stat['drb'] ?? 0;
int tr = orb + drb;
int stl = stat['stl'] ?? 0;
int ast = stat['ast'] ?? 0;
int tov = stat['tov'] ?? 0;
int blk = stat['blk'] ?? 0;
tPts += pts; tFgm += fgm; tFga += fga; tFtm += ftm; tFta += fta;
tFls += fls; tOrb += orb; tDrb += drb; tTr += tr; tStl += stl;
tAst += ast; tTov += tov; tBlk += blk;
tP3m += p3m; tP2m += p2m; tP3a += p3a; tP2a += p2a;
String p2Pct = p2a > 0 ? ((p2m / p2a) * 100).toStringAsFixed(0) + '%' : '0%';
String p3Pct = p3a > 0 ? ((p3m / p3a) * 100).toStringAsFixed(0) + '%' : '0%';
String globalPct = fga > 0 ? ((fgm / fga) * 100).toStringAsFixed(0) + '%' : '0%';
String llPct = fta > 0 ? ((ftm / fta) * 100).toStringAsFixed(0) + '%' : '0%';
tableData.add([
number, name, pts.toString(),
p2m.toString(), p2a.toString(), p2Pct,
p3m.toString(), p3a.toString(), p3Pct,
fgm.toString(), fga.toString(), globalPct,
ftm.toString(), fta.toString(), llPct,
fls.toString(), orb.toString(), drb.toString(), tr.toString(),
stl.toString(), ast.toString(), tov.toString(), blk.toString()
]);
}
if (tableData.isEmpty) {
tableData.add([
'-', 'Sem jogadores registados', '0',
'0', '0', '0%',
'0', '0', '0%',
'0', '0', '0%',
'0', '0', '0%',
'0', '0', '0', '0', '0', '0', '0', '0'
]);
}
String tP2Pct = tP2a > 0 ? ((tP2m / tP2a) * 100).toStringAsFixed(0) + '%' : '0%';
String tP3Pct = tP3a > 0 ? ((tP3m / tP3a) * 100).toStringAsFixed(0) + '%' : '0%';
String tGlobalPct = tFga > 0 ? ((tFgm / tFga) * 100).toStringAsFixed(0) + '%' : '0%';
String tLlPct = tFta > 0 ? ((tFtm / tFta) * 100).toStringAsFixed(0) + '%' : '0%';
tableData.add([
'', 'TOTAIS', tPts.toString(),
tP2m.toString(), tP2a.toString(), tP2Pct,
tP3m.toString(), tP3a.toString(), tP3Pct,
tFgm.toString(), tFga.toString(), tGlobalPct,
tFtm.toString(), tFta.toString(), tLlPct,
tFls.toString(), tOrb.toString(), tDrb.toString(), tTr.toString(),
tStl.toString(), tAst.toString(), tTov.toString(), tBlk.toString()
]);
return tableData;
}
static pw.Widget _buildPdfTable(List<List<String>> data, PdfColor headerColor) {
final headerStyle = pw.TextStyle(color: PdfColors.white, fontWeight: pw.FontWeight.bold, fontSize: 8);
final subHeaderStyle = pw.TextStyle(color: PdfColors.white, fontWeight: pw.FontWeight.bold, fontSize: 7);
final cellStyle = const pw.TextStyle(fontSize: 8);
// Agora usamos apenas 15 colunas principais na tabela.
// Os grupos (2P, 3P, etc.) são subdivididos INTERNAMENTE para evitar erros de colSpan.
return pw.Table(
border: pw.TableBorder.all(color: PdfColors.grey400, width: 0.5),
columnWidths: {
0: const pw.FlexColumnWidth(1.2), // Nº
1: const pw.FlexColumnWidth(5.0), // NOME (Maior para caber nomes como S.Gilgeous-alexander)
2: const pw.FlexColumnWidth(1.5), // PT
3: const pw.FlexColumnWidth(4.5), // 2 PONTOS (Grupo de 3)
4: const pw.FlexColumnWidth(4.5), // 3 PONTOS (Grupo de 3)
5: const pw.FlexColumnWidth(4.5), // GLOBAL (Grupo de 3)
6: const pw.FlexColumnWidth(4.5), // L. LIVRES (Grupo de 3)
7: const pw.FlexColumnWidth(1.5), // FLS
8: const pw.FlexColumnWidth(1.5), // RO
9: const pw.FlexColumnWidth(1.5), // RD
10: const pw.FlexColumnWidth(1.5), // TR
11: const pw.FlexColumnWidth(1.5), // BR
12: const pw.FlexColumnWidth(1.5), // AS
13: const pw.FlexColumnWidth(1.5), // BP
14: const pw.FlexColumnWidth(1.5), // BLK
},
children: [
// --- LINHA 1: CABEÇALHOS ---
pw.TableRow(
decoration: pw.BoxDecoration(color: headerColor),
children: [
_simpleHeader('', subHeaderStyle),
_simpleHeader('NOME', subHeaderStyle, align: pw.Alignment.centerLeft),
_simpleHeader('PT', subHeaderStyle),
_groupHeader('2 PONTOS', headerStyle, subHeaderStyle),
_groupHeader('3 PONTOS', headerStyle, subHeaderStyle),
_groupHeader('GLOBAL', headerStyle, subHeaderStyle),
_groupHeader('L. LIVRES', headerStyle, subHeaderStyle),
_simpleHeader('FLS', subHeaderStyle),
_simpleHeader('RO', subHeaderStyle),
_simpleHeader('RD', subHeaderStyle),
_simpleHeader('TR', subHeaderStyle),
_simpleHeader('BR', subHeaderStyle),
_simpleHeader('AS', subHeaderStyle),
_simpleHeader('BP', subHeaderStyle),
_simpleHeader('BLK', subHeaderStyle),
],
),
// --- LINHAS 2+: DADOS ---
...data.map((row) {
bool isTotais = row[1] == 'TOTAIS';
var rowStyle = isTotais ? pw.TextStyle(fontSize: 8, fontWeight: pw.FontWeight.bold) : cellStyle;
return pw.TableRow(
decoration: pw.BoxDecoration(
color: isTotais ? PdfColors.grey200 : PdfColors.white,
),
children: [
_simpleData(row[0], rowStyle),
_simpleData(row[1], rowStyle, align: pw.Alignment.centerLeft),
_simpleData(row[2], rowStyle),
_groupData(row[3], row[4], row[5], rowStyle), // 2P: C, T, %
_groupData(row[6], row[7], row[8], rowStyle), // 3P: C, T, %
_groupData(row[9], row[10], row[11], rowStyle), // GLOBAL: C, T, %
_groupData(row[12], row[13], row[14], rowStyle), // L. LIVRES: C, T, %
_simpleData(row[15], rowStyle),
_simpleData(row[16], rowStyle),
_simpleData(row[17], rowStyle),
_simpleData(row[18], rowStyle),
_simpleData(row[19], rowStyle),
_simpleData(row[20], rowStyle),
_simpleData(row[21], rowStyle),
_simpleData(row[22], rowStyle),
],
);
}),
],
);
}
// ==== WIDGETS AUXILIARES PARA RESOLVER A ESTRUTURA DO PDF ====
// Cabeçalho simples (Colunas que não se dividem)
static pw.Widget _simpleHeader(String text, pw.TextStyle style, {pw.Alignment align = pw.Alignment.center}) {
return pw.Container(
alignment: align,
padding: const pw.EdgeInsets.symmetric(vertical: 2, horizontal: 2),
child: pw.Text(text, style: style),
);
}
// Dados simples
static pw.Widget _simpleData(String text, pw.TextStyle style, {pw.Alignment align = pw.Alignment.center}) {
return pw.Container(
alignment: align,
padding: const pw.EdgeInsets.symmetric(vertical: 3, horizontal: 2),
child: pw.Text(text, style: style),
);
}
// Cria a divisão do Cabeçalho (O falso ColSpan que une "2 PONTOS" sobre "C | T | %")
static pw.Widget _groupHeader(String title, pw.TextStyle hStyle, pw.TextStyle sStyle) {
return pw.Column(
children: [
pw.Container(
width: double.infinity,
alignment: pw.Alignment.center,
padding: const pw.EdgeInsets.symmetric(vertical: 2),
decoration: const pw.BoxDecoration(
border: pw.Border(bottom: pw.BorderSide(color: PdfColors.white, width: 0.5)),
),
child: pw.Text(title, style: hStyle),
),
pw.Row(
children: [
pw.Expanded(child: pw.Container(alignment: pw.Alignment.center, child: pw.Text('C', style: sStyle))),
pw.Container(width: 0.5, height: 10, color: PdfColors.white), // Divisória vertical manual
pw.Expanded(child: pw.Container(alignment: pw.Alignment.center, child: pw.Text('T', style: sStyle))),
pw.Container(width: 0.5, height: 10, color: PdfColors.white), // Divisória vertical manual
pw.Expanded(child: pw.Container(alignment: pw.Alignment.center, child: pw.Text('%', style: sStyle))),
],
),
],
);
}
static pw.Widget _groupData(String c, String t, String pct, pw.TextStyle style) {
return pw.Row(
children: [
pw.Expanded(
child: pw.Container(
alignment: pw.Alignment.center,
padding: const pw.EdgeInsets.symmetric(vertical: 3),
child: pw.Text(c, style: style),
),
),
pw.Container(width: 0.5, height: 12, color: PdfColors.grey400), // Divisória cinza
pw.Expanded(
child: pw.Container(
alignment: pw.Alignment.center,
padding: const pw.EdgeInsets.symmetric(vertical: 3),
child: pw.Text(t, style: style),
),
),
pw.Container(width: 0.5, height: 12, color: PdfColors.grey400), // Divisória cinza
pw.Expanded(
child: pw.Container(
alignment: pw.Alignment.center,
padding: const pw.EdgeInsets.symmetric(vertical: 3),
child: pw.Text(pct, style: style),
),
),
],
);
}
static pw.Widget _buildSummaryBox(String title, String value) {
return pw.Container(
width: 120,
decoration: pw.BoxDecoration(
border: pw.TableBorder.all(color: PdfColors.black, width: 1),
),
child: pw.Column(
children: [
pw.Container(
width: double.infinity,
padding: const pw.EdgeInsets.all(4),
color: const PdfColor.fromInt(0xFFA00000),
child: pw.Text(title, style: pw.TextStyle(color: PdfColors.white, fontSize: 9, fontWeight: pw.FontWeight.bold), textAlign: pw.TextAlign.center),
),
pw.Container(
width: double.infinity,
padding: const pw.EdgeInsets.all(6),
child: pw.Text(value, style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold), textAlign: pw.TextAlign.center),
),
]
)
);
}
}

View File

@@ -1,10 +1,14 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:playmaker/classe/theme.dart'; import 'package:playmaker/classe/theme.dart';
import 'package:supabase_flutter/supabase_flutter.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 '../utils/size_extension.dart';
import 'login.dart'; import 'login.dart';
// 👇 OBRIGATÓRIO IMPORTAR O MAIN.DART PARA LER A VARIÁVEL "themeNotifier"
import '../main.dart'; import '../main.dart';
class SettingsScreen extends StatefulWidget { class SettingsScreen extends StatefulWidget {
@@ -16,16 +20,126 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
File? _localImageFile;
String? _uploadedImageUrl;
bool _isUploadingImage = false;
bool _isMemoryLoaded = false; // 👇 VARIÁVEL MÁGICA CONTRA O PISCAR
final supabase = Supabase.instance.client;
@override
void initState() {
super.initState();
_loadUserAvatar();
}
// 👇 LÊ A IMAGEM DA MEMÓRIA INSTANTANEAMENTE E CONFIRMA NA BD
Future<void> _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 {
final data = await supabase
.from('profiles')
.select('avatar_url')
.eq('id', userId)
.maybeSingle();
if (mounted && data != null && data['avatar_url'] != null) {
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");
}
}
Future<void> _handleImageChange() async {
final ImagePicker picker = ImagePicker();
final XFile? pickedFile = await picker.pickImage(source: ImageSource.gallery);
if (pickedFile == null || !mounted) return;
try {
setState(() {
_localImageFile = File(pickedFile.path);
_isUploadingImage = true;
});
final userId = supabase.auth.currentUser?.id;
if (userId == null) throw Exception("Utilizador não autenticado.");
final String storagePath = '$userId/profile_picture.png';
await supabase.storage.from('avatars').upload(
storagePath,
_localImageFile!,
fileOptions: const FileOptions(cacheControl: '3600', upsert: true)
);
final String publicUrl = supabase.storage.from('avatars').getPublicUrl(storagePath);
await supabase
.from('profiles')
.upsert({
'id': userId,
'avatar_url': publicUrl
});
// 👇 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;
_isUploadingImage = false;
_localImageFile = null;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Foto atualizada!"), backgroundColor: Colors.green)
);
}
} catch (e) {
if (mounted) {
setState(() {
_isUploadingImage = false;
_localImageFile = null;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Erro: $e"), backgroundColor: AppTheme.primaryRed)
);
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 👇 CORES DINÂMICAS (A MÁGICA DO MODO ESCURO)
final Color primaryRed = AppTheme.primaryRed; final Color primaryRed = AppTheme.primaryRed;
final Color bgColor = Theme.of(context).scaffoldBackgroundColor; final Color bgColor = Theme.of(context).scaffoldBackgroundColor;
final Color cardColor = Theme.of(context).cardTheme.color ?? Theme.of(context).colorScheme.surface; final Color cardColor = Theme.of(context).cardTheme.color ?? Theme.of(context).colorScheme.surface;
final Color textColor = Theme.of(context).colorScheme.onSurface; final Color textColor = Theme.of(context).colorScheme.onSurface;
final Color textLightColor = textColor.withOpacity(0.6); final Color textLightColor = textColor.withOpacity(0.6);
// 👇 SABER SE A APP ESTÁ ESCURA OU CLARA NESTE EXATO MOMENTO
bool isDark = Theme.of(context).brightness == Brightness.dark; bool isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold( return Scaffold(
@@ -37,10 +151,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
centerTitle: true, centerTitle: true,
title: Text( title: Text(
"Perfil e Definições", "Perfil e Definições",
style: TextStyle( style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.w600),
fontSize: 18 * context.sf,
fontWeight: FontWeight.w600,
),
), ),
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
@@ -52,9 +163,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// ==========================================
// CARTÃO DE PERFIL
// ==========================================
Container( Container(
padding: EdgeInsets.all(20 * context.sf), padding: EdgeInsets.all(20 * context.sf),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -62,20 +170,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
borderRadius: BorderRadius.circular(16 * context.sf), borderRadius: BorderRadius.circular(16 * context.sf),
border: Border.all(color: Colors.grey.withOpacity(0.1)), border: Border.all(color: Colors.grey.withOpacity(0.1)),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 10, offset: const Offset(0, 4)),
color: Colors.black.withOpacity(0.04),
blurRadius: 10,
offset: const Offset(0, 4),
),
], ],
), ),
child: Row( child: Row(
children: [ children: [
CircleAvatar( _buildTappableProfileAvatar(context, primaryRed),
radius: 32 * context.sf,
backgroundColor: primaryRed.withOpacity(0.1),
child: Icon(Icons.person, color: primaryRed, size: 32 * context.sf),
),
SizedBox(width: 16 * context.sf), SizedBox(width: 16 * context.sf),
Expanded( Expanded(
child: Column( child: Column(
@@ -83,19 +183,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [ children: [
Text( Text(
"Treinador", "Treinador",
style: TextStyle( style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold, color: textColor),
fontSize: 18 * context.sf,
fontWeight: FontWeight.bold,
color: textColor,
),
), ),
SizedBox(height: 4 * context.sf), SizedBox(height: 4 * context.sf),
Text( Text(
Supabase.instance.client.auth.currentUser?.email ?? "sem@email.com", supabase.auth.currentUser?.email ?? "sem@email.com",
style: TextStyle( style: TextStyle(color: textLightColor, fontSize: 14 * context.sf),
color: textLightColor,
fontSize: 14 * context.sf,
),
), ),
], ],
), ),
@@ -106,18 +199,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
SizedBox(height: 32 * context.sf), SizedBox(height: 32 * context.sf),
// ==========================================
// SECÇÃO: DEFINIÇÕES
// ==========================================
Padding( Padding(
padding: EdgeInsets.only(left: 4 * context.sf, bottom: 12 * context.sf), padding: EdgeInsets.only(left: 4 * context.sf, bottom: 12 * context.sf),
child: Text( child: Text(
"Definições", "Definições",
style: TextStyle( style: TextStyle(color: textLightColor, fontSize: 14 * context.sf, fontWeight: FontWeight.bold),
color: textLightColor,
fontSize: 14 * context.sf,
fontWeight: FontWeight.bold,
),
), ),
), ),
Container( Container(
@@ -126,11 +212,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
borderRadius: BorderRadius.circular(16 * context.sf), borderRadius: BorderRadius.circular(16 * context.sf),
border: Border.all(color: Colors.grey.withOpacity(0.1)), border: Border.all(color: Colors.grey.withOpacity(0.1)),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 10, offset: const Offset(0, 4)),
color: Colors.black.withOpacity(0.04),
blurRadius: 10,
offset: const Offset(0, 4),
),
], ],
), ),
child: ListTile( child: ListTile(
@@ -148,7 +230,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
value: isDark, value: isDark,
activeColor: primaryRed, activeColor: primaryRed,
onChanged: (bool value) { onChanged: (bool value) {
// 👇 CHAMA A VARIÁVEL DO MAIN.DART E ATUALIZA A APP TODA
themeNotifier.value = value ? ThemeMode.dark : ThemeMode.light; themeNotifier.value = value ? ThemeMode.dark : ThemeMode.light;
}, },
), ),
@@ -157,18 +238,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
SizedBox(height: 32 * context.sf), SizedBox(height: 32 * context.sf),
// ==========================================
// SECÇÃO: CONTA
// ==========================================
Padding( Padding(
padding: EdgeInsets.only(left: 4 * context.sf, bottom: 12 * context.sf), padding: EdgeInsets.only(left: 4 * context.sf, bottom: 12 * context.sf),
child: Text( child: Text(
"Conta", "Conta",
style: TextStyle( style: TextStyle(color: textLightColor, fontSize: 14 * context.sf, fontWeight: FontWeight.bold),
color: textLightColor,
fontSize: 14 * context.sf,
fontWeight: FontWeight.bold,
),
), ),
), ),
Container( Container(
@@ -177,11 +251,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
borderRadius: BorderRadius.circular(16 * context.sf), borderRadius: BorderRadius.circular(16 * context.sf),
border: Border.all(color: Colors.grey.withOpacity(0.1)), border: Border.all(color: Colors.grey.withOpacity(0.1)),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 10, offset: const Offset(0, 4)),
color: Colors.black.withOpacity(0.04),
blurRadius: 10,
offset: const Offset(0, 4),
),
], ],
), ),
child: ListTile( child: ListTile(
@@ -189,28 +259,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
leading: Icon(Icons.logout_outlined, color: primaryRed, size: 26 * context.sf), leading: Icon(Icons.logout_outlined, color: primaryRed, size: 26 * context.sf),
title: Text( title: Text(
"Terminar Sessão", "Terminar Sessão",
style: TextStyle( style: TextStyle(color: primaryRed, fontWeight: FontWeight.bold, fontSize: 15 * context.sf),
color: primaryRed,
fontWeight: FontWeight.bold,
fontSize: 15 * context.sf,
), ),
), onTap: () => _confirmLogout(context),
onTap: () => _confirmLogout(context), // 👇 CHAMA O LOGOUT REAL
), ),
), ),
SizedBox(height: 50 * context.sf), SizedBox(height: 50 * context.sf),
// ==========================================
// VERSÃO DA APP
// ==========================================
Center( Center(
child: Text( child: Text(
"PlayMaker v1.0.0", "PlayMaker v1.0.0",
style: TextStyle( style: TextStyle(color: textLightColor.withOpacity(0.7), fontSize: 13 * context.sf),
color: textLightColor.withOpacity(0.7),
fontSize: 13 * context.sf,
),
), ),
), ),
SizedBox(height: 20 * context.sf), SizedBox(height: 20 * context.sf),
@@ -220,28 +280,103 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
// 👇 FUNÇÃO PARA FAZER LOGOUT // 👇 AVATAR OTIMIZADO: SEM LAG, COM CACHE E MEMÓRIA
Widget _buildTappableProfileAvatar(BuildContext context, Color primaryRed) {
return GestureDetector(
onTap: () {
_handleImageChange();
},
child: Stack(
alignment: Alignment.center,
children: [
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
Positioned(
bottom: 0,
right: 0,
child: Container(
padding: EdgeInsets.all(6 * context.sf),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(color: Colors.grey.withOpacity(0.2)),
),
child: Icon(Icons.edit_outlined, color: primaryRed, size: 16 * context.sf),
),
),
// LOADING OVERLAY (Enquanto faz o upload)
if (_isUploadingImage)
Positioned.fill(
child: Container(
decoration: BoxDecoration(color: Colors.black.withOpacity(0.4), shape: BoxShape.circle),
child: const Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 3),
),
),
),
],
),
);
}
void _confirmLogout(BuildContext context) { void _confirmLogout(BuildContext context) {
showDialog( showDialog(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
title: Text("Terminar Sessão", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16 * context.sf)),
title: Text("Terminar Sessão", style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold)),
content: Text("Tens a certeza que queres sair da conta?", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)), content: Text("Tens a certeza que queres sair da conta?", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)),
actions: [ 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( ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryRed, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
onPressed: () async { 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(); await Supabase.instance.client.auth.signOut();
if (ctx.mounted) { if (ctx.mounted) {
// Mata a navegação toda para trás e manda para o Login
Navigator.of(ctx).pushAndRemoveUntil( Navigator.of(ctx).pushAndRemoveUntil(
MaterialPageRoute(builder: (context) => const LoginPage()), MaterialPageRoute(builder: (context) => const LoginPage()),
(Route<dynamic> route) => false, (Route<dynamic> route) => false,
); );
} }
}, },
child: Text("Sair", style: TextStyle(color: AppTheme.primaryRed, fontWeight: FontWeight.bold)) child: const Text("Sair", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold))
), ),
], ],
), ),

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:playmaker/classe/theme.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 '../controllers/team_controller.dart';
import '../utils/size_extension.dart'; import '../utils/size_extension.dart';
@@ -67,7 +68,7 @@ class _StatusPageState extends State<StatusPage> {
stream: _supabase.from('members').stream(primaryKey: ['id']).eq('team_id', _selectedTeamId!), stream: _supabase.from('members').stream(primaryKey: ['id']).eq('team_id', _selectedTeamId!),
builder: (context, membersSnapshot) { builder: (context, membersSnapshot) {
if (statsSnapshot.connectionState == ConnectionState.waiting || gamesSnapshot.connectionState == ConnectionState.waiting || membersSnapshot.connectionState == ConnectionState.waiting) { 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 ?? []; final membersData = membersSnapshot.data ?? [];
@@ -98,15 +99,17 @@ class _StatusPageState extends State<StatusPage> {
); );
} }
// 👇 AGORA GUARDA TAMBÉM O IMAGE_URL DO MEMBRO PARA MOSTRAR NA TABELA
List<Map<String, dynamic>> _aggregateStats(List<dynamic> stats, List<dynamic> games, List<dynamic> members) { List<Map<String, dynamic>> _aggregateStats(List<dynamic> stats, List<dynamic> games, List<dynamic> members) {
Map<String, Map<String, dynamic>> aggregated = {}; Map<String, Map<String, dynamic>> aggregated = {};
for (var member in members) { for (var member in members) {
String name = member['name']?.toString() ?? "Desconhecido"; 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) { for (var row in stats) {
String name = row['player_name']?.toString() ?? "Desconhecido"; 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]!['j'] += 1;
aggregated[name]!['pts'] += (row['pts'] ?? 0); aggregated[name]!['pts'] += (row['pts'] ?? 0);
@@ -132,7 +135,7 @@ class _StatusPageState extends State<StatusPage> {
for (var p in players) { 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); 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<Map<String, dynamic>> players, Map<String, dynamic> teamTotals, Color bgColor, Color textColor) { Widget _buildStatsGrid(BuildContext context, List<Map<String, dynamic>> players, Map<String, dynamic> teamTotals, Color bgColor, Color textColor) {
@@ -160,7 +163,31 @@ class _StatusPageState extends State<StatusPage> {
], ],
rows: [ rows: [
...players.map((player) => DataRow(cells: [ ...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)))), DataCell(Center(child: Text(player['j'].toString(), style: TextStyle(color: textColor)))),
_buildStatCell(context, player['pts'], textColor, isHighlight: true), _buildStatCell(context, player['pts'], textColor, isHighlight: true),
_buildStatCell(context, player['ast'], textColor), _buildStatCell(context, player['ast'], textColor),

View File

@@ -1,6 +1,10 @@
import 'dart:io';
import 'package:flutter/material.dart'; 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/screens/team_stats_page.dart';
import 'package:playmaker/classe/theme.dart'; // 👇 IMPORT DO TEMA import 'package:playmaker/classe/theme.dart';
import '../controllers/team_controller.dart'; import '../controllers/team_controller.dart';
import '../models/team_model.dart'; import '../models/team_model.dart';
import '../utils/size_extension.dart'; import '../utils/size_extension.dart';
@@ -162,7 +166,7 @@ class _TeamsPageState extends State<TeamsPage> {
hintStyle: TextStyle(fontSize: 16 * context.sf, color: Colors.grey), hintStyle: TextStyle(fontSize: 16 * context.sf, color: Colors.grey),
prefixIcon: Icon(Icons.search, color: AppTheme.primaryRed, size: 22 * context.sf), prefixIcon: Icon(Icons.search, color: AppTheme.primaryRed, size: 22 * context.sf),
filled: true, filled: true,
fillColor: Theme.of(context).colorScheme.surface, // 👇 Adapta-se ao Dark Mode fillColor: Theme.of(context).colorScheme.surface,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(15 * context.sf), borderSide: BorderSide.none), border: OutlineInputBorder(borderRadius: BorderRadius.circular(15 * context.sf), borderSide: BorderSide.none),
), ),
), ),
@@ -170,8 +174,8 @@ class _TeamsPageState extends State<TeamsPage> {
} }
Widget _buildTeamsList() { Widget _buildTeamsList() {
return StreamBuilder<List<Map<String, dynamic>>>( return FutureBuilder<List<Map<String, dynamic>>>(
stream: controller.teamsStream, future: controller.getTeamsWithStats(),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) return Center(child: CircularProgressIndicator(color: AppTheme.primaryRed)); if (snapshot.connectionState == ConnectionState.waiting) return Center(child: CircularProgressIndicator(color: AppTheme.primaryRed));
if (!snapshot.hasData || snapshot.data!.isEmpty) return Center(child: Text("Nenhuma equipa encontrada.", style: TextStyle(fontSize: 16 * context.sf, color: Theme.of(context).colorScheme.onSurface))); if (!snapshot.hasData || snapshot.data!.isEmpty) return Center(child: Text("Nenhuma equipa encontrada.", style: TextStyle(fontSize: 16 * context.sf, color: Theme.of(context).colorScheme.onSurface)));
@@ -190,28 +194,45 @@ class _TeamsPageState extends State<TeamsPage> {
else return (b['created_at'] ?? '').toString().compareTo((a['created_at'] ?? '').toString()); else return (b['created_at'] ?? '').toString().compareTo((a['created_at'] ?? '').toString());
}); });
return ListView.builder( return RefreshIndicator(
color: AppTheme.primaryRed,
onRefresh: () async => setState(() {}),
child: ListView.builder(
padding: EdgeInsets.symmetric(horizontal: 16 * context.sf), padding: EdgeInsets.symmetric(horizontal: 16 * context.sf),
itemCount: data.length, itemCount: data.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final team = Team.fromMap(data[index]); final team = Team.fromMap(data[index]);
return GestureDetector( return GestureDetector(
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (context) => TeamStatsPage(team: team))), onTap: () => Navigator.push(context, MaterialPageRoute(builder: (context) => TeamStatsPage(team: team))).then((_) => setState(() {})),
child: TeamCard( child: TeamCard(
team: team, team: team,
controller: controller, controller: controller,
onFavoriteTap: () => controller.toggleFavorite(team.id, team.isFavorite), onFavoriteTap: () async {
await controller.toggleFavorite(team.id, team.isFavorite);
setState(() {});
},
onDelete: () => setState(() {}),
sf: context.sf, sf: context.sf,
), ),
); );
}, },
),
); );
}, },
); );
} }
void _showCreateDialog(BuildContext context) { void _showCreateDialog(BuildContext context) {
showDialog(context: context, builder: (context) => CreateTeamDialog(sf: context.sf, onConfirm: (name, season, imageUrl) => controller.createTeam(name, season, imageUrl))); showDialog(
context: context,
builder: (context) => CreateTeamDialog(
sf: context.sf,
onConfirm: (name, season, imageFile) async {
await controller.createTeam(name, season, imageFile);
setState(() {});
}
),
);
} }
} }
@@ -220,6 +241,7 @@ class TeamCard extends StatelessWidget {
final Team team; final Team team;
final TeamController controller; final TeamController controller;
final VoidCallback onFavoriteTap; final VoidCallback onFavoriteTap;
final VoidCallback onDelete;
final double sf; final double sf;
const TeamCard({ const TeamCard({
@@ -227,6 +249,7 @@ class TeamCard extends StatelessWidget {
required this.team, required this.team,
required this.controller, required this.controller,
required this.onFavoriteTap, required this.onFavoriteTap,
required this.onDelete,
required this.sf, required this.sf,
}); });
@@ -234,6 +257,7 @@ class TeamCard extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bgColor = Theme.of(context).cardTheme.color ?? Theme.of(context).colorScheme.surface; final bgColor = Theme.of(context).cardTheme.color ?? Theme.of(context).colorScheme.surface;
final textColor = Theme.of(context).colorScheme.onSurface; final textColor = Theme.of(context).colorScheme.onSurface;
final double avatarSize = 56 * sf; // 2 * radius (28)
return Container( return Container(
margin: EdgeInsets.only(bottom: 12 * sf), margin: EdgeInsets.only(bottom: 12 * sf),
@@ -251,18 +275,29 @@ class TeamCard extends StatelessWidget {
leading: Stack( leading: Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
CircleAvatar( // 👇 AVATAR DA EQUIPA OTIMIZADO COM CACHE 👇
radius: 28 * sf, ClipOval(
backgroundColor: Colors.grey.withOpacity(0.2), child: Container(
backgroundImage: (team.imageUrl.isNotEmpty && team.imageUrl.startsWith('http')) width: avatarSize,
? NetworkImage(team.imageUrl) height: avatarSize,
: null, color: Colors.grey.withOpacity(0.2),
child: (team.imageUrl.isEmpty || !team.imageUrl.startsWith('http')) child: (team.imageUrl.isNotEmpty && team.imageUrl.startsWith('http'))
? Text( ? 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, team.imageUrl.isEmpty ? "🏀" : team.imageUrl,
style: TextStyle(fontSize: 24 * sf), style: TextStyle(fontSize: 24 * sf),
) ),
: null, ),
),
), ),
Positioned( Positioned(
left: -15 * sf, left: -15 * sf,
@@ -272,9 +307,7 @@ class TeamCard extends StatelessWidget {
team.isFavorite ? Icons.star : Icons.star_border, team.isFavorite ? Icons.star : Icons.star_border,
color: team.isFavorite ? AppTheme.warningAmber : Theme.of(context).colorScheme.onSurface.withOpacity(0.2), color: team.isFavorite ? AppTheme.warningAmber : Theme.of(context).colorScheme.onSurface.withOpacity(0.2),
size: 28 * sf, size: 28 * sf,
shadows: [ shadows: [Shadow(color: Colors.black.withOpacity(team.isFavorite ? 0.3 : 0.1), blurRadius: 4 * sf)],
Shadow(color: Colors.black.withOpacity(team.isFavorite ? 0.3 : 0.1), blurRadius: 4 * sf),
],
), ),
onPressed: onFavoriteTap, onPressed: onFavoriteTap,
), ),
@@ -292,20 +325,13 @@ class TeamCard extends StatelessWidget {
children: [ children: [
Icon(Icons.groups_outlined, size: 16 * sf, color: Colors.grey), Icon(Icons.groups_outlined, size: 16 * sf, color: Colors.grey),
SizedBox(width: 4 * sf), SizedBox(width: 4 * sf),
StreamBuilder<int>( Text(
stream: controller.getPlayerCountStream(team.id), "${team.playerCount} Jogs.",
initialData: 0,
builder: (context, snapshot) {
final count = snapshot.data ?? 0;
return Text(
"$count Jogs.",
style: TextStyle( style: TextStyle(
color: count > 0 ? AppTheme.successGreen : AppTheme.warningAmber, // 👇 Usando cores do tema color: team.playerCount > 0 ? AppTheme.successGreen : AppTheme.warningAmber,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 13 * sf, fontSize: 13 * sf,
), ),
);
},
), ),
SizedBox(width: 8 * sf), SizedBox(width: 8 * sf),
Expanded( Expanded(
@@ -320,7 +346,7 @@ class TeamCard extends StatelessWidget {
IconButton( IconButton(
tooltip: 'Ver Estatísticas', tooltip: 'Ver Estatísticas',
icon: Icon(Icons.bar_chart_rounded, color: Colors.blue, size: 24 * sf), icon: Icon(Icons.bar_chart_rounded, color: Colors.blue, size: 24 * sf),
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (context) => TeamStatsPage(team: team))), onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (context) => TeamStatsPage(team: team))).then((_) => onDelete()),
), ),
IconButton( IconButton(
tooltip: 'Eliminar Equipa', tooltip: 'Eliminar Equipa',
@@ -337,20 +363,23 @@ 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( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (ctx) => AlertDialog(
backgroundColor: cardColor, backgroundColor: cardColor,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
title: Text('Eliminar Equipa?', style: TextStyle(fontSize: 18 * sf, fontWeight: FontWeight.bold, color: textColor)), title: Text('Eliminar Equipa?', style: TextStyle(fontSize: 18 * sf, fontWeight: FontWeight.bold, color: textColor)),
content: Text('Tens a certeza que queres eliminar "${team.name}"?', style: TextStyle(fontSize: 14 * sf, color: textColor)), content: Text('Tens a certeza que queres eliminar "${team.name}"?', style: TextStyle(fontSize: 14 * sf, color: textColor)),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(ctx),
child: Text('Cancelar', style: TextStyle(fontSize: 14 * sf, color: Colors.grey)), child: Text('Cancelar', style: TextStyle(fontSize: 14 * sf, color: Colors.grey)),
), ),
TextButton( TextButton(
onPressed: () { onPressed: () {
controller.deleteTeam(team.id); Navigator.pop(ctx);
Navigator.pop(context); onDelete();
controller.deleteTeam(team.id).catchError((e) {
if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Erro ao eliminar: $e'), backgroundColor: Colors.red));
});
}, },
child: Text('Eliminar', style: TextStyle(color: AppTheme.primaryRed, fontSize: 14 * sf)), child: Text('Eliminar', style: TextStyle(color: AppTheme.primaryRed, fontSize: 14 * sf)),
), ),
@@ -360,9 +389,9 @@ class TeamCard extends StatelessWidget {
} }
} }
// --- DIALOG DE CRIAÇÃO --- // --- DIALOG DE CRIAÇÃO (COM CROPPER E ESCUDO) ---
class CreateTeamDialog extends StatefulWidget { class CreateTeamDialog extends StatefulWidget {
final Function(String name, String season, String imageUrl) onConfirm; final Function(String name, String season, File? imageFile) onConfirm;
final double sf; final double sf;
const CreateTeamDialog({super.key, required this.onConfirm, required this.sf}); const CreateTeamDialog({super.key, required this.onConfirm, required this.sf});
@@ -373,9 +402,48 @@ class CreateTeamDialog extends StatefulWidget {
class _CreateTeamDialogState extends State<CreateTeamDialog> { class _CreateTeamDialogState extends State<CreateTeamDialog> {
final TextEditingController _nameController = TextEditingController(); final TextEditingController _nameController = TextEditingController();
final TextEditingController _imageController = TextEditingController();
String _selectedSeason = '2024/25'; String _selectedSeason = '2024/25';
File? _selectedImage;
bool _isLoading = false;
bool _isPickerActive = false;
Future<void> _pickImage() async {
if (_isPickerActive) return;
setState(() => _isPickerActive = true);
try {
final ImagePicker picker = ImagePicker();
final XFile? pickedFile = await picker.pickImage(source: ImageSource.gallery);
if (pickedFile != null) {
CroppedFile? croppedFile = await ImageCropper().cropImage(
sourcePath: pickedFile.path,
aspectRatio: const CropAspectRatio(ratioX: 1, ratioY: 1),
uiSettings: [
AndroidUiSettings(
toolbarTitle: 'Recortar Logo',
toolbarColor: AppTheme.primaryRed,
toolbarWidgetColor: Colors.white,
initAspectRatio: CropAspectRatioPreset.square,
lockAspectRatio: true,
hideBottomControls: true,
),
IOSUiSettings(title: 'Recortar Logo', aspectRatioLockEnabled: true, resetButtonHidden: true),
],
);
if (croppedFile != null && mounted) {
setState(() {
_selectedImage = File(croppedFile.path);
});
}
}
} finally {
if (mounted) setState(() => _isPickerActive = false);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return AlertDialog(
@@ -386,6 +454,34 @@ class _CreateTeamDialogState extends State<CreateTeamDialog> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
GestureDetector(
onTap: _pickImage,
child: Stack(
children: [
CircleAvatar(
radius: 40 * widget.sf,
backgroundColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.05),
backgroundImage: _selectedImage != null ? FileImage(_selectedImage!) : null,
child: _selectedImage == null
? Icon(Icons.add_photo_alternate_outlined, size: 30 * widget.sf, color: Colors.grey)
: null,
),
if (_selectedImage == null)
Positioned(
bottom: 0, right: 0,
child: Container(
padding: EdgeInsets.all(4 * widget.sf),
decoration: const BoxDecoration(color: AppTheme.primaryRed, shape: BoxShape.circle),
child: Icon(Icons.add, color: Colors.white, size: 16 * widget.sf),
),
),
],
),
),
SizedBox(height: 10 * widget.sf),
Text("Logótipo (Opcional)", style: TextStyle(fontSize: 12 * widget.sf, color: Colors.grey)),
SizedBox(height: 20 * widget.sf),
TextField(controller: _nameController, style: TextStyle(fontSize: 14 * widget.sf, color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration(labelText: 'Nome da Equipa', labelStyle: TextStyle(fontSize: 14 * widget.sf)), textCapitalization: TextCapitalization.words), TextField(controller: _nameController, style: TextStyle(fontSize: 14 * widget.sf, color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration(labelText: 'Nome da Equipa', labelStyle: TextStyle(fontSize: 14 * widget.sf)), textCapitalization: TextCapitalization.words),
SizedBox(height: 15 * widget.sf), SizedBox(height: 15 * widget.sf),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
@@ -395,8 +491,6 @@ class _CreateTeamDialogState extends State<CreateTeamDialog> {
items: ['2023/24', '2024/25', '2025/26'].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(), items: ['2023/24', '2024/25', '2025/26'].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
onChanged: (val) => setState(() => _selectedSeason = val!), onChanged: (val) => setState(() => _selectedSeason = val!),
), ),
SizedBox(height: 15 * widget.sf),
TextField(controller: _imageController, style: TextStyle(fontSize: 14 * widget.sf, color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration(labelText: 'URL Imagem ou Emoji', labelStyle: TextStyle(fontSize: 14 * widget.sf), hintText: 'Ex: 🏀 ou https://...', hintStyle: TextStyle(fontSize: 14 * widget.sf, color: Colors.grey))),
], ],
), ),
), ),
@@ -404,8 +498,16 @@ class _CreateTeamDialogState extends State<CreateTeamDialog> {
TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancelar', style: TextStyle(fontSize: 14 * widget.sf, color: Colors.grey))), TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancelar', style: TextStyle(fontSize: 14 * widget.sf, color: Colors.grey))),
ElevatedButton( ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryRed, padding: EdgeInsets.symmetric(horizontal: 16 * widget.sf, vertical: 10 * widget.sf)), style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryRed, padding: EdgeInsets.symmetric(horizontal: 16 * widget.sf, vertical: 10 * widget.sf)),
onPressed: () { if (_nameController.text.trim().isNotEmpty) { widget.onConfirm(_nameController.text.trim(), _selectedSeason, _imageController.text.trim()); Navigator.pop(context); } }, onPressed: _isLoading ? null : () async {
child: Text('Criar', style: TextStyle(color: Colors.white, fontSize: 14 * widget.sf)), if (_nameController.text.trim().isNotEmpty) {
setState(() => _isLoading = true);
await widget.onConfirm(_nameController.text.trim(), _selectedSeason, _selectedImage);
if (context.mounted) Navigator.pop(context);
}
},
child: _isLoading
? SizedBox(width: 16 * widget.sf, height: 16 * widget.sf, child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: Text('Criar', style: TextStyle(color: Colors.white, fontSize: 14 * widget.sf)),
), ),
], ],
); );

View File

@@ -1 +0,0 @@
import 'package:flutter/material.dart';

View File

@@ -1,23 +1,38 @@
import 'dart:async'; import 'dart:io';
import 'package:flutter/material.dart'; 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: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/team_model.dart';
import '../models/person_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 INSTANTÂNEO)
// ==========================================
class StatsHeader extends StatelessWidget { class StatsHeader extends StatelessWidget {
final Team team; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
padding: EdgeInsets.only(top: 50 * context.sf, left: 20 * context.sf, right: 20 * context.sf, bottom: 20 * context.sf), padding: EdgeInsets.only(top: 50 * context.sf, left: 20 * context.sf, right: 20 * context.sf, bottom: 20 * context.sf),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.primaryRed, // 👇 Usando a cor oficial color: AppTheme.primaryRed,
borderRadius: BorderRadius.only( borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(30 * context.sf), bottomLeft: Radius.circular(30 * context.sf),
bottomRight: Radius.circular(30 * context.sf) bottomRight: Radius.circular(30 * context.sf)
@@ -27,22 +42,53 @@ class StatsHeader extends StatelessWidget {
children: [ children: [
IconButton( IconButton(
icon: Icon(Icons.arrow_back, color: Colors.white, size: 24 * context.sf), icon: Icon(Icons.arrow_back, color: Colors.white, size: 24 * context.sf),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context)
), ),
SizedBox(width: 10 * context.sf), SizedBox(width: 10 * context.sf),
CircleAvatar( GestureDetector(
radius: 24 * context.sf, onTap: onEditPhoto,
backgroundColor: Colors.white24, child: Stack(
backgroundImage: (team.imageUrl.isNotEmpty && team.imageUrl.startsWith('http')) alignment: Alignment.center,
? NetworkImage(team.imageUrl) children: [
: null, // 👇 AVATAR DA EQUIPA SEM LAG 👇
child: (team.imageUrl.isEmpty || !team.imageUrl.startsWith('http')) ClipOval(
? Text( child: Container(
team.imageUrl.isEmpty ? "🛡️" : team.imageUrl, width: 56 * context.sf,
style: TextStyle(fontSize: 20 * 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))),
) )
: null, : 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), SizedBox(width: 15 * context.sf),
@@ -50,15 +96,8 @@ class StatsHeader extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(team.name, style: TextStyle(color: Colors.white, fontSize: 20 * context.sf, fontWeight: FontWeight.bold), overflow: TextOverflow.ellipsis),
team.name, Text(team.season, style: TextStyle(color: Colors.white70, fontSize: 14 * context.sf)),
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 +110,28 @@ class StatsHeader extends StatelessWidget {
// --- CARD DE RESUMO --- // --- CARD DE RESUMO ---
class StatsSummaryCard extends StatelessWidget { class StatsSummaryCard extends StatelessWidget {
final int total; final int total;
const StatsSummaryCard({super.key, required this.total}); const StatsSummaryCard({super.key, required this.total});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 👇 Adapta-se ao Modo Claro/Escuro
final Color bgColor = Theme.of(context).brightness == Brightness.dark ? const Color(0xFF1E1E1E) : Colors.white; final Color bgColor = Theme.of(context).brightness == Brightness.dark ? const Color(0xFF1E1E1E) : Colors.white;
return Card( return Card(
elevation: 4, elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20 * context.sf)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20 * context.sf)),
child: Container( child: Container(
padding: EdgeInsets.all(20 * context.sf), padding: EdgeInsets.all(20 * context.sf),
decoration: BoxDecoration( decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(20 * context.sf), border: Border.all(color: Colors.grey.withOpacity(0.15))),
color: bgColor,
borderRadius: BorderRadius.circular(20 * context.sf),
border: Border.all(color: Colors.grey.withOpacity(0.15)),
),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Row( Row(
children: [ 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), SizedBox(width: 10 * context.sf),
Text( Text("Total de Membros", style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 16 * context.sf, fontWeight: FontWeight.w600)),
"Total de Membros",
style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 16 * context.sf, fontWeight: FontWeight.w600)
),
], ],
), ),
Text( Text("$total", style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 28 * context.sf, fontWeight: FontWeight.bold)),
"$total",
style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 28 * context.sf, fontWeight: FontWeight.bold)
),
], ],
), ),
), ),
@@ -116,7 +142,6 @@ class StatsSummaryCard extends StatelessWidget {
// --- TÍTULO DE SECÇÃO --- // --- TÍTULO DE SECÇÃO ---
class StatsSectionTitle extends StatelessWidget { class StatsSectionTitle extends StatelessWidget {
final String title; final String title;
const StatsSectionTitle({super.key, required this.title}); const StatsSectionTitle({super.key, required this.title});
@override @override
@@ -124,79 +149,119 @@ class StatsSectionTitle extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(title, style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface)),
title,
style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface)
),
Divider(color: Colors.grey.withOpacity(0.2)), Divider(color: Colors.grey.withOpacity(0.2)),
], ],
); );
} }
} }
// --- CARD DA PESSOA (JOGADOR/TREINADOR) --- // --- CARD DA PESSOA (FOTO SEM LAG) ---
class PersonCard extends StatelessWidget { class PersonCard extends StatelessWidget {
final Person person; final Person person;
final bool isCoach; final bool isCoach;
final VoidCallback onEdit; final VoidCallback onEdit;
final VoidCallback onDelete; final VoidCallback onDelete;
const PersonCard({ const PersonCard({super.key, required this.person, required this.isCoach, required this.onEdit, required this.onDelete});
super.key,
required this.person,
required this.isCoach,
required this.onEdit,
required this.onDelete,
});
@override @override
Widget build(BuildContext context) { 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 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 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( return Card(
margin: EdgeInsets.only(top: 12 * context.sf), margin: EdgeInsets.only(top: 12 * context.sf),
elevation: 2, elevation: 2,
color: isCoach ? coachBg : defaultBg, color: isCoach ? coachBg : defaultBg,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * context.sf)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * context.sf)),
child: ListTile( child: Padding(
contentPadding: EdgeInsets.symmetric(horizontal: 16 * context.sf, vertical: 4 * context.sf), padding: EdgeInsets.symmetric(horizontal: 16 * context.sf, vertical: 12 * context.sf),
leading: isCoach child: Row(
? CircleAvatar( children: [
radius: 22 * context.sf, // 👇 FOTO DO JOGADOR/TREINADOR INSTANTÂNEA 👇
backgroundColor: AppTheme.warningAmber, // 👇 Cor do tema ClipOval(
child: Icon(Icons.person, color: Colors.white, size: 24 * context.sf) 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),
) )
: Container( : Icon(Icons.person, color: iconColor, size: 24 * context.sf),
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( SizedBox(width: 12 * context.sf),
person.name, Expanded(
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16 * context.sf, color: Theme.of(context).colorScheme.onSurface) 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),
trailing: Row( ],
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, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton( IconButton(icon: Icon(Icons.edit_outlined, color: Colors.blue, size: 22 * context.sf), onPressed: onEdit, padding: EdgeInsets.zero, constraints: const BoxConstraints()),
icon: Icon(Icons.edit_outlined, color: Colors.blue, size: 22 * context.sf), SizedBox(width: 16 * context.sf),
onPressed: onEdit, IconButton(icon: Icon(Icons.delete_outline, color: AppTheme.primaryRed, size: 22 * context.sf), onPressed: onDelete, padding: EdgeInsets.zero, constraints: const BoxConstraints()),
],
), ),
IconButton( ],
icon: Icon(Icons.delete_outline, color: AppTheme.primaryRed, size: 22 * context.sf), // 👇 Cor do tema
onPressed: onDelete,
), ),
),
);
}
}
// ==========================================
// 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 +272,8 @@ class PersonCard extends StatelessWidget {
// ========================================== // ==========================================
// 2. PÁGINA PRINCIPAL // 2. PÁGINA PRINCIPAL
// ========================================== // ==========================================
class TeamStatsPage extends StatefulWidget { class TeamStatsPage extends StatefulWidget {
final Team team; final Team team;
const TeamStatsPage({super.key, required this.team}); const TeamStatsPage({super.key, required this.team});
@override @override
@@ -220,30 +283,78 @@ class TeamStatsPage extends StatefulWidget {
class _TeamStatsPageState extends State<TeamStatsPage> { class _TeamStatsPageState extends State<TeamStatsPage> {
final StatsController _controller = StatsController(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor, // 👇 Adapta-se ao Modo Escuro backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: Column( body: Column(
children: [ children: [
StatsHeader(team: widget.team), StatsHeader(team: widget.team, currentImageUrl: _teamImageUrl, onEditPhoto: _updateTeamPhoto, isUploading: _isUploadingTeamPhoto),
Expanded( Expanded(
child: StreamBuilder<List<Person>>( child: StreamBuilder<List<Person>>(
stream: _controller.getMembers(widget.team.id), stream: _controller.getMembers(widget.team.id),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
return Center(child: CircularProgressIndicator(color: AppTheme.primaryRed)); return const SkeletonLoadingStats();
} }
if (snapshot.hasError) { if (snapshot.hasError) return Center(child: Text("Erro ao carregar: ${snapshot.error}", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)));
return Center(child: Text("Erro ao carregar: ${snapshot.error}", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)));
}
final members = snapshot.data ?? []; final members = snapshot.data ?? [];
final coaches = members.where((m) => m.type == 'Treinador').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(); 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( return RefreshIndicator(
color: AppTheme.primaryRed, color: AppTheme.primaryRed,
@@ -257,32 +368,17 @@ class _TeamStatsPageState extends State<TeamStatsPage> {
StatsSummaryCard(total: members.length), StatsSummaryCard(total: members.length),
SizedBox(height: 30 * context.sf), SizedBox(height: 30 * context.sf),
// TREINADORES
if (coaches.isNotEmpty) ...[ if (coaches.isNotEmpty) ...[
const StatsSectionTitle(title: "Treinadores"), const StatsSectionTitle(title: "Treinadores"),
...coaches.map((c) => PersonCard( ...coaches.map((c) => PersonCard(person: c, isCoach: true, onEdit: () => _controller.showEditPersonDialog(context, widget.team.id, c), onDelete: () => _confirmDelete(context, c))),
person: c,
isCoach: true,
onEdit: () => _controller.showEditPersonDialog(context, widget.team.id, c),
onDelete: () => _confirmDelete(context, c),
)),
SizedBox(height: 30 * context.sf), SizedBox(height: 30 * context.sf),
], ],
// JOGADORES
const StatsSectionTitle(title: "Jogadores"), const StatsSectionTitle(title: "Jogadores"),
if (players.isEmpty) if (players.isEmpty)
Padding( Padding(padding: EdgeInsets.only(top: 20 * context.sf), child: Text("Nenhum jogador nesta equipa.", style: TextStyle(color: Colors.grey, fontSize: 16 * context.sf)))
padding: EdgeInsets.only(top: 20 * context.sf),
child: Text("Nenhum jogador nesta equipa.", style: TextStyle(color: Colors.grey, fontSize: 16 * context.sf)),
)
else else
...players.map((p) => PersonCard( ...players.map((p) => PersonCard(person: p, isCoach: false, onEdit: () => _controller.showEditPersonDialog(context, widget.team.id, p), onDelete: () => _confirmDelete(context, p))),
person: p,
isCoach: false,
onEdit: () => _controller.showEditPersonDialog(context, widget.team.id, p),
onDelete: () => _confirmDelete(context, p),
)),
SizedBox(height: 80 * context.sf), SizedBox(height: 80 * context.sf),
], ],
), ),
@@ -296,7 +392,7 @@ class _TeamStatsPageState extends State<TeamStatsPage> {
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
heroTag: 'fab_team_${widget.team.id}', heroTag: 'fab_team_${widget.team.id}',
onPressed: () => _controller.showAddPersonDialog(context, 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), child: Icon(Icons.add, color: Colors.white, size: 24 * context.sf),
), ),
); );
@@ -310,16 +406,17 @@ class _TeamStatsPageState extends State<TeamStatsPage> {
title: Text("Eliminar Membro?", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)), 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)), content: Text("Tens a certeza que queres remover ${person.name}?", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("Cancelar", style: TextStyle(color: Colors.grey))),
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx), onPressed: () {
child: const Text("Cancelar", style: TextStyle(color: Colors.grey)) Navigator.pop(ctx);
), ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("A remover ${person.name}..."), duration: const Duration(seconds: 1)));
TextButton(
onPressed: () async { _controller.deletePerson(person).catchError((e) {
await _controller.deletePerson(person.id); if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Erro: $e"), backgroundColor: AppTheme.primaryRed));
if (ctx.mounted) Navigator.pop(ctx); });
}, },
child: Text("Eliminar", style: TextStyle(color: AppTheme.primaryRed)), // 👇 Cor oficial child: const Text("Eliminar", style: TextStyle(color: AppTheme.primaryRed)),
), ),
], ],
), ),
@@ -327,36 +424,70 @@ class _TeamStatsPageState extends State<TeamStatsPage> {
} }
} }
// ========================================== // ==========================================
// 3. CONTROLLER // 3. CONTROLLER
// ========================================== // ==========================================
class StatsController { class StatsController {
final _supabase = Supabase.instance.client; final _supabase = Supabase.instance.client;
Stream<List<Person>> getMembers(String teamId) { Stream<List<Person>> getMembers(String teamId) {
return _supabase return _supabase.from('members').stream(primaryKey: ['id']).eq('team_id', teamId).map((data) => data.map((json) => Person.fromMap(json)).toList());
.from('members')
.stream(primaryKey: ['id'])
.eq('team_id', teamId)
.order('name', ascending: true)
.map((data) => data.map((json) => Person.fromMap(json)).toList());
} }
Future<void> deletePerson(String personId) async { 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 { try {
await _supabase.from('members').delete().eq('id', personId); 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) { } catch (e) {
debugPrint("Erro ao eliminar: $e"); debugPrint("Erro ao eliminar: $e");
} }
} }
void showAddPersonDialog(BuildContext context, String teamId) { void showAddPersonDialog(BuildContext context, String teamId) { _showForm(context, teamId: 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) { Future<File?> pickAndCropImage(BuildContext context) async {
_showForm(context, teamId: teamId, person: person); 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}) { void _showForm(BuildContext context, {required String teamId, Person? person}) {
@@ -365,24 +496,81 @@ class StatsController {
final numCtrl = TextEditingController(text: person?.number ?? ''); final numCtrl = TextEditingController(text: person?.number ?? '');
String selectedType = person?.type ?? 'Jogador'; String selectedType = person?.type ?? 'Jogador';
File? selectedImage;
bool isUploading = false;
bool isPickerActive = false;
String? currentImageUrl = isEdit ? person.imageUrl : null;
String? nameError;
String? numError;
showDialog( showDialog(
context: context, context: context,
builder: (ctx) => StatefulBuilder( builder: (ctx) => StatefulBuilder(
builder: (ctx, setState) => AlertDialog( builder: (ctx, setState) => AlertDialog(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * context.sf)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * context.sf)),
title: Text( title: Text(isEdit ? "Editar Membro" : "Novo Membro", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)),
isEdit ? "Editar Membro" : "Novo Membro",
style: TextStyle(color: Theme.of(context).colorScheme.onSurface)
),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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( TextField(
controller: nameCtrl, controller: nameCtrl,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: const InputDecoration(labelText: "Nome Completo"), decoration: InputDecoration(
labelText: "Nome Completo",
errorText: nameError,
),
textCapitalization: TextCapitalization.words, textCapitalization: TextCapitalization.words,
), ),
SizedBox(height: 15 * context.sf), SizedBox(height: 15 * context.sf),
@@ -391,19 +579,18 @@ class StatsController {
dropdownColor: Theme.of(context).colorScheme.surface, dropdownColor: Theme.of(context).colorScheme.surface,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 16 * context.sf), style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 16 * context.sf),
decoration: const InputDecoration(labelText: "Função"), decoration: const InputDecoration(labelText: "Função"),
items: ["Jogador", "Treinador"] items: ["Jogador", "Treinador"].map((e) => DropdownMenuItem(value: e, child: Text(e))).toList(),
.map((e) => DropdownMenuItem(value: e, child: Text(e))) onChanged: (v) { if (v != null) setState(() => selectedType = v); },
.toList(),
onChanged: (v) {
if (v != null) setState(() => selectedType = v);
},
), ),
if (selectedType == "Jogador") ...[ if (selectedType == "Jogador") ...[
SizedBox(height: 15 * context.sf), SizedBox(height: 15 * context.sf),
TextField( TextField(
controller: numCtrl, controller: numCtrl,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), 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,
),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
), ),
] ]
@@ -411,29 +598,45 @@ class StatsController {
), ),
), ),
actions: [ actions: [
TextButton( TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("Cancelar", style: TextStyle(color: Colors.grey))),
onPressed: () => Navigator.pop(ctx),
child: const Text("Cancelar", style: TextStyle(color: Colors.grey))
),
ElevatedButton( ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(backgroundColor: AppTheme.successGreen, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8 * context.sf))),
backgroundColor: AppTheme.successGreen, // 👇 Cor verde do tema onPressed: isUploading ? null : () async {
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8 * context.sf))
),
onPressed: () async {
if (nameCtrl.text.trim().isEmpty) return;
String? numeroFinal = (selectedType == "Treinador") setState(() {
? null nameError = null;
: (numCtrl.text.trim().isEmpty ? null : numCtrl.text.trim()); 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 { 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) { if (isEdit) {
await _supabase.from('members').update({ await _supabase.from('members').update({
'name': nameCtrl.text.trim(), 'name': nameCtrl.text.trim(),
'type': selectedType, 'type': selectedType,
'number': numeroFinal, 'number': numeroFinal,
'image_url': finalImageUrl,
}).eq('id', person.id); }).eq('id', person.id);
} else { } else {
await _supabase.from('members').insert({ await _supabase.from('members').insert({
@@ -441,23 +644,24 @@ class StatsController {
'name': nameCtrl.text.trim(), 'name': nameCtrl.text.trim(),
'type': selectedType, 'type': selectedType,
'number': numeroFinal, 'number': numeroFinal,
'image_url': finalImageUrl,
}); });
} }
if (ctx.mounted) Navigator.pop(ctx); if (ctx.mounted) Navigator.pop(ctx);
} catch (e) { } catch (e) {
debugPrint("Erro Supabase: $e"); setState(() {
if (ctx.mounted) { isUploading = false;
String errorMsg = "Erro ao guardar: $e"; if (e is PostgrestException && e.code == '23505') {
if (e.toString().contains('unique')) { numError = "Este número já está em uso!";
errorMsg = "Já existe um membro com este numero na equipa."; } else if (e.toString().toLowerCase().contains('unique') || e.toString().toLowerCase().contains('duplicate')) {
} numError = "Este número já está em uso!";
ScaffoldMessenger.of(ctx).showSnackBar( } else {
SnackBar(content: Text(errorMsg), backgroundColor: AppTheme.primaryRed) // 👇 Cor oficial para erro nameError = "Erro ao guardar. Tente novamente.";
);
} }
});
} }
}, },
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"),
) )
], ],
), ),

View File

@@ -3,9 +3,29 @@ import 'dart:math' as math;
extension SizeExtension on BuildContext { extension SizeExtension on BuildContext {
double get sf { double get sf {
final double wScreen = MediaQuery.of(this).size.width; final Size size = MediaQuery.of(this).size;
final double hScreen = MediaQuery.of(this).size.height;
// Ajusta a escala baseada no ecrã (muda os valores 1150/720 conforme a tua calibração) // 1. Definimos os valores base do design (geralmente feitos no Figma/Adobe XD)
return math.min(wScreen / 1150, hScreen / 720); const double baseWidth = 375;
const double baseHeight = 812;
// 2. Calculamos o rácio de largura e altura
double scaleW = size.width / baseWidth;
double scaleH = size.height / baseHeight;
// 3. Usamos a média ou o menor valor para manter a proporção
// O 'min' evita que o texto estique demasiado se o ecrã for muito alto ou largo
double scale = math.min(scaleW, scaleH);
// 4. Segurança (Clamping): Não deixa as coisas ficarem minúsculas
// nem exageradamente grandes em tablets.
return scale.clamp(0.8, 1.4);
} }
// Atalhos úteis para facilitar o código
double get screenWidth => MediaQuery.of(this).size.width;
double get screenHeight => MediaQuery.of(this).size.height;
// Verifica se é Tablet (opcional)
bool get isTablet => screenWidth > 600;
} }

View File

@@ -11,33 +11,50 @@ class BasketTrackHeader extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
children: [ children: [
// Usamos um Stack para controlar a sobreposição exata
Stack(
alignment: Alignment.center,
children: [
// 1. A Imagem (Aumentada para 320)
SizedBox( SizedBox(
width: 200 * context.sf, width: 320 * context.sf,
height: 200 * context.sf, height: 350 * context.sf,
child: Image.asset( child: Image.asset(
'assets/playmaker-logos.png', 'assets/playmaker-logos.png',
fit: BoxFit.contain, fit: BoxFit.contain,
), ),
), ),
// 2. O Texto "subido" para dentro da área da imagem
Positioned(
bottom: 5 * context.sf, // Ajusta este valor para aproximar/afastar do centro da logo
child: Column(
children: [
Text( Text(
'BasketTrack', 'BasketTrack',
style: TextStyle( style: TextStyle(
fontSize: 36 * context.sf, fontSize: 36 * context.sf,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface, // 👇 Adaptável ao Modo Escuro color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
SizedBox(height: 6 * context.sf), SizedBox(height: 4 * context.sf),
Text( Text(
'Gere as tuas equipas e estatísticas', 'Gere as tuas equipas e estatísticas',
style: TextStyle( style: TextStyle(
fontSize: 16 * context.sf, fontSize: 16 * context.sf,
color: Colors.grey, // Mantemos cinza para subtítulo color: Colors.grey,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
], ],
),
),
],
),
// Espaço extra para não bater nos campos de login logo a seguir
SizedBox(height: 10 * context.sf),
],
); );
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -118,8 +118,7 @@ class PersonCard extends StatelessWidget {
height: 45, height: 45,
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration(color: Colors.blue.withOpacity(0.1), borderRadius: BorderRadius.circular(10)), decoration: BoxDecoration(color: Colors.blue.withOpacity(0.1), borderRadius: BorderRadius.circular(10)),
child: Text(person.number, style: const TextStyle(color: Colors.blue, fontWeight: FontWeight.bold, fontSize: 16)), child: Text(person.number ?? "J", style: const TextStyle(color: Colors.blue, fontWeight: FontWeight.bold, fontSize: 16)), ),
),
title: Text(person.name, style: const TextStyle(fontWeight: FontWeight.bold)), title: Text(person.name, style: const TextStyle(fontWeight: FontWeight.bold)),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View File

@@ -6,13 +6,21 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <gtk/gtk_plugin.h> #include <gtk/gtk_plugin.h>
#include <printing/printing_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h> #include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) gtk_registrar = g_autoptr(FlPluginRegistrar) gtk_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin");
gtk_plugin_register_with_registrar(gtk_registrar); gtk_plugin_register_with_registrar(gtk_registrar);
g_autoptr(FlPluginRegistrar) printing_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin");
printing_plugin_register_with_registrar(printing_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View File

@@ -3,7 +3,9 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
gtk gtk
printing
url_launcher_linux url_launcher_linux
) )

View File

@@ -6,13 +6,19 @@ import FlutterMacOS
import Foundation import Foundation
import app_links import app_links
import file_selector_macos
import path_provider_foundation import path_provider_foundation
import printing
import shared_preferences_foundation import shared_preferences_foundation
import sqflite_darwin
import url_launcher_macos import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
} }

View File

@@ -41,6 +41,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.4" version: "1.0.4"
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
async: async:
dependency: transitive dependency: transitive
description: description:
@@ -49,6 +57,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.0" version: "2.13.0"
barcode:
dependency: transitive
description:
name: barcode
sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4"
url: "https://pub.dev"
source: hosted
version: "2.2.9"
bidi:
dependency: transitive
description:
name: bidi
sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d"
url: "https://pub.dev"
source: hosted
version: "2.0.13"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@@ -57,6 +81,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
cached_network_image:
dependency: "direct main"
description:
name: cached_network_image
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
cached_network_image_platform_interface:
dependency: transitive
description:
name: cached_network_image_platform_interface
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
cached_network_image_web:
dependency: transitive
description:
name: cached_network_image_web
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@@ -89,6 +137,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" version: "3.1.2"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev"
source: hosted
version: "0.3.5+2"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
@@ -145,6 +201,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" version: "7.0.1"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
url: "https://pub.dev"
source: hosted
version: "0.9.5"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
fixnum: fixnum:
dependency: transitive dependency: transitive
description: description:
@@ -158,6 +246,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_cache_manager:
dependency: transitive
description:
name: flutter_cache_manager
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -166,6 +262,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" version: "5.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
url: "https://pub.dev"
source: hosted
version: "2.0.33"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -216,6 +320,102 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
image_cropper:
dependency: "direct main"
description:
name: image_cropper
sha256: "46c8f9aae51c8350b2a2982462f85a129e77b04675d35b09db5499437d7a996b"
url: "https://pub.dev"
source: hosted
version: "11.0.0"
image_cropper_for_web:
dependency: transitive
description:
name: image_cropper_for_web
sha256: e09749714bc24c4e3b31fbafa2e5b7229b0ff23e8b14d4ba44bd723b77611a0f
url: "https://pub.dev"
source: hosted
version: "7.0.0"
image_cropper_platform_interface:
dependency: transitive
description:
name: image_cropper_platform_interface
sha256: "886a30ec199362cdcc2fbb053b8e53347fbfb9dbbdaa94f9ff85622609f5e7ff"
url: "https://pub.dev"
source: hosted
version: "8.0.0"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156
url: "https://pub.dev"
source: hosted
version: "0.8.13+14"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
url: "https://pub.dev"
source: hosted
version: "0.8.13+6"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
url: "https://pub.dev"
source: hosted
version: "0.2.2+1"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev"
source: hosted
version: "0.2.2"
jwt_decode: jwt_decode:
dependency: transitive dependency: transitive
description: description:
@@ -304,6 +504,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
octo_image:
dependency: transitive
description:
name: octo_image
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -312,6 +520,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider: path_provider:
dependency: transitive dependency: transitive
description: description:
@@ -360,6 +576,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
pdf:
dependency: "direct main"
description:
name: pdf
sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b
url: "https://pub.dev"
source: hosted
version: "3.12.0"
pdf_widget_wrapper:
dependency: transitive
description:
name: pdf_widget_wrapper
sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5
url: "https://pub.dev"
source: hosted
version: "1.0.4"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@@ -384,6 +624,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.0" version: "4.0.0"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
postgrest: postgrest:
dependency: transitive dependency: transitive
description: description:
@@ -392,6 +640,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.0" version: "2.6.0"
printing:
dependency: "direct main"
description:
name: printing
sha256: "689170c9ddb1bda85826466ba80378aa8993486d3c959a71cd7d2d80cb606692"
url: "https://pub.dev"
source: hosted
version: "5.14.3"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -400,6 +656,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5+1" version: "6.1.5+1"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
realtime_client: realtime_client:
dependency: transitive dependency: transitive
description: description:
@@ -425,7 +689,7 @@ packages:
source: hosted source: hosted
version: "0.28.0" version: "0.28.0"
shared_preferences: shared_preferences:
dependency: transitive dependency: "direct main"
description: description:
name: shared_preferences name: shared_preferences
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
@@ -480,6 +744,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" version: "2.4.1"
shimmer:
dependency: "direct main"
description:
name: shimmer
sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -493,6 +765,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.1" version: "1.10.1"
sqflite:
dependency: transitive
description:
name: sqflite
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
url: "https://pub.dev"
source: hosted
version: "2.4.2"
sqflite_android:
dependency: transitive
description:
name: sqflite_android
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
url: "https://pub.dev"
source: hosted
version: "2.4.2+3"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
sqflite_darwin:
dependency: transitive
description:
name: sqflite_darwin
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
sqflite_platform_interface:
dependency: transitive
description:
name: sqflite_platform_interface
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@@ -541,6 +853,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.12.0" version: "2.12.0"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
url: "https://pub.dev"
source: hosted
version: "3.4.0"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@@ -629,6 +949,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.5" version: "3.1.5"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -677,6 +1005,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yet_another_json_isolate: yet_another_json_isolate:
dependency: transitive dependency: transitive
description: description:

View File

@@ -36,6 +36,13 @@ dependencies:
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
provider: ^6.1.5+1 provider: ^6.1.5+1
supabase_flutter: ^2.12.0 supabase_flutter: ^2.12.0
image_picker: ^1.2.1
image_cropper: ^11.0.0
shimmer: ^3.0.0
cached_network_image: ^3.4.1
shared_preferences: ^2.5.4
printing: ^5.14.3
pdf: ^3.12.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -58,6 +65,7 @@ flutter:
assets: assets:
- assets/playmaker-logo.png - assets/playmaker-logo.png
- assets/campo.png - assets/campo.png
- assets/playmaker-logos.png
# An image asset can refer to one or more resolution-specific "variants", see # An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images # https://flutter.dev/to/resolution-aware-images

View File

@@ -7,11 +7,17 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <app_links/app_links_plugin_c_api.h> #include <app_links/app_links_plugin_c_api.h>
#include <file_selector_windows/file_selector_windows.h>
#include <printing/printing_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h> #include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
AppLinksPluginCApiRegisterWithRegistrar( AppLinksPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AppLinksPluginCApi")); registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
PrintingPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PrintingPlugin"));
UrlLauncherWindowsRegisterWithRegistrar( UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows")); registry->GetRegistrarForPlugin("UrlLauncherWindows"));
} }

View File

@@ -4,6 +4,8 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
app_links app_links
file_selector_windows
printing
url_launcher_windows url_launcher_windows
) )