sabado
This commit is contained in:
@@ -1,8 +1,18 @@
|
||||
<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
|
||||
android:label="playmaker"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
|
||||
<activity
|
||||
android:name="com.yalantis.ucrop.UCropActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.AppCompat.Light.NoActionBar"/>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -45,5 +45,12 @@
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<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>
|
||||
</plist>
|
||||
|
||||
@@ -4,51 +4,44 @@ import '../models/game_model.dart';
|
||||
class GameController {
|
||||
final _supabase = Supabase.instance.client;
|
||||
|
||||
// 👇 Atalho para apanhar o ID do utilizador logado
|
||||
String get myUserId => _supabase.auth.currentUser?.id ?? '';
|
||||
|
||||
// 1. LER JOGOS (Stream em Tempo Real da tabela original)
|
||||
// LER JOGOS
|
||||
Stream<List<Game>> get gamesStream {
|
||||
return _supabase
|
||||
.from('games')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('user_id', myUserId) // 🔒 SEGURANÇA: Ouve apenas os jogos deste utilizador
|
||||
.eq('user_id', myUserId)
|
||||
.asyncMap((event) async {
|
||||
// Lê diretamente da tabela "games" e já não da "games_with_logos"
|
||||
final data = await _supabase
|
||||
.from('games')
|
||||
.select()
|
||||
.eq('user_id', myUserId) // 🔒 SEGURANÇA
|
||||
.eq('user_id', myUserId)
|
||||
.order('game_date', ascending: false);
|
||||
|
||||
// O Game.fromMap agora faz o trabalho sujo todo!
|
||||
return data.map((json) => Game.fromMap(json)).toList();
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 👇 LER JOGOS COM FILTROS DE EQUIPA E TEMPORADA
|
||||
// =========================================================================
|
||||
// LER JOGOS COM FILTROS
|
||||
Stream<List<Game>> getFilteredGames({required String teamFilter, required String seasonFilter}) {
|
||||
return _supabase
|
||||
.from('games')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('user_id', myUserId) // 🔒 SEGURANÇA
|
||||
.eq('user_id', myUserId)
|
||||
.asyncMap((event) async {
|
||||
|
||||
// 1. Começamos a query na tabela principal "games"
|
||||
var query = _supabase.from('games').select().eq('user_id', myUserId); // 🔒 SEGURANÇA
|
||||
var query = _supabase.from('games').select().eq('user_id', myUserId);
|
||||
|
||||
// 2. Se a temporada não for "Todas", aplicamos o filtro AQUI
|
||||
if (seasonFilter != 'Todas') {
|
||||
query = query.eq('season', seasonFilter);
|
||||
}
|
||||
|
||||
// 3. Executamos a query e ordenamos pela data
|
||||
final data = await query.order('game_date', ascending: false);
|
||||
|
||||
List<Game> games = data.map((json) => Game.fromMap(json)).toList();
|
||||
|
||||
// 4. Filtramos a equipa em memória
|
||||
if (teamFilter != 'Todas') {
|
||||
games = games.where((g) => g.myTeam == teamFilter || g.opponentTeam == teamFilter).toList();
|
||||
}
|
||||
@@ -57,11 +50,11 @@ class GameController {
|
||||
});
|
||||
}
|
||||
|
||||
// 2. CRIAR JOGO
|
||||
// CRIAR JOGO
|
||||
Future<String?> createGame(String myTeam, String opponent, String season) async {
|
||||
try {
|
||||
final response = await _supabase.from('games').insert({
|
||||
'user_id': myUserId, // 🔒 CARIMBA O JOGO COM O ID DO TREINADOR
|
||||
'user_id': myUserId,
|
||||
'my_team': myTeam,
|
||||
'opponent_team': opponent,
|
||||
'season': season,
|
||||
@@ -69,16 +62,24 @@ class GameController {
|
||||
'opponent_score': 0,
|
||||
'status': 'Decorrer',
|
||||
'game_date': DateTime.now().toIso8601String(),
|
||||
// 👇 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'];
|
||||
return response['id']?.toString();
|
||||
} catch (e) {
|
||||
print("Erro ao criar jogo: $e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
// Não é necessário fechar streams do Supabase manualmente aqui
|
||||
}
|
||||
void dispose() {}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class ShotRecord {
|
||||
final double relativeX;
|
||||
final double relativeY;
|
||||
final bool isMake;
|
||||
final String playerId;
|
||||
final String playerName;
|
||||
final String? zone;
|
||||
final int? points;
|
||||
@@ -15,28 +18,39 @@ class ShotRecord {
|
||||
required this.relativeX,
|
||||
required this.relativeY,
|
||||
required this.isMake,
|
||||
required this.playerId,
|
||||
required this.playerName,
|
||||
this.zone,
|
||||
this.points,
|
||||
});
|
||||
|
||||
// 👇 Para o Auto-Save converter em Texto
|
||||
Map<String, dynamic> toJson() => {
|
||||
'relativeX': relativeX, 'relativeY': relativeY, 'isMake': isMake,
|
||||
'playerId': playerId, 'playerName': playerName, 'zone': zone, 'points': points,
|
||||
};
|
||||
|
||||
// 👇 Para o Auto-Save ler do Texto
|
||||
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 {
|
||||
// 👇 AGORA É UM CHANGENOTIFIER (Gestor de Estado Profissional) 👇
|
||||
class PlacarController extends ChangeNotifier {
|
||||
final String gameId;
|
||||
final String myTeam;
|
||||
final String opponentTeam;
|
||||
final VoidCallback onUpdate;
|
||||
|
||||
PlacarController({
|
||||
required this.gameId,
|
||||
required this.myTeam,
|
||||
required this.opponentTeam,
|
||||
required this.onUpdate
|
||||
});
|
||||
|
||||
bool isLoading = true;
|
||||
bool isSaving = false;
|
||||
|
||||
bool gameWasAlreadyFinished = false;
|
||||
|
||||
int myScore = 0;
|
||||
@@ -55,19 +69,20 @@ class PlacarController {
|
||||
List<String> oppCourt = [];
|
||||
List<String> oppBench = [];
|
||||
|
||||
Map<String, String> playerNames = {};
|
||||
Map<String, String> playerNumbers = {};
|
||||
Map<String, Map<String, int>> playerStats = {};
|
||||
Map<String, String> playerDbIds = {};
|
||||
|
||||
bool showMyBench = false;
|
||||
bool showOppBench = false;
|
||||
|
||||
bool isSelectingShotLocation = false;
|
||||
String? pendingAction;
|
||||
String? pendingPlayer;
|
||||
String? pendingPlayerId;
|
||||
List<ShotRecord> matchShots = [];
|
||||
|
||||
Duration duration = const Duration(minutes: 10);
|
||||
// 👇 O CRONÓMETRO AGORA TEM VIDA PRÓPRIA (ValueNotifier) PARA NÃO ENCRAVAR A APP 👇
|
||||
ValueNotifier<Duration> durationNotifier = ValueNotifier(const Duration(minutes: 10));
|
||||
Timer? timer;
|
||||
bool isRunning = false;
|
||||
|
||||
@@ -81,16 +96,9 @@ class PlacarController {
|
||||
try {
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
|
||||
myCourt.clear();
|
||||
myBench.clear();
|
||||
oppCourt.clear();
|
||||
oppBench.clear();
|
||||
playerStats.clear();
|
||||
playerNumbers.clear();
|
||||
playerDbIds.clear();
|
||||
matchShots.clear(); // Limpa as bolas do último jogo
|
||||
myFouls = 0;
|
||||
opponentFouls = 0;
|
||||
myCourt.clear(); myBench.clear(); oppCourt.clear(); oppBench.clear();
|
||||
playerNames.clear(); playerStats.clear(); playerNumbers.clear();
|
||||
matchShots.clear(); myFouls = 0; opponentFouls = 0;
|
||||
|
||||
final gameResponse = await supabase.from('games').select().eq('id', gameId).single();
|
||||
|
||||
@@ -98,7 +106,7 @@ class PlacarController {
|
||||
opponentScore = int.tryParse(gameResponse['opponent_score']?.toString() ?? '0') ?? 0;
|
||||
|
||||
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;
|
||||
opponentTimeoutsUsed = int.tryParse(gameResponse['opp_timeouts']?.toString() ?? '0') ?? 0;
|
||||
@@ -128,7 +136,7 @@ class PlacarController {
|
||||
|
||||
if (savedStats.containsKey(dbId)) {
|
||||
var s = savedStats[dbId];
|
||||
playerStats[name] = {
|
||||
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,
|
||||
@@ -147,7 +155,7 @@ class PlacarController {
|
||||
|
||||
if (savedStats.containsKey(dbId)) {
|
||||
var s = savedStats[dbId];
|
||||
playerStats[name] = {
|
||||
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,
|
||||
@@ -158,44 +166,46 @@ class PlacarController {
|
||||
}
|
||||
_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);
|
||||
for (var shotData in shotsResponse) {
|
||||
matchShots.add(ShotRecord(
|
||||
relativeX: double.parse(shotData['relative_x'].toString()),
|
||||
relativeY: double.parse(shotData['relative_y'].toString()),
|
||||
isMake: shotData['is_make'] == true,
|
||||
playerId: shotData['member_id'].toString(),
|
||||
playerName: shotData['player_name'].toString(),
|
||||
zone: shotData['zone']?.toString(),
|
||||
points: shotData['points'] != null ? int.parse(shotData['points'].toString()) : null,
|
||||
));
|
||||
}
|
||||
|
||||
// 👇 AUTO-SAVE: SE O JOGO FOI ABAIXO A MEIO, RECUPERA TUDO AQUI! 👇
|
||||
await _loadLocalBackup();
|
||||
|
||||
isLoading = false;
|
||||
onUpdate();
|
||||
notifyListeners(); // Substitui o antigo onUpdate!
|
||||
} catch (e) {
|
||||
debugPrint("Erro ao retomar jogo: $e");
|
||||
_padTeam(myCourt, myBench, "Falha", isMyTeam: true);
|
||||
_padTeam(oppCourt, oppBench, "Falha Opp", isMyTeam: false);
|
||||
isLoading = false;
|
||||
onUpdate();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _registerPlayer({required String name, required String number, String? dbId, required bool isMyTeam, required bool isCourt}) {
|
||||
if (playerNumbers.containsKey(name)) name = "$name (Opp)";
|
||||
playerNumbers[name] = number;
|
||||
if (dbId != null) playerDbIds[name] = dbId;
|
||||
String id = dbId ?? "fake_${DateTime.now().millisecondsSinceEpoch}_${math.Random().nextInt(9999)}";
|
||||
|
||||
playerStats[name] = {
|
||||
playerNames[id] = name;
|
||||
playerNumbers[id] = number;
|
||||
|
||||
playerStats[id] = {
|
||||
"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
|
||||
};
|
||||
|
||||
if (isMyTeam) {
|
||||
if (isCourt) myCourt.add(name); else myBench.add(name);
|
||||
if (isCourt) myCourt.add(id); else myBench.add(id);
|
||||
} else {
|
||||
if (isCourt) oppCourt.add(name); else oppBench.add(name);
|
||||
if (isCourt) oppCourt.add(id); else oppBench.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,33 +215,80 @@ class PlacarController {
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 👇 AS DUAS FUNÇÕES MÁGICAS DO AUTO-SAVE 👇
|
||||
// =========================================================================
|
||||
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(),
|
||||
};
|
||||
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();
|
||||
|
||||
debugPrint("🔄 AUTO-SAVE RECUPERADO COM SUCESSO!");
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Erro ao carregar Auto-Save: $e");
|
||||
}
|
||||
}
|
||||
|
||||
void toggleTimer(BuildContext context) {
|
||||
if (isRunning) {
|
||||
timer?.cancel();
|
||||
_saveLocalBackup(); // Grava no telemóvel quando pausa!
|
||||
} else {
|
||||
timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (duration.inSeconds > 0) {
|
||||
duration -= const Duration(seconds: 1);
|
||||
if (durationNotifier.value.inSeconds > 0) {
|
||||
durationNotifier.value -= const Duration(seconds: 1); // 👈 Só o relógio atualiza, a app não pisca!
|
||||
} else {
|
||||
timer.cancel();
|
||||
isRunning = false;
|
||||
if (currentQuarter < 4) {
|
||||
currentQuarter++;
|
||||
duration = const Duration(minutes: 10);
|
||||
myFouls = 0;
|
||||
opponentFouls = 0;
|
||||
myTimeoutsUsed = 0;
|
||||
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));
|
||||
}
|
||||
durationNotifier.value = const Duration(minutes: 10);
|
||||
myFouls = 0; opponentFouls = 0;
|
||||
myTimeoutsUsed = 0; opponentTimeoutsUsed = 0;
|
||||
_saveLocalBackup(); // Grava mudança de período
|
||||
}
|
||||
notifyListeners(); // Aqui sim, redesenhamos o ecrã para mudar o Quarto
|
||||
}
|
||||
onUpdate();
|
||||
});
|
||||
}
|
||||
isRunning = !isRunning;
|
||||
onUpdate();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void useTimeout(bool isOpponent) {
|
||||
@@ -242,14 +299,14 @@ class PlacarController {
|
||||
}
|
||||
isRunning = false;
|
||||
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) {
|
||||
String name = playerData.replaceAll("player_my_", "").replaceAll("player_opp_", "");
|
||||
final stats = playerStats[name]!;
|
||||
String playerId = playerData.replaceAll("player_my_", "").replaceAll("player_opp_", "");
|
||||
final stats = playerStats[playerId]!;
|
||||
final name = playerNames[playerId]!;
|
||||
|
||||
if (stats["fls"]! >= 5 && action != "sub_foul") {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('🛑 $name atingiu 5 faltas e está expulso!'), backgroundColor: Colors.red));
|
||||
@@ -258,91 +315,61 @@ class PlacarController {
|
||||
|
||||
if (action == "add_pts_2" || action == "add_pts_3" || action == "miss_2" || action == "miss_3") {
|
||||
pendingAction = action;
|
||||
pendingPlayer = playerData;
|
||||
pendingPlayerId = playerData;
|
||||
isSelectingShotLocation = true;
|
||||
} else {
|
||||
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) {
|
||||
String benchPlayer = action.replaceAll("bench_my_", "");
|
||||
if (playerStats[benchPlayer]!["fls"]! >= 5) return;
|
||||
int courtIndex = myCourt.indexOf(courtPlayerName);
|
||||
int benchIndex = myBench.indexOf(benchPlayer);
|
||||
myCourt[courtIndex] = benchPlayer;
|
||||
myBench[benchIndex] = courtPlayerName;
|
||||
String benchPlayerId = action.replaceAll("bench_my_", "");
|
||||
if (playerStats[benchPlayerId]!["fls"]! >= 5) return;
|
||||
int courtIndex = myCourt.indexOf(courtPlayerId);
|
||||
int benchIndex = myBench.indexOf(benchPlayerId);
|
||||
myCourt[courtIndex] = benchPlayerId;
|
||||
myBench[benchIndex] = courtPlayerId;
|
||||
showMyBench = false;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Sai $courtPlayerName, Entra $benchPlayer')));
|
||||
}
|
||||
if (action.startsWith("bench_opp_") && isOpponent) {
|
||||
String benchPlayer = action.replaceAll("bench_opp_", "");
|
||||
if (playerStats[benchPlayer]!["fls"]! >= 5) return;
|
||||
int courtIndex = oppCourt.indexOf(courtPlayerName);
|
||||
int benchIndex = oppBench.indexOf(benchPlayer);
|
||||
oppCourt[courtIndex] = benchPlayer;
|
||||
oppBench[benchIndex] = courtPlayerName;
|
||||
String benchPlayerId = action.replaceAll("bench_opp_", "");
|
||||
if (playerStats[benchPlayerId]!["fls"]! >= 5) return;
|
||||
int courtIndex = oppCourt.indexOf(courtPlayerId);
|
||||
int benchIndex = oppBench.indexOf(benchPlayerId);
|
||||
oppCourt[courtIndex] = benchPlayerId;
|
||||
oppBench[benchIndex] = courtPlayerId;
|
||||
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) {
|
||||
// 💡 AVISO AMIGÁVEL REMOVIDO. Agora podes marcar pontos mesmo com o tempo parado!
|
||||
|
||||
String name = targetPlayer.replaceAll("player_my_", "").replaceAll("player_opp_", "");
|
||||
String playerId = targetPlayer.replaceAll("player_my_", "").replaceAll("player_opp_", "");
|
||||
bool isMyTeam = targetPlayer.startsWith("player_my_");
|
||||
bool isMake = action.startsWith("add_");
|
||||
String name = playerNames[playerId]!;
|
||||
|
||||
// 1. ATUALIZA A ESTATÍSTICA DO JOGADOR
|
||||
if (playerStats.containsKey(name)) {
|
||||
playerStats[name]!['fga'] = playerStats[name]!['fga']! + 1;
|
||||
if (playerStats.containsKey(playerId)) {
|
||||
playerStats[playerId]!['fga'] = playerStats[playerId]!['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;
|
||||
}
|
||||
playerStats[playerId]!['fgm'] = playerStats[playerId]!['fgm']! + 1;
|
||||
playerStats[playerId]!['pts'] = playerStats[playerId]!['pts']! + points;
|
||||
if (isMyTeam) myScore += points; else opponentScore += points;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. CRIA A BOLINHA PARA APARECER NO CAMPO
|
||||
matchShots.add(ShotRecord(
|
||||
relativeX: relativeX,
|
||||
relativeY: relativeY,
|
||||
isMake: isMake,
|
||||
playerName: name,
|
||||
zone: zone,
|
||||
points: points,
|
||||
));
|
||||
matchShots.add(ShotRecord(relativeX: relativeX, relativeY: relativeY, isMake: isMake, playerId: playerId, playerName: name, zone: zone, points: points));
|
||||
|
||||
// 4. MANDA UMA MENSAGEM NO ECRÃ
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
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();
|
||||
_saveLocalBackup(); // 👈 Grava logo para não perder o cesto!
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// MANTIDO PARA CASO USES A MARCAÇÃO CLÁSSICA DIRETAMENTE NO CAMPO ESCURO
|
||||
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 is2Pt = pendingAction!.contains("_2");
|
||||
@@ -355,21 +382,15 @@ class PlacarController {
|
||||
bool isMake = pendingAction!.startsWith("add_pts_");
|
||||
double relX = position.dx / size.width;
|
||||
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(
|
||||
relativeX: relX,
|
||||
relativeY: relY,
|
||||
isMake: isMake,
|
||||
playerName: name
|
||||
));
|
||||
matchShots.add(ShotRecord(relativeX: relX, relativeY: relY, isMake: isMake, playerId: pId, playerName: playerNames[pId]!));
|
||||
|
||||
commitStat(pendingAction!, pendingPlayer!);
|
||||
commitStat(pendingAction!, pendingPlayerId!);
|
||||
|
||||
isSelectingShotLocation = false;
|
||||
pendingAction = null;
|
||||
pendingPlayer = null;
|
||||
onUpdate();
|
||||
isSelectingShotLocation = false; pendingAction = null; pendingPlayerId = null;
|
||||
_saveLocalBackup(); // 👈 Grava logo
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool _validateShotZone(Offset position, Size size, bool is3Pt) {
|
||||
@@ -400,13 +421,13 @@ class PlacarController {
|
||||
}
|
||||
|
||||
void cancelShotLocation() {
|
||||
isSelectingShotLocation = false; pendingAction = null; pendingPlayer = null; onUpdate();
|
||||
isSelectingShotLocation = false; pendingAction = null; pendingPlayerId = null; notifyListeners();
|
||||
}
|
||||
|
||||
void commitStat(String action, String playerData) {
|
||||
bool isOpponent = playerData.startsWith("player_opp_");
|
||||
String name = playerData.replaceAll("player_my_", "").replaceAll("player_opp_", "");
|
||||
final stats = playerStats[name]!;
|
||||
String playerId = playerData.replaceAll("player_my_", "").replaceAll("player_opp_", "");
|
||||
final stats = playerStats[playerId]!;
|
||||
|
||||
if (action.startsWith("add_pts_")) {
|
||||
int pts = int.parse(action.split("_").last);
|
||||
@@ -445,15 +466,16 @@ class PlacarController {
|
||||
if (stats["fls"]! > 0) stats["fls"] = stats["fls"]! - 1;
|
||||
if (isOpponent) { if (opponentFouls > 0) opponentFouls--; } else { if (myFouls > 0) myFouls--; }
|
||||
}
|
||||
_saveLocalBackup(); // 👈 Grava na memória!
|
||||
}
|
||||
|
||||
Future<void> saveGameStats(BuildContext context) async {
|
||||
final supabase = Supabase.instance.client;
|
||||
isSaving = true;
|
||||
onUpdate();
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
bool isGameFinishedNow = currentQuarter >= 4 && duration.inSeconds == 0;
|
||||
bool isGameFinishedNow = currentQuarter >= 4 && durationNotifier.value.inSeconds == 0;
|
||||
String newStatus = isGameFinishedNow ? 'Terminado' : 'Pausado';
|
||||
|
||||
String topPtsName = '---'; int maxPts = -1;
|
||||
@@ -462,7 +484,7 @@ class PlacarController {
|
||||
String topDefName = '---'; int maxDef = -1;
|
||||
String mvpName = '---'; int maxMvpScore = -1;
|
||||
|
||||
playerStats.forEach((playerName, stats) {
|
||||
playerStats.forEach((playerId, stats) {
|
||||
int pts = stats['pts'] ?? 0;
|
||||
int ast = stats['ast'] ?? 0;
|
||||
int rbs = stats['rbs'] ?? 0;
|
||||
@@ -471,18 +493,20 @@ class PlacarController {
|
||||
|
||||
int defScore = stl + blk;
|
||||
int mvpScore = pts + ast + rbs + defScore;
|
||||
|
||||
String pName = playerNames[playerId] ?? '---';
|
||||
|
||||
if (pts > maxPts && pts > 0) { maxPts = pts; topPtsName = '$playerName ($pts)'; }
|
||||
if (ast > maxAst && ast > 0) { maxAst = ast; topAstName = '$playerName ($ast)'; }
|
||||
if (rbs > maxRbs && rbs > 0) { maxRbs = rbs; topRbsName = '$playerName ($rbs)'; }
|
||||
if (defScore > maxDef && defScore > 0) { maxDef = defScore; topDefName = '$playerName ($defScore)'; }
|
||||
if (mvpScore > maxMvpScore && mvpScore > 0) { maxMvpScore = mvpScore; mvpName = playerName; }
|
||||
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 (defScore > maxDef && defScore > 0) { maxDef = defScore; topDefName = '$pName ($defScore)'; }
|
||||
if (mvpScore > maxMvpScore && mvpScore > 0) { maxMvpScore = mvpScore; mvpName = pName; }
|
||||
});
|
||||
|
||||
await supabase.from('games').update({
|
||||
'my_score': myScore,
|
||||
'opponent_score': opponentScore,
|
||||
'remaining_seconds': duration.inSeconds,
|
||||
'remaining_seconds': durationNotifier.value.inSeconds,
|
||||
'my_timeouts': myTimeoutsUsed,
|
||||
'opp_timeouts': opponentTimeoutsUsed,
|
||||
'current_quarter': currentQuarter,
|
||||
@@ -495,9 +519,7 @@ class PlacarController {
|
||||
}).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 = {};
|
||||
|
||||
@@ -507,84 +529,65 @@ class PlacarController {
|
||||
}
|
||||
|
||||
if (myScore > opponentScore) {
|
||||
myTeamUpdate['wins'] = (myTeamUpdate['wins'] ?? 0) + 1;
|
||||
oppTeamUpdate['losses'] = (oppTeamUpdate['losses'] ?? 0) + 1;
|
||||
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;
|
||||
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;
|
||||
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!);
|
||||
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 = [];
|
||||
playerStats.forEach((playerName, stats) {
|
||||
String? memberDbId = playerDbIds[playerName];
|
||||
if (memberDbId != null && stats.values.any((val) => val > 0)) {
|
||||
bool isMyTeamPlayer = myCourt.contains(playerName) || myBench.contains(playerName);
|
||||
playerStats.forEach((playerId, stats) {
|
||||
if (!playerId.startsWith("fake_") && stats.values.any((val) => val > 0)) {
|
||||
bool isMyTeamPlayer = myCourt.contains(playerId) || myBench.contains(playerId);
|
||||
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'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await supabase.from('player_stats').delete().eq('game_id', gameId);
|
||||
if (batchStats.isNotEmpty) {
|
||||
await supabase.from('player_stats').insert(batchStats);
|
||||
}
|
||||
if (batchStats.isNotEmpty) await supabase.from('player_stats').insert(batchStats);
|
||||
|
||||
// 👇 6. GUARDA AS BOLINHAS (MAPA DE CALOR) NO SUPABASE 👇
|
||||
List<Map<String, dynamic>> batchShots = [];
|
||||
for (var shot in matchShots) {
|
||||
String? memberDbId = playerDbIds[shot.playerName];
|
||||
if (memberDbId != null) {
|
||||
if (!shot.playerId.startsWith("fake_")) {
|
||||
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),
|
||||
'game_id': gameId, 'member_id': shot.playerId, '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!
|
||||
await supabase.from('shot_locations').delete().eq('game_id', gameId);
|
||||
if (batchShots.isNotEmpty) {
|
||||
await supabase.from('shot_locations').insert(batchShots);
|
||||
}
|
||||
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));
|
||||
}
|
||||
// 👇 SE O SUPABASE GUARDOU COM SUCESSO, LIMPA A MEMÓRIA DO TELEMÓVEL! 👇
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('backup_$gameId');
|
||||
|
||||
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) {
|
||||
debugPrint("Erro ao gravar estatísticas: $e");
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Erro ao guardar: $e'), backgroundColor: Colors.red));
|
||||
}
|
||||
if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Erro ao guardar: $e'), backgroundColor: Colors.red));
|
||||
} finally {
|
||||
isSaving = false;
|
||||
onUpdate();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}*/
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:io';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
class TeamController {
|
||||
// Instância do cliente Supabase
|
||||
final _supabase = Supabase.instance.client;
|
||||
|
||||
// 1. STREAM (Realtime)
|
||||
@@ -13,18 +13,39 @@ class TeamController {
|
||||
.map((data) => List<Map<String, dynamic>>.from(data));
|
||||
}
|
||||
|
||||
// 2. CRIAR
|
||||
Future<void> createTeam(String name, String season, String? imageUrl) async {
|
||||
// 2. CRIAR (Agora aceita um File e faz o Upload!)
|
||||
Future<void> createTeam(String name, String season, File? imageFile) async {
|
||||
try {
|
||||
String? uploadedImageUrl;
|
||||
|
||||
// Se o utilizador escolheu uma imagem, fazemos o upload primeiro
|
||||
if (imageFile != null) {
|
||||
// Criar um nome único para o ficheiro (ex: id do user + timestamp)
|
||||
final userId = _supabase.auth.currentUser?.id ?? 'default';
|
||||
final fileName = '${userId}_${DateTime.now().millisecondsSinceEpoch}.png';
|
||||
final storagePath = 'teams/$fileName';
|
||||
|
||||
// Faz o upload para o bucket 'avatars' (podes usar o mesmo ou criar um chamado 'teams_logos')
|
||||
await _supabase.storage.from('avatars').upload(
|
||||
storagePath,
|
||||
imageFile,
|
||||
fileOptions: const FileOptions(cacheControl: '3600', upsert: true)
|
||||
);
|
||||
|
||||
// Vai buscar o URL público
|
||||
uploadedImageUrl = _supabase.storage.from('avatars').getPublicUrl(storagePath);
|
||||
}
|
||||
|
||||
// Agora insere a equipa na base de dados (com ou sem URL)
|
||||
await _supabase.from('teams').insert({
|
||||
'name': name,
|
||||
'season': season,
|
||||
'image_url': imageUrl,
|
||||
'image_url': uploadedImageUrl ?? '', // Se não houver foto, guarda vazio
|
||||
'is_favorite': false,
|
||||
});
|
||||
print("✅ Equipa guardada no Supabase!");
|
||||
} catch (e) {
|
||||
print("❌ Erro ao criar: $e");
|
||||
print("❌ Erro ao criar equipa: $e");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +63,7 @@ class TeamController {
|
||||
try {
|
||||
await _supabase
|
||||
.from('teams')
|
||||
.update({'is_favorite': !currentStatus}) // Inverte o valor
|
||||
.update({'is_favorite': !currentStatus})
|
||||
.eq('id', teamId);
|
||||
} catch (e) {
|
||||
print("❌ Erro ao favoritar: $e");
|
||||
@@ -52,28 +73,20 @@ class TeamController {
|
||||
// 5. CONTAR JOGADORES (LEITURA ÚNICA)
|
||||
Future<int> getPlayerCount(String teamId) async {
|
||||
try {
|
||||
final count = await _supabase
|
||||
.from('members')
|
||||
.count()
|
||||
.eq('team_id', teamId);
|
||||
final count = await _supabase.from('members').count().eq('team_id', teamId);
|
||||
return count;
|
||||
} catch (e) {
|
||||
print("Erro ao contar jogadores: $e");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 👇 6. A FUNÇÃO QUE RESOLVE O ERRO (EM TEMPO REAL) 👇
|
||||
Stream<int> getPlayerCountStream(String teamId) {
|
||||
return _supabase
|
||||
.from('members')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('team_id', teamId)
|
||||
.map((membros) => membros
|
||||
.where((membro) => membro['type'] == 'Jogador')
|
||||
.length);
|
||||
// 6. CONTAR JOGADORES (STREAM EM TEMPO REAL)
|
||||
Future<List<Map<String, dynamic>>> getTeamsWithStats() async {
|
||||
final data = await _supabase
|
||||
.from('teams_with_stats') // Lemos da View que criámos!
|
||||
.select('*')
|
||||
.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() {}
|
||||
}
|
||||
@@ -1,38 +1,71 @@
|
||||
class Game {
|
||||
final String id;
|
||||
final String userId;
|
||||
final String myTeam;
|
||||
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 status;
|
||||
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({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.myTeam,
|
||||
required this.opponentTeam,
|
||||
this.myTeamLogo,
|
||||
this.opponentTeamLogo,
|
||||
required this.myScore,
|
||||
required this.opponentScore,
|
||||
required this.status,
|
||||
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)
|
||||
factory Game.fromMap(Map<String, dynamic> map) {
|
||||
// 👇 A MÁGICA ACONTECE AQUI: Lemos os dados e protegemos os NULLs
|
||||
factory Game.fromMap(Map<String, dynamic> json) {
|
||||
return Game(
|
||||
id: map['id'],
|
||||
myTeam: map['my_team_name'],
|
||||
opponentTeam: map['opponent_team_name'],
|
||||
myTeamLogo: map['my_team_logo'], // Certifique-se que o Supabase retorna isto
|
||||
opponentTeamLogo: map['opponent_team_logo'],
|
||||
myScore: map['my_score'].toString(),
|
||||
opponentScore: map['opponent_score'].toString(),
|
||||
status: map['status'],
|
||||
season: map['season'],
|
||||
id: json['id']?.toString() ?? '',
|
||||
userId: json['user_id']?.toString() ?? '',
|
||||
myTeam: json['my_team']?.toString() ?? 'Minha Equipa',
|
||||
opponentTeam: json['opponent_team']?.toString() ?? 'Adversário',
|
||||
myScore: (json['my_score'] ?? 0).toString(), // Protege NULL e converte Int4 para String
|
||||
opponentScore: (json['opponent_score'] ?? 0).toString(),
|
||||
season: json['season']?.toString() ?? '---',
|
||||
status: json['status']?.toString() ?? 'Decorrer',
|
||||
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() ?? '---',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,24 +3,43 @@ class Person {
|
||||
final String teamId;
|
||||
final String name;
|
||||
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({
|
||||
required this.id,
|
||||
required this.teamId,
|
||||
required this.name,
|
||||
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) {
|
||||
return Person(
|
||||
id: map['id'] ?? '',
|
||||
teamId: map['team_id'] ?? '',
|
||||
name: map['name'] ?? '',
|
||||
type: map['type'] ?? 'Jogador',
|
||||
number: map['number']?.toString() ?? '',
|
||||
id: map['id']?.toString() ?? '',
|
||||
teamId: map['team_id']?.toString() ?? '',
|
||||
name: map['name']?.toString() ?? 'Desconhecido',
|
||||
type: map['type']?.toString() ?? 'Jogador',
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,26 +4,33 @@ class Team {
|
||||
final String season;
|
||||
final String imageUrl;
|
||||
final bool isFavorite;
|
||||
final String createdAt;
|
||||
final int playerCount; // 👇 NOVA VARIÁVEL AQUI
|
||||
|
||||
Team({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.season,
|
||||
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) {
|
||||
return Team(
|
||||
id: map['id']?.toString() ?? '',
|
||||
name: map['name'] ?? '',
|
||||
season: map['season'] ?? '',
|
||||
imageUrl: map['image_url'] ?? '',
|
||||
name: map['name']?.toString() ?? 'Sem Nome',
|
||||
season: map['season']?.toString() ?? '',
|
||||
imageUrl: map['image_url']?.toString() ?? '',
|
||||
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() {
|
||||
return {
|
||||
'name': name,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,6 @@ import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:playmaker/pages/status_page.dart';
|
||||
import '../utils/size_extension.dart';
|
||||
import 'settings_screen.dart';
|
||||
// 👇 Importa o ficheiro onde meteste o StatCard e o SportGrid
|
||||
// import 'home_widgets.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
@@ -29,6 +28,37 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
int _teamDraws = 0;
|
||||
|
||||
final _supabase = Supabase.instance.client;
|
||||
|
||||
// 👇 NOVA VARIÁVEL PARA GUARDAR A FOTO
|
||||
String? _avatarUrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadUserAvatar(); // Vai buscar a foto logo quando a Home abre!
|
||||
}
|
||||
|
||||
// 👇 FUNÇÃO PARA LER A FOTO DA BASE DE DADOS
|
||||
Future<void> _loadUserAvatar() async {
|
||||
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) {
|
||||
setState(() {
|
||||
_avatarUrl = data['avatar_url'];
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
print("Erro ao carregar avatar na Home: $e");
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -45,14 +75,31 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
title: Text('PlayMaker', style: TextStyle(fontSize: 20 * context.sf)),
|
||||
backgroundColor: AppTheme.primaryRed,
|
||||
foregroundColor: Colors.white,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.person, size: 24 * context.sf),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||
);
|
||||
},
|
||||
|
||||
// 👇 AQUI ESTÁ A MÁGICA DA TUA FOTO NA APPBAR 👇
|
||||
leading: Padding(
|
||||
padding: EdgeInsets.all(10.0 * context.sf), // Dá um espacinho para não colar aos bordos
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
onTap: () async {
|
||||
// O 'await' faz com que a Home espere que tu feches os settings...
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||
);
|
||||
// ... e quando voltas, ele recarrega a foto logo!
|
||||
_loadUserAvatar();
|
||||
},
|
||||
child: CircleAvatar(
|
||||
backgroundColor: Colors.white.withOpacity(0.2), // Fundo suave caso não haja foto
|
||||
backgroundImage: _avatarUrl != null && _avatarUrl!.isNotEmpty
|
||||
? NetworkImage(_avatarUrl!)
|
||||
: null,
|
||||
child: _avatarUrl == null || _avatarUrl!.isEmpty
|
||||
? Icon(Icons.person, color: Colors.white, size: 20 * context.sf)
|
||||
: null, // Só mostra o ícone se não houver foto
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -196,7 +243,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
Text('Histórico de Jogos', style: TextStyle(fontSize: 20 * context.sf, fontWeight: FontWeight.bold, color: textColor)),
|
||||
SizedBox(height: 16 * context.sf),
|
||||
|
||||
// 👇 AQUI ESTÁ O NOVO CARTÃO VAZIO PARA QUANDO NÃO HÁ EQUIPA 👇
|
||||
_selectedTeamName == "Selecionar Equipa"
|
||||
? Container(
|
||||
width: double.infinity,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:playmaker/classe/theme.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import '../utils/size_extension.dart';
|
||||
import 'login.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
// 👇 OBRIGATÓRIO IMPORTAR O MAIN.DART PARA LER A VARIÁVEL "themeNotifier"
|
||||
import '../main.dart';
|
||||
import '../utils/size_extension.dart';
|
||||
import 'login.dart'; // 👇 Necessário para o redirecionamento do logout
|
||||
import '../main.dart'; // 👇 OBRIGATÓRIO PARA LER A VARIÁVEL "themeNotifier"
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
@@ -16,16 +18,116 @@ class SettingsScreen extends StatefulWidget {
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
// 👇 VARIÁVEIS DE ESTADO PARA FOTO DE PERFIL
|
||||
File? _localImageFile;
|
||||
String? _uploadedImageUrl;
|
||||
bool _isUploadingImage = false;
|
||||
|
||||
final supabase = Supabase.instance.client;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadUserAvatar();
|
||||
}
|
||||
|
||||
// 👇 LÊ A IMAGEM ATUAL DA BASE DE DADOS (Tabela 'profiles')
|
||||
void _loadUserAvatar() async {
|
||||
final userId = supabase.auth.currentUser?.id;
|
||||
if (userId == null) return;
|
||||
|
||||
try {
|
||||
// ⚠️ NOTA: Ajusta 'profiles' e 'avatar_url' se os nomes na tua BD forem diferentes!
|
||||
final data = await supabase
|
||||
.from('profiles')
|
||||
.select('avatar_url')
|
||||
.eq('id', userId)
|
||||
.maybeSingle(); // maybeSingle evita erro se o perfil ainda não existir
|
||||
|
||||
if (mounted && data != null && data['avatar_url'] != null) {
|
||||
setState(() {
|
||||
_uploadedImageUrl = data['avatar_url'];
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
print("Erro ao carregar avatar: $e");
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 👇 A MÁGICA DE ESCOLHER E FAZER UPLOAD DA FOTO 👇
|
||||
// =========================================================================
|
||||
Future<void> _handleImageChange() async {
|
||||
final ImagePicker picker = ImagePicker();
|
||||
|
||||
// 1. ABRIR GALERIA
|
||||
final XFile? pickedFile = await picker.pickImage(source: ImageSource.gallery);
|
||||
if (pickedFile == null || !mounted) return;
|
||||
|
||||
try {
|
||||
// 2. MOSTRAR IMAGEM LOCAL E ATIVAR LOADING
|
||||
setState(() {
|
||||
_localImageFile = File(pickedFile.path);
|
||||
_isUploadingImage = true;
|
||||
});
|
||||
|
||||
final userId = supabase.auth.currentUser?.id;
|
||||
if (userId == null) throw Exception("Utilizador não autenticado.");
|
||||
|
||||
final String storagePath = '$userId/profile_picture.png';
|
||||
|
||||
// 3. FAZER UPLOAD (Método direto e seguro!)
|
||||
await supabase.storage.from('avatars').upload(
|
||||
storagePath,
|
||||
_localImageFile!, // Envia o ficheiro File diretamente!
|
||||
fileOptions: const FileOptions(cacheControl: '3600', upsert: true)
|
||||
);
|
||||
|
||||
// 4. OBTER URL PÚBLICO
|
||||
final String publicUrl = supabase.storage.from('avatars').getPublicUrl(storagePath);
|
||||
|
||||
// 5. ATUALIZAR NA BASE DE DADOS
|
||||
// ⚠️ NOTA: Garante que a tabela 'profiles' existe e tem o teu user_id
|
||||
await supabase
|
||||
.from('profiles')
|
||||
.upsert({
|
||||
'id': userId, // Garante que atualiza o perfil certo ou cria um novo
|
||||
'avatar_url': publicUrl
|
||||
});
|
||||
|
||||
// 6. SUCESSO!
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
// 👇 CORES DINÂMICAS (A MÁGICA DO MODO ESCURO)
|
||||
final Color primaryRed = AppTheme.primaryRed;
|
||||
final Color bgColor = Theme.of(context).scaffoldBackgroundColor;
|
||||
final Color cardColor = Theme.of(context).cardTheme.color ?? Theme.of(context).colorScheme.surface;
|
||||
final Color textColor = Theme.of(context).colorScheme.onSurface;
|
||||
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;
|
||||
|
||||
return Scaffold(
|
||||
@@ -37,10 +139,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
centerTitle: true,
|
||||
title: Text(
|
||||
"Perfil e Definições",
|
||||
style: TextStyle(
|
||||
fontSize: 18 * context.sf,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.w600),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
@@ -62,20 +161,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
borderRadius: BorderRadius.circular(16 * context.sf),
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.1)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.04),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 10, offset: const Offset(0, 4)),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 32 * context.sf,
|
||||
backgroundColor: primaryRed.withOpacity(0.1),
|
||||
child: Icon(Icons.person, color: primaryRed, size: 32 * context.sf),
|
||||
),
|
||||
// 👇 IMAGEM TAPPABLE AQUI 👇
|
||||
_buildTappableProfileAvatar(context, primaryRed),
|
||||
SizedBox(width: 16 * context.sf),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -83,19 +175,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
children: [
|
||||
Text(
|
||||
"Treinador",
|
||||
style: TextStyle(
|
||||
fontSize: 18 * context.sf,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: textColor,
|
||||
),
|
||||
style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold, color: textColor),
|
||||
),
|
||||
SizedBox(height: 4 * context.sf),
|
||||
Text(
|
||||
Supabase.instance.client.auth.currentUser?.email ?? "sem@email.com",
|
||||
style: TextStyle(
|
||||
color: textLightColor,
|
||||
fontSize: 14 * context.sf,
|
||||
),
|
||||
supabase.auth.currentUser?.email ?? "sem@email.com",
|
||||
style: TextStyle(color: textLightColor, fontSize: 14 * context.sf),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -113,11 +198,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
padding: EdgeInsets.only(left: 4 * context.sf, bottom: 12 * context.sf),
|
||||
child: Text(
|
||||
"Definições",
|
||||
style: TextStyle(
|
||||
color: textLightColor,
|
||||
fontSize: 14 * context.sf,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: TextStyle(color: textLightColor, fontSize: 14 * context.sf, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
@@ -126,11 +207,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
borderRadius: BorderRadius.circular(16 * context.sf),
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.1)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.04),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 10, offset: const Offset(0, 4)),
|
||||
],
|
||||
),
|
||||
child: ListTile(
|
||||
@@ -148,7 +225,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
value: isDark,
|
||||
activeColor: primaryRed,
|
||||
onChanged: (bool value) {
|
||||
// 👇 CHAMA A VARIÁVEL DO MAIN.DART E ATUALIZA A APP TODA
|
||||
themeNotifier.value = value ? ThemeMode.dark : ThemeMode.light;
|
||||
},
|
||||
),
|
||||
@@ -164,11 +240,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
padding: EdgeInsets.only(left: 4 * context.sf, bottom: 12 * context.sf),
|
||||
child: Text(
|
||||
"Conta",
|
||||
style: TextStyle(
|
||||
color: textLightColor,
|
||||
fontSize: 14 * context.sf,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: TextStyle(color: textLightColor, fontSize: 14 * context.sf, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
@@ -177,11 +249,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
borderRadius: BorderRadius.circular(16 * context.sf),
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.1)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.04),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 10, offset: const Offset(0, 4)),
|
||||
],
|
||||
),
|
||||
child: ListTile(
|
||||
@@ -189,13 +257,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
leading: Icon(Icons.logout_outlined, color: primaryRed, size: 26 * context.sf),
|
||||
title: Text(
|
||||
"Terminar Sessão",
|
||||
style: TextStyle(
|
||||
color: primaryRed,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15 * context.sf,
|
||||
),
|
||||
style: TextStyle(color: primaryRed, fontWeight: FontWeight.bold, fontSize: 15 * context.sf),
|
||||
),
|
||||
onTap: () => _confirmLogout(context), // 👇 CHAMA O LOGOUT REAL
|
||||
onTap: () => _confirmLogout(context),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -207,10 +271,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Center(
|
||||
child: Text(
|
||||
"PlayMaker v1.0.0",
|
||||
style: TextStyle(
|
||||
color: textLightColor.withOpacity(0.7),
|
||||
fontSize: 13 * context.sf,
|
||||
),
|
||||
style: TextStyle(color: textLightColor.withOpacity(0.7), fontSize: 13 * context.sf),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20 * context.sf),
|
||||
@@ -220,28 +281,83 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// 👇 FUNÇÃO PARA FAZER LOGOUT
|
||||
// 👇 O WIDGET DA FOTO DE PERFIL (Protegido com GestureDetector)
|
||||
Widget _buildTappableProfileAvatar(BuildContext context, Color primaryRed) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
print("CLIQUEI NA FOTO! A abrir galeria..."); // 👇 Vê na consola se isto aparece
|
||||
_handleImageChange();
|
||||
},
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 36 * context.sf,
|
||||
backgroundColor: primaryRed.withOpacity(0.1),
|
||||
backgroundImage: _isUploadingImage && _localImageFile != null
|
||||
? FileImage(_localImageFile!)
|
||||
: (_uploadedImageUrl != null && _uploadedImageUrl!.isNotEmpty
|
||||
? NetworkImage(_uploadedImageUrl!)
|
||||
: null),
|
||||
child: (_uploadedImageUrl == null && !(_isUploadingImage && _localImageFile != null))
|
||||
? Icon(Icons.person, color: primaryRed, size: 36 * context.sf)
|
||||
: null,
|
||||
),
|
||||
|
||||
// Í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
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 👇 FUNÇÃO DE LOGOUT
|
||||
void _confirmLogout(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
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)),
|
||||
actions: [
|
||||
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 {
|
||||
await Supabase.instance.client.auth.signOut();
|
||||
if (ctx.mounted) {
|
||||
// Mata a navegação toda para trás e manda para o Login
|
||||
Navigator.of(ctx).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (context) => const LoginPage()),
|
||||
(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))
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:image_cropper/image_cropper.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 '../models/team_model.dart';
|
||||
import '../utils/size_extension.dart';
|
||||
@@ -162,16 +165,17 @@ class _TeamsPageState extends State<TeamsPage> {
|
||||
hintStyle: TextStyle(fontSize: 16 * context.sf, color: Colors.grey),
|
||||
prefixIcon: Icon(Icons.search, color: AppTheme.primaryRed, size: 22 * context.sf),
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 👇 AGORA USA FUTUREBUILDER E É MUITO MAIS RÁPIDO 👇
|
||||
Widget _buildTeamsList() {
|
||||
return StreamBuilder<List<Map<String, dynamic>>>(
|
||||
stream: controller.teamsStream,
|
||||
return FutureBuilder<List<Map<String, dynamic>>>(
|
||||
future: controller.getTeamsWithStats(),
|
||||
builder: (context, snapshot) {
|
||||
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)));
|
||||
@@ -190,28 +194,45 @@ class _TeamsPageState extends State<TeamsPage> {
|
||||
else return (b['created_at'] ?? '').toString().compareTo((a['created_at'] ?? '').toString());
|
||||
});
|
||||
|
||||
return ListView.builder(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16 * context.sf),
|
||||
itemCount: data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final team = Team.fromMap(data[index]);
|
||||
return GestureDetector(
|
||||
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (context) => TeamStatsPage(team: team))),
|
||||
child: TeamCard(
|
||||
team: team,
|
||||
controller: controller,
|
||||
onFavoriteTap: () => controller.toggleFavorite(team.id, team.isFavorite),
|
||||
sf: context.sf,
|
||||
),
|
||||
);
|
||||
},
|
||||
return RefreshIndicator(
|
||||
color: AppTheme.primaryRed,
|
||||
onRefresh: () async => setState(() {}), // Puxa para baixo para recarregar
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16 * context.sf),
|
||||
itemCount: data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final team = Team.fromMap(data[index]);
|
||||
return GestureDetector(
|
||||
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (context) => TeamStatsPage(team: team))).then((_) => setState(() {})),
|
||||
child: TeamCard(
|
||||
team: team,
|
||||
controller: controller,
|
||||
onFavoriteTap: () async {
|
||||
await controller.toggleFavorite(team.id, team.isFavorite);
|
||||
setState(() {}); // Atualiza a estrela na hora
|
||||
},
|
||||
onDelete: () => setState(() {}), // Atualiza a lista quando apaga
|
||||
sf: context.sf,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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(() {}); // 👇 Atualiza a lista quando acaba de criar a equipa!
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +241,7 @@ class TeamCard extends StatelessWidget {
|
||||
final Team team;
|
||||
final TeamController controller;
|
||||
final VoidCallback onFavoriteTap;
|
||||
final VoidCallback onDelete; // 👇 Avisa o pai quando é apagado
|
||||
final double sf;
|
||||
|
||||
const TeamCard({
|
||||
@@ -227,6 +249,7 @@ class TeamCard extends StatelessWidget {
|
||||
required this.team,
|
||||
required this.controller,
|
||||
required this.onFavoriteTap,
|
||||
required this.onDelete,
|
||||
required this.sf,
|
||||
});
|
||||
|
||||
@@ -259,7 +282,7 @@ class TeamCard extends StatelessWidget {
|
||||
: null,
|
||||
child: (team.imageUrl.isEmpty || !team.imageUrl.startsWith('http'))
|
||||
? Text(
|
||||
team.imageUrl.isEmpty ? "🏀" : team.imageUrl,
|
||||
team.imageUrl.isEmpty ? "🏀" : team.imageUrl,
|
||||
style: TextStyle(fontSize: 24 * sf),
|
||||
)
|
||||
: null,
|
||||
@@ -272,9 +295,7 @@ class TeamCard extends StatelessWidget {
|
||||
team.isFavorite ? Icons.star : Icons.star_border,
|
||||
color: team.isFavorite ? AppTheme.warningAmber : Theme.of(context).colorScheme.onSurface.withOpacity(0.2),
|
||||
size: 28 * sf,
|
||||
shadows: [
|
||||
Shadow(color: Colors.black.withOpacity(team.isFavorite ? 0.3 : 0.1), blurRadius: 4 * sf),
|
||||
],
|
||||
shadows: [Shadow(color: Colors.black.withOpacity(team.isFavorite ? 0.3 : 0.1), blurRadius: 4 * sf)],
|
||||
),
|
||||
onPressed: onFavoriteTap,
|
||||
),
|
||||
@@ -292,21 +313,17 @@ class TeamCard extends StatelessWidget {
|
||||
children: [
|
||||
Icon(Icons.groups_outlined, size: 16 * sf, color: Colors.grey),
|
||||
SizedBox(width: 4 * sf),
|
||||
StreamBuilder<int>(
|
||||
stream: controller.getPlayerCountStream(team.id),
|
||||
initialData: 0,
|
||||
builder: (context, snapshot) {
|
||||
final count = snapshot.data ?? 0;
|
||||
return Text(
|
||||
"$count Jogs.",
|
||||
style: TextStyle(
|
||||
color: count > 0 ? AppTheme.successGreen : AppTheme.warningAmber, // 👇 Usando cores do tema
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13 * sf,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
// 👇 ESTATÍSTICA MUITO MAIS LEVE. LÊ O VALOR DIRETAMENTE! 👇
|
||||
Text(
|
||||
"${team.playerCount} Jogs.",
|
||||
style: TextStyle(
|
||||
color: team.playerCount > 0 ? AppTheme.successGreen : AppTheme.warningAmber,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13 * sf,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(width: 8 * sf),
|
||||
Expanded(
|
||||
child: Text("| ${team.season}", style: TextStyle(color: Colors.grey, fontSize: 13 * sf), overflow: TextOverflow.ellipsis),
|
||||
@@ -320,7 +337,7 @@ class TeamCard extends StatelessWidget {
|
||||
IconButton(
|
||||
tooltip: 'Ver Estatísticas',
|
||||
icon: Icon(Icons.bar_chart_rounded, color: Colors.blue, size: 24 * sf),
|
||||
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (context) => TeamStatsPage(team: team))),
|
||||
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (context) => TeamStatsPage(team: team))).then((_) => onDelete()), // Atualiza se algo mudou
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Eliminar Equipa',
|
||||
@@ -334,23 +351,30 @@ class TeamCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDelete(BuildContext context, double sf, Color cardColor, Color textColor) {
|
||||
void _confirmDelete(BuildContext context, double sf, Color cardColor, Color textColor) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: cardColor,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
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)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text('Cancelar', style: TextStyle(fontSize: 14 * sf, color: Colors.grey)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
controller.deleteTeam(team.id);
|
||||
Navigator.pop(context);
|
||||
// ⚡ 1. FECHA LOGO O POP-UP!
|
||||
Navigator.pop(ctx);
|
||||
// ⚡ 2. AVISA O PAI PARA ESCONDER A EQUIPA DO ECRÃ NA HORA!
|
||||
onDelete();
|
||||
|
||||
// 3. APAGA NO FUNDO (Sem o utilizador ficar à espera)
|
||||
controller.deleteTeam(team.id).catchError((e) {
|
||||
if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Erro ao eliminar: $e'), backgroundColor: Colors.red));
|
||||
});
|
||||
},
|
||||
child: Text('Eliminar', style: TextStyle(color: AppTheme.primaryRed, fontSize: 14 * sf)),
|
||||
),
|
||||
@@ -360,9 +384,9 @@ class TeamCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// --- DIALOG DE CRIAÇÃO ---
|
||||
// --- DIALOG DE CRIAÇÃO (COM CROPPER E ESCUDO) ---
|
||||
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;
|
||||
|
||||
const CreateTeamDialog({super.key, required this.onConfirm, required this.sf});
|
||||
@@ -373,8 +397,48 @@ class CreateTeamDialog extends StatefulWidget {
|
||||
|
||||
class _CreateTeamDialogState extends State<CreateTeamDialog> {
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
final TextEditingController _imageController = TextEditingController();
|
||||
String _selectedSeason = '2024/25';
|
||||
|
||||
File? _selectedImage;
|
||||
bool _isLoading = false;
|
||||
bool _isPickerActive = false; // 👇 ESCUDO ANTI-DUPLO-CLIQUE
|
||||
|
||||
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) {
|
||||
// 👇 USA O CROPPER QUE CONFIGURASTE PARA AS CARAS
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
@@ -386,6 +450,34 @@ class _CreateTeamDialogState extends State<CreateTeamDialog> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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),
|
||||
SizedBox(height: 15 * widget.sf),
|
||||
DropdownButtonFormField<String>(
|
||||
@@ -395,8 +487,6 @@ class _CreateTeamDialogState extends State<CreateTeamDialog> {
|
||||
items: ['2023/24', '2024/25', '2025/26'].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
|
||||
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 +494,16 @@ class _CreateTeamDialogState extends State<CreateTeamDialog> {
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancelar', style: TextStyle(fontSize: 14 * widget.sf, color: Colors.grey))),
|
||||
ElevatedButton(
|
||||
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); } },
|
||||
child: Text('Criar', style: TextStyle(color: Colors.white, fontSize: 14 * widget.sf)),
|
||||
onPressed: _isLoading ? null : () async {
|
||||
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)),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:image_cropper/image_cropper.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:playmaker/classe/theme.dart'; // 👇 IMPORT DO TEMA!
|
||||
import 'package:playmaker/classe/theme.dart';
|
||||
import '../models/team_model.dart';
|
||||
import '../models/person_model.dart';
|
||||
import '../utils/size_extension.dart'; // 👇 SUPERPODER SF
|
||||
import '../utils/size_extension.dart';
|
||||
|
||||
// --- CABEÇALHO ---
|
||||
// ==========================================
|
||||
// 1. CABEÇALHO (AGORA COM CACHE DE IMAGEM)
|
||||
// ==========================================
|
||||
class StatsHeader extends StatelessWidget {
|
||||
final Team team;
|
||||
final String? currentImageUrl;
|
||||
final VoidCallback onEditPhoto;
|
||||
final bool isUploading;
|
||||
|
||||
const StatsHeader({super.key, required this.team});
|
||||
const StatsHeader({
|
||||
super.key,
|
||||
required this.team,
|
||||
required this.currentImageUrl,
|
||||
required this.onEditPhoto,
|
||||
required this.isUploading,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(top: 50 * context.sf, left: 20 * context.sf, right: 20 * context.sf, bottom: 20 * context.sf),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryRed, // 👇 Usando a cor oficial
|
||||
color: AppTheme.primaryRed,
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(30 * context.sf),
|
||||
bottomRight: Radius.circular(30 * context.sf)
|
||||
@@ -26,23 +41,42 @@ class StatsHeader extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: Colors.white, size: 24 * context.sf),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: Icon(Icons.arrow_back, color: Colors.white, size: 24 * context.sf),
|
||||
onPressed: () => Navigator.pop(context)
|
||||
),
|
||||
SizedBox(width: 10 * context.sf),
|
||||
|
||||
CircleAvatar(
|
||||
radius: 24 * context.sf,
|
||||
backgroundColor: Colors.white24,
|
||||
backgroundImage: (team.imageUrl.isNotEmpty && team.imageUrl.startsWith('http'))
|
||||
? NetworkImage(team.imageUrl)
|
||||
: null,
|
||||
child: (team.imageUrl.isEmpty || !team.imageUrl.startsWith('http'))
|
||||
? Text(
|
||||
team.imageUrl.isEmpty ? "🛡️" : team.imageUrl,
|
||||
style: TextStyle(fontSize: 20 * context.sf),
|
||||
GestureDetector(
|
||||
onTap: onEditPhoto,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 28 * context.sf,
|
||||
backgroundColor: Colors.white24,
|
||||
backgroundImage: (currentImageUrl != null && currentImageUrl!.isNotEmpty && currentImageUrl!.startsWith('http'))
|
||||
? CachedNetworkImageProvider(currentImageUrl!)
|
||||
: null,
|
||||
child: (currentImageUrl == null || currentImageUrl!.isEmpty || !currentImageUrl!.startsWith('http'))
|
||||
? Text((currentImageUrl != null && currentImageUrl!.isNotEmpty) ? currentImageUrl! : "🛡️", style: TextStyle(fontSize: 24 * context.sf))
|
||||
: null,
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0, right: 0,
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(4 * context.sf),
|
||||
decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle),
|
||||
child: Icon(Icons.edit, color: AppTheme.primaryRed, size: 12 * context.sf),
|
||||
),
|
||||
),
|
||||
if (isUploading)
|
||||
Container(
|
||||
width: 56 * context.sf, height: 56 * context.sf,
|
||||
decoration: const BoxDecoration(color: Colors.black45, shape: BoxShape.circle),
|
||||
child: const Padding(padding: EdgeInsets.all(12.0), child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)),
|
||||
)
|
||||
: null,
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(width: 15 * context.sf),
|
||||
@@ -50,15 +84,8 @@ class StatsHeader extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
team.name,
|
||||
style: TextStyle(color: Colors.white, fontSize: 20 * context.sf, fontWeight: FontWeight.bold),
|
||||
overflow: TextOverflow.ellipsis
|
||||
),
|
||||
Text(
|
||||
team.season,
|
||||
style: TextStyle(color: Colors.white70, fontSize: 14 * context.sf)
|
||||
),
|
||||
Text(team.name, style: TextStyle(color: Colors.white, fontSize: 20 * context.sf, fontWeight: FontWeight.bold), overflow: TextOverflow.ellipsis),
|
||||
Text(team.season, style: TextStyle(color: Colors.white70, fontSize: 14 * context.sf)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -71,41 +98,28 @@ class StatsHeader extends StatelessWidget {
|
||||
// --- CARD DE RESUMO ---
|
||||
class StatsSummaryCard extends StatelessWidget {
|
||||
final int total;
|
||||
|
||||
const StatsSummaryCard({super.key, required this.total});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 👇 Adapta-se ao Modo Claro/Escuro
|
||||
final Color bgColor = Theme.of(context).brightness == Brightness.dark ? const Color(0xFF1E1E1E) : Colors.white;
|
||||
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20 * context.sf)),
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(20 * context.sf),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(20 * context.sf),
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.15)),
|
||||
),
|
||||
decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(20 * context.sf), border: Border.all(color: Colors.grey.withOpacity(0.15))),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.groups, color: AppTheme.primaryRed, size: 28 * context.sf), // 👇 Cor do tema
|
||||
Icon(Icons.groups, color: AppTheme.primaryRed, size: 28 * context.sf),
|
||||
SizedBox(width: 10 * context.sf),
|
||||
Text(
|
||||
"Total de Membros",
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 16 * context.sf, fontWeight: FontWeight.w600)
|
||||
),
|
||||
Text("Total de Membros", style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 16 * context.sf, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
"$total",
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 28 * context.sf, fontWeight: FontWeight.bold)
|
||||
),
|
||||
Text("$total", style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 28 * context.sf, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -116,7 +130,6 @@ class StatsSummaryCard extends StatelessWidget {
|
||||
// --- TÍTULO DE SECÇÃO ---
|
||||
class StatsSectionTitle extends StatelessWidget {
|
||||
final String title;
|
||||
|
||||
const StatsSectionTitle({super.key, required this.title});
|
||||
|
||||
@override
|
||||
@@ -124,79 +137,107 @@ class StatsSectionTitle extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface)
|
||||
),
|
||||
Text(title, style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface)),
|
||||
Divider(color: Colors.grey.withOpacity(0.2)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- CARD DA PESSOA (JOGADOR/TREINADOR) ---
|
||||
// --- CARD DA PESSOA (FOTO + NÚMERO + NOME E CACHE) ---
|
||||
class PersonCard extends StatelessWidget {
|
||||
final Person person;
|
||||
final bool isCoach;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
const PersonCard({
|
||||
super.key,
|
||||
required this.person,
|
||||
required this.isCoach,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
});
|
||||
const PersonCard({super.key, required this.person, required this.isCoach, required this.onEdit, required this.onDelete});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 👇 Adapta as cores do Card ao Modo Escuro e ao Tema
|
||||
final Color defaultBg = Theme.of(context).brightness == Brightness.dark ? const Color(0xFF1E1E1E) : Colors.white;
|
||||
final Color coachBg = Theme.of(context).brightness == Brightness.dark ? AppTheme.warningAmber.withOpacity(0.1) : const Color(0xFFFFF9C4);
|
||||
final String? pImage = person.imageUrl;
|
||||
|
||||
return Card(
|
||||
margin: EdgeInsets.only(top: 12 * context.sf),
|
||||
elevation: 2,
|
||||
elevation: 2,
|
||||
color: isCoach ? coachBg : defaultBg,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * context.sf)),
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16 * context.sf, vertical: 4 * context.sf),
|
||||
leading: isCoach
|
||||
? CircleAvatar(
|
||||
radius: 22 * context.sf,
|
||||
backgroundColor: AppTheme.warningAmber, // 👇 Cor do tema
|
||||
child: Icon(Icons.person, color: Colors.white, size: 24 * context.sf)
|
||||
)
|
||||
: Container(
|
||||
width: 45 * context.sf,
|
||||
height: 45 * context.sf,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryRed.withOpacity(0.1), // 👇 Cor do tema
|
||||
borderRadius: BorderRadius.circular(10 * context.sf)
|
||||
),
|
||||
child: Text(
|
||||
person.number ?? "J",
|
||||
style: TextStyle(color: AppTheme.primaryRed, fontWeight: FontWeight.bold, fontSize: 16 * context.sf)
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
person.name,
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16 * context.sf, color: Theme.of(context).colorScheme.onSurface)
|
||||
),
|
||||
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16 * context.sf, vertical: 12 * context.sf),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.edit_outlined, color: Colors.blue, size: 22 * context.sf),
|
||||
onPressed: onEdit,
|
||||
CircleAvatar(
|
||||
radius: 22 * context.sf,
|
||||
backgroundColor: isCoach ? AppTheme.warningAmber : AppTheme.primaryRed.withOpacity(0.1),
|
||||
backgroundImage: (pImage != null && pImage.isNotEmpty) ? CachedNetworkImageProvider(pImage) : null,
|
||||
child: (pImage == null || pImage.isEmpty) ? Icon(Icons.person, color: isCoach ? Colors.white : AppTheme.primaryRed, size: 24 * context.sf) : null,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.delete_outline, color: AppTheme.primaryRed, size: 22 * context.sf), // 👇 Cor do tema
|
||||
onPressed: onDelete,
|
||||
SizedBox(width: 12 * context.sf),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
if (!isCoach && person.number != null && person.number!.isNotEmpty) ...[
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8 * context.sf, vertical: 4 * context.sf),
|
||||
decoration: BoxDecoration(color: AppTheme.primaryRed.withOpacity(0.1), borderRadius: BorderRadius.circular(6 * context.sf)),
|
||||
child: Text(person.number!, style: TextStyle(color: AppTheme.primaryRed, fontWeight: FontWeight.bold, fontSize: 14 * context.sf)),
|
||||
),
|
||||
SizedBox(width: 10 * context.sf),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(person.name, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16 * context.sf, color: Theme.of(context).colorScheme.onSurface), overflow: TextOverflow.ellipsis)
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(icon: Icon(Icons.edit_outlined, color: Colors.blue, size: 22 * context.sf), onPressed: onEdit, padding: EdgeInsets.zero, constraints: const BoxConstraints()),
|
||||
SizedBox(width: 16 * context.sf),
|
||||
IconButton(icon: Icon(Icons.delete_outline, color: AppTheme.primaryRed, size: 22 * context.sf), onPressed: onDelete, padding: EdgeInsets.zero, constraints: const BoxConstraints()),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// WIDGET NOVO: SKELETON LOADING (SHIMMER)
|
||||
// ==========================================
|
||||
class SkeletonLoadingStats extends StatelessWidget {
|
||||
const SkeletonLoadingStats({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final baseColor = isDark ? Colors.grey[800]! : Colors.grey[300]!;
|
||||
final highlightColor = isDark ? Colors.grey[700]! : Colors.grey[100]!;
|
||||
|
||||
return Shimmer.fromColors(
|
||||
baseColor: baseColor,
|
||||
highlightColor: highlightColor,
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.all(16.0 * context.sf),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(height: 80 * context.sf, width: double.infinity, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20 * context.sf))),
|
||||
SizedBox(height: 30 * context.sf),
|
||||
Container(height: 20 * context.sf, width: 150 * context.sf, color: Colors.white),
|
||||
SizedBox(height: 10 * context.sf),
|
||||
for (int i = 0; i < 3; i++) ...[
|
||||
Container(
|
||||
height: 60 * context.sf, width: double.infinity,
|
||||
margin: EdgeInsets.only(top: 12 * context.sf),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(15 * context.sf)),
|
||||
),
|
||||
]
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -207,10 +248,8 @@ class PersonCard extends StatelessWidget {
|
||||
// ==========================================
|
||||
// 2. PÁGINA PRINCIPAL
|
||||
// ==========================================
|
||||
|
||||
class TeamStatsPage extends StatefulWidget {
|
||||
final Team team;
|
||||
|
||||
const TeamStatsPage({super.key, required this.team});
|
||||
|
||||
@override
|
||||
@@ -219,31 +258,79 @@ class TeamStatsPage extends StatefulWidget {
|
||||
|
||||
class _TeamStatsPageState extends State<TeamStatsPage> {
|
||||
final StatsController _controller = StatsController();
|
||||
|
||||
late String _teamImageUrl;
|
||||
bool _isUploadingTeamPhoto = false;
|
||||
bool _isPickerActive = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_teamImageUrl = widget.team.imageUrl;
|
||||
}
|
||||
|
||||
Future<void> _updateTeamPhoto() async {
|
||||
if (_isPickerActive) return;
|
||||
setState(() => _isPickerActive = true);
|
||||
|
||||
try {
|
||||
final File? croppedFile = await _controller.pickAndCropImage(context);
|
||||
if (croppedFile == null) return;
|
||||
|
||||
setState(() => _isUploadingTeamPhoto = true);
|
||||
|
||||
final fileName = 'team_${widget.team.id}_${DateTime.now().millisecondsSinceEpoch}.png';
|
||||
final supabase = Supabase.instance.client;
|
||||
|
||||
await supabase.storage.from('avatars').upload(fileName, croppedFile, fileOptions: const FileOptions(upsert: true));
|
||||
final publicUrl = supabase.storage.from('avatars').getPublicUrl(fileName);
|
||||
|
||||
await supabase.from('teams').update({'image_url': publicUrl}).eq('id', widget.team.id);
|
||||
|
||||
if (_teamImageUrl.isNotEmpty && _teamImageUrl.startsWith('http')) {
|
||||
final oldPath = _controller.extractPathFromUrl(_teamImageUrl, 'avatars');
|
||||
if (oldPath != null) await supabase.storage.from('avatars').remove([oldPath]);
|
||||
}
|
||||
|
||||
if (mounted) setState(() => _teamImageUrl = publicUrl);
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Erro: $e"), backgroundColor: AppTheme.primaryRed));
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isUploadingTeamPhoto = false;
|
||||
_isPickerActive = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor, // 👇 Adapta-se ao Modo Escuro
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
body: Column(
|
||||
children: [
|
||||
StatsHeader(team: widget.team),
|
||||
StatsHeader(team: widget.team, currentImageUrl: _teamImageUrl, onEditPhoto: _updateTeamPhoto, isUploading: _isUploadingTeamPhoto),
|
||||
|
||||
Expanded(
|
||||
child: StreamBuilder<List<Person>>(
|
||||
stream: _controller.getMembers(widget.team.id),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return Center(child: CircularProgressIndicator(color: AppTheme.primaryRed));
|
||||
return const SkeletonLoadingStats();
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text("Erro ao carregar: ${snapshot.error}", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)));
|
||||
}
|
||||
if (snapshot.hasError) return Center(child: Text("Erro ao carregar: ${snapshot.error}", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)));
|
||||
|
||||
final members = snapshot.data ?? [];
|
||||
|
||||
final coaches = members.where((m) => m.type == 'Treinador').toList();
|
||||
final players = members.where((m) => m.type == 'Jogador').toList();
|
||||
final coaches = members.where((m) => m.type == 'Treinador').toList()..sort((a, b) => a.name.compareTo(b.name));
|
||||
final players = members.where((m) => m.type == 'Jogador').toList()..sort((a, b) {
|
||||
int numA = int.tryParse(a.number ?? '999') ?? 999;
|
||||
int numB = int.tryParse(b.number ?? '999') ?? 999;
|
||||
return numA.compareTo(numB);
|
||||
});
|
||||
|
||||
return RefreshIndicator(
|
||||
color: AppTheme.primaryRed,
|
||||
@@ -257,32 +344,17 @@ class _TeamStatsPageState extends State<TeamStatsPage> {
|
||||
StatsSummaryCard(total: members.length),
|
||||
SizedBox(height: 30 * context.sf),
|
||||
|
||||
// TREINADORES
|
||||
if (coaches.isNotEmpty) ...[
|
||||
const StatsSectionTitle(title: "Treinadores"),
|
||||
...coaches.map((c) => PersonCard(
|
||||
person: c,
|
||||
isCoach: true,
|
||||
onEdit: () => _controller.showEditPersonDialog(context, widget.team.id, c),
|
||||
onDelete: () => _confirmDelete(context, c),
|
||||
)),
|
||||
...coaches.map((c) => PersonCard(person: c, isCoach: true, onEdit: () => _controller.showEditPersonDialog(context, widget.team.id, c), onDelete: () => _confirmDelete(context, c))),
|
||||
SizedBox(height: 30 * context.sf),
|
||||
],
|
||||
|
||||
// JOGADORES
|
||||
const StatsSectionTitle(title: "Jogadores"),
|
||||
if (players.isEmpty)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 20 * context.sf),
|
||||
child: Text("Nenhum jogador nesta equipa.", style: TextStyle(color: Colors.grey, fontSize: 16 * context.sf)),
|
||||
)
|
||||
Padding(padding: EdgeInsets.only(top: 20 * context.sf), child: Text("Nenhum jogador nesta equipa.", style: TextStyle(color: Colors.grey, fontSize: 16 * context.sf)))
|
||||
else
|
||||
...players.map((p) => PersonCard(
|
||||
person: p,
|
||||
isCoach: false,
|
||||
onEdit: () => _controller.showEditPersonDialog(context, widget.team.id, p),
|
||||
onDelete: () => _confirmDelete(context, p),
|
||||
)),
|
||||
...players.map((p) => PersonCard(person: p, isCoach: false, onEdit: () => _controller.showEditPersonDialog(context, widget.team.id, p), onDelete: () => _confirmDelete(context, p))),
|
||||
SizedBox(height: 80 * context.sf),
|
||||
],
|
||||
),
|
||||
@@ -296,13 +368,13 @@ class _TeamStatsPageState extends State<TeamStatsPage> {
|
||||
floatingActionButton: FloatingActionButton(
|
||||
heroTag: 'fab_team_${widget.team.id}',
|
||||
onPressed: () => _controller.showAddPersonDialog(context, widget.team.id),
|
||||
backgroundColor: AppTheme.successGreen, // 👇 Cor de sucesso do tema
|
||||
backgroundColor: AppTheme.successGreen,
|
||||
child: Icon(Icons.add, color: Colors.white, size: 24 * context.sf),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDelete(BuildContext context, Person person) {
|
||||
void _confirmDelete(BuildContext context, Person person) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
@@ -310,53 +382,91 @@ class _TeamStatsPageState extends State<TeamStatsPage> {
|
||||
title: Text("Eliminar Membro?", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)),
|
||||
content: Text("Tens a certeza que queres remover ${person.name}?", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("Cancelar", style: TextStyle(color: Colors.grey))),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text("Cancelar", style: TextStyle(color: Colors.grey))
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await _controller.deletePerson(person.id);
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
onPressed: () {
|
||||
// ⚡ FECHA LOGO O POP-UP!
|
||||
Navigator.pop(ctx);
|
||||
// Mostra um aviso rápido para o utilizador saber que a app está a trabalhar
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("A remover ${person.name}..."), duration: const Duration(seconds: 1)));
|
||||
|
||||
// APAGA NO FUNDO
|
||||
_controller.deletePerson(person).catchError((e) {
|
||||
if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Erro: $e"), backgroundColor: AppTheme.primaryRed));
|
||||
});
|
||||
},
|
||||
child: Text("Eliminar", style: TextStyle(color: AppTheme.primaryRed)), // 👇 Cor oficial
|
||||
child: const Text("Eliminar", style: TextStyle(color: AppTheme.primaryRed)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ==========================================
|
||||
// 3. CONTROLLER
|
||||
// ==========================================
|
||||
|
||||
class StatsController {
|
||||
final _supabase = Supabase.instance.client;
|
||||
|
||||
Stream<List<Person>> getMembers(String teamId) {
|
||||
return _supabase
|
||||
.from('members')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('team_id', teamId)
|
||||
.order('name', ascending: true)
|
||||
.map((data) => data.map((json) => Person.fromMap(json)).toList());
|
||||
return _supabase.from('members').stream(primaryKey: ['id']).eq('team_id', teamId).map((data) => data.map((json) => Person.fromMap(json)).toList());
|
||||
}
|
||||
|
||||
Future<void> deletePerson(String personId) async {
|
||||
try {
|
||||
await _supabase.from('members').delete().eq('id', personId);
|
||||
} catch (e) {
|
||||
debugPrint("Erro ao eliminar: $e");
|
||||
String? extractPathFromUrl(String url, String bucket) {
|
||||
if (url.isEmpty) return null;
|
||||
final parts = url.split('/$bucket/');
|
||||
if (parts.length > 1) return parts.last;
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> deletePerson(Person person) async {
|
||||
try {
|
||||
await _supabase.from('members').delete().eq('id', person.id);
|
||||
|
||||
if (person.imageUrl != null && person.imageUrl!.isNotEmpty) {
|
||||
final path = extractPathFromUrl(person.imageUrl!, 'avatars');
|
||||
if (path != null) await _supabase.storage.from('avatars').remove([path]);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Erro ao eliminar: $e");
|
||||
}
|
||||
}
|
||||
|
||||
void showAddPersonDialog(BuildContext context, String teamId) {
|
||||
_showForm(context, teamId: teamId);
|
||||
}
|
||||
void showAddPersonDialog(BuildContext context, String teamId) { _showForm(context, teamId: teamId); }
|
||||
void showEditPersonDialog(BuildContext context, String teamId, Person person) { _showForm(context, teamId: teamId, person: person); }
|
||||
|
||||
void showEditPersonDialog(BuildContext context, String teamId, Person person) {
|
||||
_showForm(context, teamId: teamId, person: person);
|
||||
Future<File?> pickAndCropImage(BuildContext context) async {
|
||||
final picker = ImagePicker();
|
||||
final pickedFile = await picker.pickImage(source: ImageSource.gallery);
|
||||
|
||||
if (pickedFile == null) return null;
|
||||
|
||||
CroppedFile? croppedFile = await ImageCropper().cropImage(
|
||||
sourcePath: pickedFile.path,
|
||||
aspectRatio: const CropAspectRatio(ratioX: 1, ratioY: 1),
|
||||
uiSettings: [
|
||||
AndroidUiSettings(
|
||||
toolbarTitle: 'Recortar Foto',
|
||||
toolbarColor: AppTheme.primaryRed,
|
||||
toolbarWidgetColor: Colors.white,
|
||||
initAspectRatio: CropAspectRatioPreset.square,
|
||||
lockAspectRatio: true,
|
||||
hideBottomControls: true,
|
||||
),
|
||||
IOSUiSettings(
|
||||
title: 'Recortar Foto',
|
||||
aspectRatioLockEnabled: true,
|
||||
resetButtonHidden: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (croppedFile != null) {
|
||||
return File(croppedFile.path);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _showForm(BuildContext context, {required String teamId, Person? person}) {
|
||||
@@ -364,6 +474,15 @@ class StatsController {
|
||||
final nameCtrl = TextEditingController(text: person?.name ?? '');
|
||||
final numCtrl = TextEditingController(text: person?.number ?? '');
|
||||
String selectedType = person?.type ?? 'Jogador';
|
||||
|
||||
File? selectedImage;
|
||||
bool isUploading = false;
|
||||
bool isPickerActive = false;
|
||||
String? currentImageUrl = isEdit ? person.imageUrl : null;
|
||||
|
||||
// 👇 VARIÁVEIS PARA O TEXTO PEQUENO VERMELHO (ESTILO LOGIN) 👇
|
||||
String? nameError;
|
||||
String? numError;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -371,18 +490,58 @@ class StatsController {
|
||||
builder: (ctx, setState) => AlertDialog(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * context.sf)),
|
||||
title: Text(
|
||||
isEdit ? "Editar Membro" : "Novo Membro",
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface)
|
||||
),
|
||||
title: Text(isEdit ? "Editar Membro" : "Novo Membro", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
if (isPickerActive) return;
|
||||
setState(() => isPickerActive = true);
|
||||
|
||||
try {
|
||||
final File? croppedFile = await pickAndCropImage(context);
|
||||
if (croppedFile != null) {
|
||||
setState(() => selectedImage = croppedFile);
|
||||
}
|
||||
} finally {
|
||||
setState(() => isPickerActive = false);
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 40 * context.sf,
|
||||
backgroundColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.05),
|
||||
backgroundImage: selectedImage != null
|
||||
? FileImage(selectedImage!)
|
||||
: (currentImageUrl != null && currentImageUrl!.isNotEmpty ? CachedNetworkImageProvider(currentImageUrl!) : null) as ImageProvider?,
|
||||
child: (selectedImage == null && (currentImageUrl == null || currentImageUrl!.isEmpty))
|
||||
? Icon(Icons.add_a_photo, size: 30 * context.sf, color: Colors.grey)
|
||||
: null,
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0, right: 0,
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(6 * context.sf),
|
||||
decoration: BoxDecoration(color: AppTheme.primaryRed, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2)),
|
||||
child: Icon(Icons.edit, color: Colors.white, size: 14 * context.sf),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20 * context.sf),
|
||||
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
decoration: const InputDecoration(labelText: "Nome Completo"),
|
||||
decoration: InputDecoration(
|
||||
labelText: "Nome Completo",
|
||||
errorText: nameError, // 👇 ERRO PEQUENO AQUI
|
||||
),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
),
|
||||
SizedBox(height: 15 * context.sf),
|
||||
@@ -391,19 +550,18 @@ class StatsController {
|
||||
dropdownColor: Theme.of(context).colorScheme.surface,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 16 * context.sf),
|
||||
decoration: const InputDecoration(labelText: "Função"),
|
||||
items: ["Jogador", "Treinador"]
|
||||
.map((e) => DropdownMenuItem(value: e, child: Text(e)))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v != null) setState(() => selectedType = v);
|
||||
},
|
||||
items: ["Jogador", "Treinador"].map((e) => DropdownMenuItem(value: e, child: Text(e))).toList(),
|
||||
onChanged: (v) { if (v != null) setState(() => selectedType = v); },
|
||||
),
|
||||
if (selectedType == "Jogador") ...[
|
||||
SizedBox(height: 15 * context.sf),
|
||||
TextField(
|
||||
controller: numCtrl,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
decoration: const InputDecoration(labelText: "Número da Camisola"),
|
||||
decoration: InputDecoration(
|
||||
labelText: "Número da Camisola",
|
||||
errorText: numError, // 👇 ERRO PEQUENO AQUI
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
]
|
||||
@@ -411,29 +569,46 @@ class StatsController {
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text("Cancelar", style: TextStyle(color: Colors.grey))
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("Cancelar", style: TextStyle(color: Colors.grey))),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.successGreen, // 👇 Cor verde do tema
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8 * context.sf))
|
||||
),
|
||||
onPressed: () async {
|
||||
if (nameCtrl.text.trim().isEmpty) return;
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.successGreen, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8 * context.sf))),
|
||||
onPressed: isUploading ? null : () async {
|
||||
|
||||
// Limpa os erros antes de tentar de novo
|
||||
setState(() {
|
||||
nameError = null;
|
||||
numError = null;
|
||||
});
|
||||
|
||||
String? numeroFinal = (selectedType == "Treinador")
|
||||
? null
|
||||
: (numCtrl.text.trim().isEmpty ? null : numCtrl.text.trim());
|
||||
if (nameCtrl.text.trim().isEmpty) {
|
||||
setState(() => nameError = "O nome é obrigatório");
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => isUploading = true);
|
||||
|
||||
String? numeroFinal = (selectedType == "Treinador") ? null : (numCtrl.text.trim().isEmpty ? null : numCtrl.text.trim());
|
||||
|
||||
try {
|
||||
String? finalImageUrl = currentImageUrl;
|
||||
|
||||
if (selectedImage != null) {
|
||||
final fileName = 'person_${DateTime.now().millisecondsSinceEpoch}.png';
|
||||
await _supabase.storage.from('avatars').upload(fileName, selectedImage!, fileOptions: const FileOptions(upsert: true));
|
||||
finalImageUrl = _supabase.storage.from('avatars').getPublicUrl(fileName);
|
||||
|
||||
if (currentImageUrl != null && currentImageUrl!.isNotEmpty) {
|
||||
final oldPath = extractPathFromUrl(currentImageUrl!, 'avatars');
|
||||
if (oldPath != null) await _supabase.storage.from('avatars').remove([oldPath]);
|
||||
}
|
||||
}
|
||||
|
||||
if (isEdit) {
|
||||
await _supabase.from('members').update({
|
||||
'name': nameCtrl.text.trim(),
|
||||
'type': selectedType,
|
||||
'number': numeroFinal,
|
||||
'image_url': finalImageUrl,
|
||||
}).eq('id', person.id);
|
||||
} else {
|
||||
await _supabase.from('members').insert({
|
||||
@@ -441,23 +616,25 @@ class StatsController {
|
||||
'name': nameCtrl.text.trim(),
|
||||
'type': selectedType,
|
||||
'number': numeroFinal,
|
||||
'image_url': finalImageUrl,
|
||||
});
|
||||
}
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
} catch (e) {
|
||||
debugPrint("Erro Supabase: $e");
|
||||
if (ctx.mounted) {
|
||||
String errorMsg = "Erro ao guardar: $e";
|
||||
if (e.toString().contains('unique')) {
|
||||
errorMsg = "Já existe um membro com este numero na equipa.";
|
||||
// 👇 AGORA OS ERROS VÃO DIRETOS PARA OS CAMPOS (ESTILO LOGIN) 👇
|
||||
setState(() {
|
||||
isUploading = false;
|
||||
if (e is PostgrestException && e.code == '23505') {
|
||||
numError = "Este número já está em uso!";
|
||||
} else if (e.toString().toLowerCase().contains('unique') || e.toString().toLowerCase().contains('duplicate')) {
|
||||
numError = "Este número já está em uso!";
|
||||
} else {
|
||||
nameError = "Erro ao guardar. Tente novamente.";
|
||||
}
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(content: Text(errorMsg), backgroundColor: AppTheme.primaryRed) // 👇 Cor oficial para erro
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
child: const Text("Guardar"),
|
||||
child: isUploading ? SizedBox(width: 16 * context.sf, height: 16 * context.sf, child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : const Text("Guardar"),
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:playmaker/controllers/placar_controller.dart';
|
||||
import 'package:playmaker/utils/size_extension.dart';
|
||||
import 'package:playmaker/classe/theme.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:playmaker/classe/theme.dart';
|
||||
import 'package:playmaker/controllers/placar_controller.dart';
|
||||
import 'package:playmaker/zone_map_dialog.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
// ============================================================================
|
||||
// 1. PLACAR SUPERIOR (CRONÓMETRO E RESULTADO)
|
||||
// ============================================================================
|
||||
// ============================================================================
|
||||
// 1. PLACAR SUPERIOR (COM CRONÓMETRO DE ALTA PERFORMANCE)
|
||||
// ============================================================================
|
||||
class TopScoreboard extends StatelessWidget {
|
||||
final PlacarController controller;
|
||||
final double sf;
|
||||
@@ -19,60 +19,86 @@ class TopScoreboard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(vertical: 10 * sf, horizontal: 35 * sf),
|
||||
padding: EdgeInsets.symmetric(vertical: 6 * sf, horizontal: 20 * sf),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.placarDarkSurface,
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(22 * sf),
|
||||
bottomRight: Radius.circular(22 * sf)
|
||||
),
|
||||
border: Border.all(color: Colors.white, width: 2.5 * sf),
|
||||
borderRadius: BorderRadius.only(bottomLeft: Radius.circular(22 * sf), bottomRight: Radius.circular(22 * sf)),
|
||||
border: Border.all(color: Colors.white, width: 2.0 * sf),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildTeamSection(controller.myTeam, controller.myScore, controller.myFouls, controller.myTimeoutsUsed, AppTheme.myTeamBlue, false, sf),
|
||||
SizedBox(width: 30 * sf),
|
||||
SizedBox(width: 20 * sf),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 18 * sf, vertical: 5 * sf),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.placarTimerBg,
|
||||
borderRadius: BorderRadius.circular(9 * sf)
|
||||
),
|
||||
child: Text(
|
||||
controller.formatTime(),
|
||||
style: TextStyle(color: Colors.white, fontSize: 28 * sf, fontWeight: FontWeight.w900, fontFamily: 'monospace', letterSpacing: 2 * sf)
|
||||
padding: EdgeInsets.symmetric(horizontal: 14 * sf, vertical: 4 * sf),
|
||||
decoration: BoxDecoration(color: AppTheme.placarTimerBg, borderRadius: BorderRadius.circular(9 * sf)),
|
||||
// 👇 AQUI ESTÁ A MAGIA DE PERFORMANCE! Só este texto se atualiza a cada segundo! 👇
|
||||
child: ValueListenableBuilder<Duration>(
|
||||
valueListenable: controller.durationNotifier,
|
||||
builder: (context, duration, child) {
|
||||
String formatTime = "${duration.inMinutes.toString().padLeft(2, '0')}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}";
|
||||
return Text(formatTime, style: TextStyle(color: Colors.white, fontSize: 24 * sf, fontWeight: FontWeight.w900, fontFamily: 'monospace', letterSpacing: 1.5 * sf));
|
||||
}
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5 * sf),
|
||||
Text(
|
||||
"PERÍODO ${controller.currentQuarter}",
|
||||
style: TextStyle(color: AppTheme.warningAmber, fontSize: 14 * sf, fontWeight: FontWeight.w900)
|
||||
),
|
||||
SizedBox(height: 4 * sf),
|
||||
Text("PERÍODO ${controller.currentQuarter}", style: TextStyle(color: AppTheme.warningAmber, fontSize: 12 * sf, fontWeight: FontWeight.w900)),
|
||||
],
|
||||
),
|
||||
SizedBox(width: 30 * sf),
|
||||
SizedBox(width: 20 * sf),
|
||||
_buildTeamSection(controller.opponentTeam, controller.opponentScore, controller.opponentFouls, controller.opponentTimeoutsUsed, AppTheme.oppTeamRed, true, sf),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTeamSection(String name, int score, int fouls, int timeouts, Color color, bool isOpp, double sf) {
|
||||
int displayFouls = fouls > 5 ? 5 : fouls;
|
||||
final timeoutIndicators = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(3, (index) => Container(
|
||||
margin: EdgeInsets.symmetric(horizontal: 2.5 * sf), width: 10 * sf, height: 10 * sf,
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: index < timeouts ? AppTheme.warningAmber : Colors.grey.shade600, border: Border.all(color: Colors.white54, width: 1.0 * sf)),
|
||||
)),
|
||||
);
|
||||
List<Widget> content = [
|
||||
Column(children: [_scoreBox(score, color, sf), SizedBox(height: 5 * sf), timeoutIndicators]),
|
||||
SizedBox(width: 12 * sf),
|
||||
Column(
|
||||
crossAxisAlignment: isOpp ? CrossAxisAlignment.start : CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(name.toUpperCase(), style: TextStyle(color: Colors.white, fontSize: 16 * sf, fontWeight: FontWeight.w900, letterSpacing: 1.0 * sf)),
|
||||
SizedBox(height: 3 * sf),
|
||||
Text("FALTAS: $displayFouls", style: TextStyle(color: displayFouls >= 5 ? AppTheme.actionMiss : AppTheme.warningAmber, fontSize: 11 * sf, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
)
|
||||
];
|
||||
return Row(crossAxisAlignment: CrossAxisAlignment.center, children: isOpp ? content : content.reversed.toList());
|
||||
}
|
||||
|
||||
Widget _scoreBox(int score, Color color, double sf) => Container(
|
||||
width: 45 * sf, height: 35 * sf, alignment: Alignment.center,
|
||||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(6 * sf)),
|
||||
child: Text(score.toString(), style: TextStyle(color: Colors.white, fontSize: 20 * sf, fontWeight: FontWeight.w900)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTeamSection(String name, int score, int fouls, int timeouts, Color color, bool isOpp, double sf) {
|
||||
int displayFouls = fouls > 5 ? 5 : fouls;
|
||||
|
||||
final timeoutIndicators = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(3, (index) => Container(
|
||||
margin: EdgeInsets.symmetric(horizontal: 3.5 * sf),
|
||||
width: 12 * sf, height: 12 * sf,
|
||||
margin: EdgeInsets.symmetric(horizontal: 2.5 * sf),
|
||||
width: 10 * sf, height: 10 * sf,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: index < timeouts ? AppTheme.warningAmber : Colors.grey.shade600,
|
||||
border: Border.all(color: Colors.white54, width: 1.5 * sf)
|
||||
border: Border.all(color: Colors.white54, width: 1.0 * sf)
|
||||
),
|
||||
)),
|
||||
);
|
||||
@@ -81,40 +107,38 @@ class TopScoreboard extends StatelessWidget {
|
||||
Column(
|
||||
children: [
|
||||
_scoreBox(score, color, sf),
|
||||
SizedBox(height: 7 * sf),
|
||||
SizedBox(height: 5 * sf),
|
||||
timeoutIndicators
|
||||
]
|
||||
),
|
||||
SizedBox(width: 18 * sf),
|
||||
SizedBox(width: 12 * sf),
|
||||
Column(
|
||||
crossAxisAlignment: isOpp ? CrossAxisAlignment.start : CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
name.toUpperCase(),
|
||||
style: TextStyle(color: Colors.white, fontSize: 20 * sf, fontWeight: FontWeight.w900, letterSpacing: 1.2 * sf)
|
||||
style: TextStyle(color: Colors.white, fontSize: 16 * sf, fontWeight: FontWeight.w900, letterSpacing: 1.0 * sf)
|
||||
),
|
||||
SizedBox(height: 5 * sf),
|
||||
SizedBox(height: 3 * sf),
|
||||
Text(
|
||||
"FALTAS: $displayFouls",
|
||||
style: TextStyle(color: displayFouls >= 5 ? AppTheme.actionMiss : AppTheme.warningAmber, fontSize: 13 * sf, fontWeight: FontWeight.bold)
|
||||
style: TextStyle(color: displayFouls >= 5 ? AppTheme.actionMiss : AppTheme.warningAmber, fontSize: 11 * sf, fontWeight: FontWeight.bold)
|
||||
),
|
||||
],
|
||||
)
|
||||
),
|
||||
];
|
||||
|
||||
return Row(crossAxisAlignment: CrossAxisAlignment.center, children: isOpp ? content : content.reversed.toList());
|
||||
}
|
||||
|
||||
Widget _scoreBox(int score, Color color, double sf) => Container(
|
||||
width: 58 * sf, height: 45 * sf,
|
||||
width: 45 * sf, height: 35 * sf,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(7 * sf)),
|
||||
child: Text(score.toString(), style: TextStyle(color: Colors.white, fontSize: 26 * sf, fontWeight: FontWeight.w900)),
|
||||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(6 * sf)),
|
||||
child: Text(score.toString(), style: TextStyle(color: Colors.white, fontSize: 20 * sf, fontWeight: FontWeight.w900)),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 2. BANCO DE SUPLENTES (DRAG & DROP)
|
||||
// 2. BANCO DE SUPLENTES (COM TRADUTOR DE NOME)
|
||||
// ============================================================================
|
||||
class BenchPlayersList extends StatelessWidget {
|
||||
final PlacarController controller;
|
||||
@@ -131,51 +155,45 @@ class BenchPlayersList extends StatelessWidget {
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: bench.map((playerName) {
|
||||
final num = controller.playerNumbers[playerName] ?? "0";
|
||||
final int fouls = controller.playerStats[playerName]?["fls"] ?? 0;
|
||||
children: bench.map((playerId) {
|
||||
final playerName = controller.playerNames[playerId] ?? "Erro";
|
||||
final num = controller.playerNumbers[playerId] ?? "0";
|
||||
final int fouls = controller.playerStats[playerId]?["fls"] ?? 0;
|
||||
final bool isFouledOut = fouls >= 5;
|
||||
|
||||
String shortName = playerName.length > 8 ? "${playerName.substring(0, 7)}." : playerName;
|
||||
|
||||
Widget avatarUI = Container(
|
||||
margin: EdgeInsets.only(bottom: 7 * sf),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 1.8 * sf),
|
||||
boxShadow: [BoxShadow(color: Colors.black45, blurRadius: 5 * sf, offset: Offset(0, 2.5 * sf))]
|
||||
),
|
||||
child: CircleAvatar(
|
||||
radius: 22 * sf,
|
||||
backgroundColor: isFouledOut ? Colors.grey.shade800 : teamColor,
|
||||
child: Text(
|
||||
num,
|
||||
style: TextStyle(
|
||||
color: isFouledOut ? Colors.red.shade300 : Colors.white,
|
||||
fontSize: 16 * sf,
|
||||
fontWeight: FontWeight.bold,
|
||||
decoration: isFouledOut ? TextDecoration.lineThrough : TextDecoration.none
|
||||
)
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 1.5 * sf),
|
||||
boxShadow: [BoxShadow(color: Colors.black45, blurRadius: 4 * sf, offset: Offset(0, 2.0 * sf))]
|
||||
),
|
||||
child: CircleAvatar(
|
||||
radius: 18 * sf,
|
||||
backgroundColor: isFouledOut ? Colors.grey.shade800 : teamColor,
|
||||
child: Text(num, style: TextStyle(color: isFouledOut ? Colors.red.shade300 : Colors.white, fontSize: 14 * sf, fontWeight: FontWeight.bold, decoration: isFouledOut ? TextDecoration.lineThrough : TextDecoration.none)),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4 * sf),
|
||||
Text(shortName, style: TextStyle(color: Colors.white, fontSize: 10 * sf, fontWeight: FontWeight.bold), overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (isFouledOut) {
|
||||
return GestureDetector(
|
||||
onTap: () => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('🛑 $playerName não pode voltar (Expulso).'), backgroundColor: AppTheme.actionMiss)),
|
||||
child: avatarUI
|
||||
);
|
||||
return GestureDetector(onTap: () => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('🛑 $playerName não pode voltar (Expulso).'), backgroundColor: AppTheme.actionMiss)), child: avatarUI);
|
||||
}
|
||||
|
||||
return Draggable<String>(
|
||||
data: "$prefix$playerName",
|
||||
feedback: Material(
|
||||
color: Colors.transparent,
|
||||
child: CircleAvatar(
|
||||
radius: 28 * sf,
|
||||
backgroundColor: teamColor,
|
||||
child: Text(num, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 18 * sf))
|
||||
)
|
||||
),
|
||||
childWhenDragging: Opacity(opacity: 0.5, child: SizedBox(width: 45 * sf, height: 45 * sf)),
|
||||
data: "$prefix$playerId",
|
||||
feedback: Material(color: Colors.transparent, child: CircleAvatar(radius: 22 * sf, backgroundColor: teamColor, child: Text(num, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16 * sf)))),
|
||||
childWhenDragging: Opacity(opacity: 0.5, child: SizedBox(width: 36 * sf, height: 36 * sf)),
|
||||
child: avatarUI,
|
||||
);
|
||||
}).toList(),
|
||||
@@ -184,34 +202,36 @@ class BenchPlayersList extends StatelessWidget {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 3. CARTÃO DO JOGADOR NO CAMPO (TARGET DE FALTAS/PONTOS/SUBSTITUIÇÕES)
|
||||
// 3. CARTÃO DO JOGADOR NO CAMPO (COM TRADUTOR DE NOME)
|
||||
// ============================================================================
|
||||
class PlayerCourtCard extends StatelessWidget {
|
||||
final PlacarController controller;
|
||||
final String name;
|
||||
final String playerId;
|
||||
final bool isOpponent;
|
||||
final double sf;
|
||||
|
||||
const PlayerCourtCard({super.key, required this.controller, required this.name, required this.isOpponent, required this.sf});
|
||||
const PlayerCourtCard({super.key, required this.controller, required this.playerId, required this.isOpponent, required this.sf});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final teamColor = isOpponent ? AppTheme.oppTeamRed : AppTheme.myTeamBlue;
|
||||
final stats = controller.playerStats[name]!;
|
||||
final number = controller.playerNumbers[name]!;
|
||||
|
||||
final realName = controller.playerNames[playerId] ?? "Erro";
|
||||
final stats = controller.playerStats[playerId]!;
|
||||
final number = controller.playerNumbers[playerId]!;
|
||||
final prefix = isOpponent ? "player_opp_" : "player_my_";
|
||||
|
||||
return Draggable<String>(
|
||||
data: "$prefix$name",
|
||||
data: "$prefix$playerId",
|
||||
feedback: Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(color: teamColor.withOpacity(0.9), borderRadius: BorderRadius.circular(8)),
|
||||
child: Text(name, style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(color: teamColor.withOpacity(0.9), borderRadius: BorderRadius.circular(6)),
|
||||
child: Text(realName, style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
childWhenDragging: Opacity(opacity: 0.5, child: _playerCardUI(number, name, stats, teamColor, false, false, sf)),
|
||||
childWhenDragging: Opacity(opacity: 0.5, child: _playerCardUI(number, realName, stats, teamColor, false, false, sf)),
|
||||
child: DragTarget<String>(
|
||||
onAcceptWithDetails: (details) {
|
||||
final action = details.data;
|
||||
@@ -223,85 +243,70 @@ class PlayerCourtCard extends StatelessWidget {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => ZoneMapDialog(
|
||||
playerName: name,
|
||||
playerName: realName,
|
||||
isMake: isMake,
|
||||
is3PointAction: is3Pt,
|
||||
onZoneSelected: (zone, points, relX, relY) {
|
||||
controller.registerShotFromPopup(context, action, "$prefix$name", zone, points, relX, relY);
|
||||
Navigator.pop(ctx);
|
||||
controller.registerShotFromPopup(context, action, "$prefix$playerId", zone, points, relX, relY);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
else if (action.startsWith("add_") || action.startsWith("sub_") || action.startsWith("miss_")) {
|
||||
controller.handleActionDrag(context, action, "$prefix$name");
|
||||
controller.handleActionDrag(context, action, "$prefix$playerId");
|
||||
}
|
||||
else if (action.startsWith("bench_")) {
|
||||
controller.handleSubbing(context, action, name, isOpponent);
|
||||
controller.handleSubbing(context, action, playerId, isOpponent);
|
||||
}
|
||||
},
|
||||
builder: (context, candidateData, rejectedData) {
|
||||
bool isSubbing = candidateData.any((data) => data != null && (data.startsWith("bench_my_") || data.startsWith("bench_opp_")));
|
||||
bool isActionHover = candidateData.any((data) => data != null && (data.startsWith("add_") || data.startsWith("sub_") || data.startsWith("miss_")));
|
||||
return _playerCardUI(number, name, stats, teamColor, isSubbing, isActionHover, sf);
|
||||
return _playerCardUI(number, realName, stats, teamColor, isSubbing, isActionHover, sf);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _playerCardUI(String number, String name, Map<String, int> stats, Color teamColor, bool isSubbing, bool isActionHover, double sf) {
|
||||
Widget _playerCardUI(String number, String displayNameStr, Map<String, int> stats, Color teamColor, bool isSubbing, bool isActionHover, double sf) {
|
||||
bool isFouledOut = stats["fls"]! >= 5;
|
||||
Color bgColor = isFouledOut ? Colors.red.shade100 : Colors.white;
|
||||
Color borderColor = isFouledOut ? AppTheme.actionMiss : Colors.transparent;
|
||||
|
||||
if (isSubbing) {
|
||||
bgColor = Colors.blue.shade50; borderColor = AppTheme.myTeamBlue;
|
||||
} else if (isActionHover && !isFouledOut) {
|
||||
bgColor = Colors.orange.shade50; borderColor = AppTheme.actionPoints;
|
||||
}
|
||||
if (isSubbing) { bgColor = Colors.blue.shade50; borderColor = AppTheme.myTeamBlue; }
|
||||
else if (isActionHover && !isFouledOut) { bgColor = Colors.orange.shade50; borderColor = AppTheme.actionPoints; }
|
||||
|
||||
int fgm = stats["fgm"]!;
|
||||
int fga = stats["fga"]!;
|
||||
int fgm = stats["fgm"]!; int fga = stats["fga"]!;
|
||||
String fgPercent = fga > 0 ? ((fgm / fga) * 100).toStringAsFixed(0) : "0";
|
||||
String displayName = name.length > 12 ? "${name.substring(0, 10)}..." : name;
|
||||
String displayName = displayNameStr.length > 12 ? "${displayNameStr.substring(0, 10)}..." : displayNameStr;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor, borderRadius: BorderRadius.circular(12), border: Border.all(color: borderColor, width: 2),
|
||||
boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 6, offset: Offset(0, 3))],
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(8), border: Border.all(color: borderColor, width: 1.5), boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 4, offset: Offset(0, 2))]),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(9 * sf),
|
||||
borderRadius: BorderRadius.circular(6 * sf),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16 * sf),
|
||||
padding: EdgeInsets.symmetric(horizontal: 10 * sf),
|
||||
color: isFouledOut ? Colors.grey[700] : teamColor,
|
||||
alignment: Alignment.center,
|
||||
child: Text(number, style: TextStyle(color: Colors.white, fontSize: 22 * sf, fontWeight: FontWeight.bold)),
|
||||
child: Text(number, style: TextStyle(color: Colors.white, fontSize: 18 * sf, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12 * sf, vertical: 7 * sf),
|
||||
padding: EdgeInsets.symmetric(horizontal: 8 * sf, vertical: 4 * sf),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
displayName,
|
||||
style: TextStyle(fontSize: 16 * sf, fontWeight: FontWeight.bold, color: isFouledOut ? AppTheme.actionMiss : Colors.black87, decoration: isFouledOut ? TextDecoration.lineThrough : TextDecoration.none)
|
||||
),
|
||||
SizedBox(height: 2.5 * sf),
|
||||
Text(
|
||||
"${stats["pts"]} Pts | FG: $fgm/$fga ($fgPercent%)",
|
||||
style: TextStyle(fontSize: 12 * sf, color: isFouledOut ? AppTheme.actionMiss : Colors.grey[700], fontWeight: FontWeight.w600)
|
||||
),
|
||||
Text(
|
||||
"${stats["ast"]} Ast | ${stats["orb"]! + stats["drb"]!} Rbs | ${stats["fls"]} Fls",
|
||||
style: TextStyle(fontSize: 12 * sf, color: isFouledOut ? AppTheme.actionMiss : Colors.grey[500], fontWeight: FontWeight.w600)
|
||||
),
|
||||
Text(displayName, style: TextStyle(fontSize: 14 * sf, fontWeight: FontWeight.bold, color: isFouledOut ? AppTheme.actionMiss : Colors.black87, decoration: isFouledOut ? TextDecoration.lineThrough : TextDecoration.none)),
|
||||
SizedBox(height: 1.5 * sf),
|
||||
Text("${stats["pts"]} Pts | FG: $fgm/$fga ($fgPercent%)", style: TextStyle(fontSize: 10 * sf, color: isFouledOut ? AppTheme.actionMiss : Colors.grey[700], fontWeight: FontWeight.w600)),
|
||||
Text("${stats["ast"]} Ast | ${stats["orb"]! + stats["drb"]!} Rbs | ${stats["fls"]} Fls", style: TextStyle(fontSize: 10 * sf, color: isFouledOut ? AppTheme.actionMiss : Colors.grey[500], fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -314,7 +319,7 @@ class PlayerCourtCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 4. PAINEL DE BOTÕES DE AÇÃO (PONTOS, RESSALTOS, ETC)
|
||||
// 4. PAINEL DE BOTÕES DE AÇÃO
|
||||
// ============================================================================
|
||||
class ActionButtonsPanel extends StatelessWidget {
|
||||
final PlacarController controller;
|
||||
@@ -324,9 +329,9 @@ class ActionButtonsPanel extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double baseSize = 65 * sf;
|
||||
final double feedSize = 82 * sf;
|
||||
final double gap = 7 * sf;
|
||||
final double baseSize = 58 * sf;
|
||||
final double feedSize = 73 * sf;
|
||||
final double gap = 5 * sf;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -452,329 +457,7 @@ class ActionButtonsPanel extends StatelessWidget {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 5. PÁGINA DO PLACAR
|
||||
// ============================================================================
|
||||
class PlacarPage extends StatefulWidget {
|
||||
final String gameId, myTeam, opponentTeam;
|
||||
|
||||
const PlacarPage({
|
||||
super.key,
|
||||
required this.gameId,
|
||||
required this.myTeam,
|
||||
required this.opponentTeam
|
||||
});
|
||||
|
||||
@override
|
||||
State<PlacarPage> createState() => _PlacarPageState();
|
||||
}
|
||||
|
||||
class _PlacarPageState extends State<PlacarPage> {
|
||||
late PlacarController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.landscapeRight,
|
||||
DeviceOrientation.landscapeLeft,
|
||||
]);
|
||||
|
||||
_controller = PlacarController(
|
||||
gameId: widget.gameId,
|
||||
myTeam: widget.myTeam,
|
||||
opponentTeam: widget.opponentTeam,
|
||||
onUpdate: () {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
);
|
||||
_controller.loadPlayers();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _buildFloatingFoulBtn(String label, Color color, String action, IconData icon, double left, double right, double top, double sf) {
|
||||
return Positioned(
|
||||
top: top,
|
||||
left: left > 0 ? left : null,
|
||||
right: right > 0 ? right : null,
|
||||
child: Draggable<String>(
|
||||
data: action,
|
||||
feedback: Material(
|
||||
color: Colors.transparent,
|
||||
child: CircleAvatar(
|
||||
radius: 30 * sf,
|
||||
backgroundColor: color.withOpacity(0.8),
|
||||
child: Icon(icon, color: Colors.white, size: 30 * sf)
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 27 * sf,
|
||||
backgroundColor: color,
|
||||
child: Icon(icon, color: Colors.white, size: 28 * sf),
|
||||
),
|
||||
SizedBox(height: 5 * sf),
|
||||
Text(label, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12 * sf)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCornerBtn({required String heroTag, required IconData icon, required Color color, required VoidCallback onTap, required double size, bool isLoading = false}) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: FloatingActionButton(
|
||||
heroTag: heroTag,
|
||||
backgroundColor: color,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14 * (size / 50))),
|
||||
elevation: 5,
|
||||
onPressed: isLoading ? null : onTap,
|
||||
child: isLoading
|
||||
? SizedBox(width: size * 0.45, height: size * 0.45, child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2.5))
|
||||
: Icon(icon, color: Colors.white, size: size * 0.55),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 👇 ATIVA O NOVO MAPA DE CALOR 👇
|
||||
void _showHeatmap(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => HeatmapDialog(
|
||||
shots: _controller.matchShots,
|
||||
myTeamName: _controller.myTeam,
|
||||
oppTeamName: _controller.opponentTeam,
|
||||
myPlayers: [..._controller.myCourt, ..._controller.myBench],
|
||||
oppPlayers: [..._controller.oppCourt, ..._controller.oppBench],
|
||||
playerStats: _controller.playerStats, // Passa os stats para mostrar os pontos
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double wScreen = MediaQuery.of(context).size.width;
|
||||
final double hScreen = MediaQuery.of(context).size.height;
|
||||
|
||||
final double sf = math.min(wScreen / 1150, hScreen / 720);
|
||||
final double cornerBtnSize = 48 * sf;
|
||||
|
||||
if (_controller.isLoading) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.placarDarkSurface,
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text("PREPARANDO O PAVILHÃO", style: TextStyle(color: Colors.white24, fontSize: 45 * sf, fontWeight: FontWeight.bold, letterSpacing: 2)),
|
||||
SizedBox(height: 35 * sf),
|
||||
StreamBuilder(
|
||||
stream: Stream.periodic(const Duration(seconds: 3)),
|
||||
builder: (context, snapshot) {
|
||||
List<String> frases = [
|
||||
"O Treinador está a desenhar a tática...",
|
||||
"A encher as bolas com ar de campeão...",
|
||||
"O árbitro está a testar o apito...",
|
||||
"A verificar se o cesto está nivelado...",
|
||||
"Os jogadores estão a terminar o aquecimento..."
|
||||
];
|
||||
String frase = frases[DateTime.now().second % frases.length];
|
||||
return Text(frase, style: TextStyle(color: AppTheme.actionPoints.withOpacity(0.7), fontSize: 26 * sf, fontStyle: FontStyle.italic));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.placarBackground,
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
child: IgnorePointer(
|
||||
ignoring: _controller.isSaving,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
margin: EdgeInsets.only(left: 65 * sf, right: 65 * sf, bottom: 55 * sf),
|
||||
decoration: BoxDecoration(border: Border.all(color: Colors.white, width: 2.5)),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final w = constraints.maxWidth;
|
||||
final h = constraints.maxHeight;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTapDown: (details) {
|
||||
if (_controller.isSelectingShotLocation) {
|
||||
_controller.registerShotLocation(context, details.localPosition, Size(w, h));
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage('assets/campo.png'),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (!_controller.isSelectingShotLocation) ...[
|
||||
Positioned(top: h * 0.25, left: w * 0.02, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[0], isOpponent: false, sf: sf)),
|
||||
Positioned(top: h * 0.68, left: w * 0.02, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[1], isOpponent: false, sf: sf)),
|
||||
Positioned(top: h * 0.45, left: w * 0.25, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[2], isOpponent: false, sf: sf)),
|
||||
Positioned(top: h * 0.15, left: w * 0.20, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[3], isOpponent: false, sf: sf)),
|
||||
Positioned(top: h * 0.80, left: w * 0.20, child: PlayerCourtCard(controller: _controller, name: _controller.myCourt[4], isOpponent: false, sf: sf)),
|
||||
|
||||
Positioned(top: h * 0.25, right: w * 0.02, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[0], isOpponent: true, sf: sf)),
|
||||
Positioned(top: h * 0.68, right: w * 0.02, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[1], isOpponent: true, sf: sf)),
|
||||
Positioned(top: h * 0.45, right: w * 0.25, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[2], isOpponent: true, sf: sf)),
|
||||
Positioned(top: h * 0.15, right: w * 0.20, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[3], isOpponent: true, sf: sf)),
|
||||
Positioned(top: h * 0.80, right: w * 0.20, child: PlayerCourtCard(controller: _controller, name: _controller.oppCourt[4], isOpponent: true, sf: sf)),
|
||||
],
|
||||
|
||||
if (!_controller.isSelectingShotLocation) ...[
|
||||
_buildFloatingFoulBtn("FALTA +", AppTheme.actionPoints, "add_foul", Icons.sports, w * 0.39, 0.0, h * 0.31, sf),
|
||||
_buildFloatingFoulBtn("FALTA -", AppTheme.actionMiss, "sub_foul", Icons.block, 0.0, w * 0.39, h * 0.31, sf),
|
||||
],
|
||||
|
||||
if (!_controller.isSelectingShotLocation)
|
||||
Positioned(
|
||||
top: (h * 0.32) + (40 * sf),
|
||||
left: 0, right: 0,
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
onTap: () => _controller.toggleTimer(context),
|
||||
child: CircleAvatar(
|
||||
radius: 68 * sf,
|
||||
backgroundColor: Colors.grey.withOpacity(0.5),
|
||||
child: Icon(_controller.isRunning ? Icons.pause : Icons.play_arrow, color: Colors.white, size: 58 * sf)
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(top: 0, left: 0, right: 0, child: Center(child: TopScoreboard(controller: _controller, sf: sf))),
|
||||
|
||||
if (!_controller.isSelectingShotLocation) Positioned(bottom: -10 * sf, left: 0, right: 0, child: ActionButtonsPanel(controller: _controller, sf: sf)),
|
||||
|
||||
if (_controller.isSelectingShotLocation)
|
||||
Positioned(
|
||||
top: h * 0.4, left: 0, right: 0,
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 35 * sf, vertical: 18 * sf),
|
||||
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(11 * sf), border: Border.all(color: Colors.white, width: 1.5 * sf)),
|
||||
child: Text("TOQUE NO CAMPO PARA MARCAR O LOCAL DO LANÇAMENTO", style: TextStyle(color: Colors.white, fontSize: 27 * sf, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
top: 50 * sf, left: 12 * sf,
|
||||
child: _buildCornerBtn(
|
||||
heroTag: 'btn_save_exit',
|
||||
icon: Icons.save_alt,
|
||||
color: AppTheme.oppTeamRed,
|
||||
size: cornerBtnSize,
|
||||
isLoading: _controller.isSaving,
|
||||
onTap: () async {
|
||||
await _controller.saveGameStats(context);
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
top: 50 * sf, right: 12 * sf,
|
||||
child: _buildCornerBtn(
|
||||
heroTag: 'btn_heatmap',
|
||||
icon: Icons.local_fire_department,
|
||||
color: Colors.orange.shade800,
|
||||
size: cornerBtnSize,
|
||||
onTap: () => _showHeatmap(context),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
bottom: 55 * sf, left: 12 * sf,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_controller.showMyBench) BenchPlayersList(controller: _controller, isOpponent: false, sf: sf),
|
||||
SizedBox(height: 12 * sf),
|
||||
_buildCornerBtn(heroTag: 'btn_sub_home', icon: Icons.swap_horiz, color: AppTheme.myTeamBlue, size: cornerBtnSize, onTap: () { _controller.showMyBench = !_controller.showMyBench; _controller.onUpdate(); }),
|
||||
SizedBox(height: 12 * sf),
|
||||
_buildCornerBtn(
|
||||
heroTag: 'btn_to_home',
|
||||
icon: Icons.timer,
|
||||
color: _controller.myTimeoutsUsed >= 3 ? Colors.grey : AppTheme.myTeamBlue,
|
||||
size: cornerBtnSize,
|
||||
onTap: _controller.myTimeoutsUsed >= 3
|
||||
? () => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: const Text('🛑 A equipa da casa já usou os 3 Timeouts deste período!'), backgroundColor: AppTheme.actionMiss))
|
||||
: () => _controller.useTimeout(false)
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
bottom: 55 * sf, right: 12 * sf,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_controller.showOppBench) BenchPlayersList(controller: _controller, isOpponent: true, sf: sf),
|
||||
SizedBox(height: 12 * sf),
|
||||
_buildCornerBtn(heroTag: 'btn_sub_away', icon: Icons.swap_horiz, color: AppTheme.oppTeamRed, size: cornerBtnSize, onTap: () { _controller.showOppBench = !_controller.showOppBench; _controller.onUpdate(); }),
|
||||
SizedBox(height: 12 * sf),
|
||||
_buildCornerBtn(
|
||||
heroTag: 'btn_to_away',
|
||||
icon: Icons.timer,
|
||||
color: _controller.opponentTimeoutsUsed >= 3 ? Colors.grey : AppTheme.oppTeamRed,
|
||||
size: cornerBtnSize,
|
||||
onTap: _controller.opponentTimeoutsUsed >= 3
|
||||
? () => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: const Text('🛑 A equipa visitante já usou os 3 Timeouts deste período!'), backgroundColor: AppTheme.actionMiss))
|
||||
: () => _controller.useTimeout(true)
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (_controller.isSaving)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 👇 O TEU MAPA DE CALOR: ADVERSÁRIO À ESQUERDA | TUA EQUIPA À DIREITA 👇
|
||||
// MAPA DE CALOR
|
||||
// ============================================================================
|
||||
class HeatmapDialog extends StatefulWidget {
|
||||
final List<ShotRecord> shots;
|
||||
@@ -864,21 +547,21 @@ class _HeatmapDialogState extends State<HeatmapDialog> {
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
// 👇 ESQUERDA: COLUNA DA EQUIPA ADVERSÁRIA 👇
|
||||
Expanded(
|
||||
child: _buildTeamColumn(
|
||||
teamName: widget.oppTeamName,
|
||||
players: widget.oppPlayers,
|
||||
teamColor: AppTheme.oppTeamRed, // Vermelho do Tema
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 👇 DIREITA: COLUNA DA TUA EQUIPA 👇
|
||||
// 👇 ESQUERDA: COLUNA DA TUA EQUIPA (AZUL) 👇
|
||||
Expanded(
|
||||
child: _buildTeamColumn(
|
||||
teamName: widget.myTeamName,
|
||||
players: widget.myPlayers,
|
||||
teamColor: AppTheme.myTeamBlue, // Azul do Tema
|
||||
teamColor: AppTheme.myTeamBlue,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 👇 DIREITA: COLUNA DA EQUIPA ADVERSÁRIA (VERMELHA) 👇
|
||||
Expanded(
|
||||
child: _buildTeamColumn(
|
||||
teamName: widget.oppTeamName,
|
||||
players: widget.oppPlayers,
|
||||
teamColor: AppTheme.oppTeamRed,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -899,7 +582,6 @@ class _HeatmapDialogState extends State<HeatmapDialog> {
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// CABEÇALHO DA EQUIPA (Botão para ver a equipa toda)
|
||||
InkWell(
|
||||
onTap: () => setState(() {
|
||||
_selectedTeam = teamName;
|
||||
@@ -926,8 +608,6 @@ class _HeatmapDialogState extends State<HeatmapDialog> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// LISTA DOS JOGADORES COM OS SEUS PONTOS
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
itemCount: realPlayers.length,
|
||||
@@ -945,7 +625,7 @@ class _HeatmapDialogState extends State<HeatmapDialog> {
|
||||
onTap: () => setState(() {
|
||||
_selectedTeam = teamName;
|
||||
_selectedPlayer = p;
|
||||
_isMapVisible = true; // Abre o mapa para este jogador!
|
||||
_isMapVisible = true;
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -956,9 +636,6 @@ class _HeatmapDialogState extends State<HeatmapDialog> {
|
||||
);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// TELA 2: O MAPA DE CALOR DESENHADO
|
||||
// ==========================================
|
||||
Widget _buildMapScreen(Color headerColor) {
|
||||
List<ShotRecord> filteredShots = widget.shots.where((s) {
|
||||
if (_selectedPlayer != 'Todos') return s.playerName == _selectedPlayer;
|
||||
@@ -973,7 +650,6 @@ class _HeatmapDialogState extends State<HeatmapDialog> {
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// CABEÇALHO COM BOTÃO VOLTAR
|
||||
Container(
|
||||
height: 40,
|
||||
color: headerColor,
|
||||
@@ -984,7 +660,7 @@ class _HeatmapDialogState extends State<HeatmapDialog> {
|
||||
Positioned(
|
||||
left: 8,
|
||||
child: InkWell(
|
||||
onTap: () => setState(() => _isMapVisible = false), // Botão de voltar ao menu de seleção!
|
||||
onTap: () => setState(() => _isMapVisible = false),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
|
||||
@@ -1005,7 +681,7 @@ class _HeatmapDialogState extends State<HeatmapDialog> {
|
||||
Positioned(
|
||||
right: 8,
|
||||
child: InkWell(
|
||||
onTap: () => Navigator.pop(context), // Fecha o popup todo
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle),
|
||||
@@ -1016,14 +692,17 @@ class _HeatmapDialogState extends State<HeatmapDialog> {
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// O DESENHO DO CAMPO E AS BOLAS
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Stack(
|
||||
children: [
|
||||
CustomPaint(size: Size(constraints.maxWidth, constraints.maxHeight), painter: HeatmapCourtPainter()),
|
||||
// 👇 A MÁGICA: O CAMPO DESENHADO IGUAL AO POP-UP (CustomPaint) 👇
|
||||
CustomPaint(
|
||||
size: Size(constraints.maxWidth, constraints.maxHeight),
|
||||
painter: HeatmapCourtPainter(),
|
||||
),
|
||||
// AS BOLINHAS DOS LANÇAMENTOS POR CIMA DAS LINHAS
|
||||
...filteredShots.map((shot) => Positioned(
|
||||
left: (shot.relativeX * constraints.maxWidth) - 8,
|
||||
top: (shot.relativeY * constraints.maxHeight) - 8,
|
||||
@@ -1043,6 +722,7 @@ class _HeatmapDialogState extends State<HeatmapDialog> {
|
||||
}
|
||||
}
|
||||
|
||||
// 👇 O PINTOR QUE DESENHA AS LINHAS PERFEITAS DO CAMPO 👇
|
||||
class HeatmapCourtPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
|
||||
@@ -118,8 +118,7 @@ class PersonCard extends StatelessWidget {
|
||||
height: 45,
|
||||
alignment: Alignment.center,
|
||||
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)),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -6,10 +6,14 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <gtk/gtk_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
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 =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin");
|
||||
gtk_plugin_register_with_registrar(gtk_registrar);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
gtk
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
@@ -6,13 +6,17 @@ import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import app_links
|
||||
import file_selector_macos
|
||||
import path_provider_foundation
|
||||
import shared_preferences_foundation
|
||||
import sqflite_darwin
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
||||
258
pubspec.lock
258
pubspec.lock
@@ -57,14 +57,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -89,6 +113,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -145,6 +177,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -158,6 +222,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -166,6 +238,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -216,6 +296,94 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -268,18 +436,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
version: "0.12.18"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -304,6 +472,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -425,7 +601,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
shared_preferences:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
||||
@@ -480,6 +656,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -493,6 +677,46 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -541,6 +765,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.12.0"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -553,10 +785,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.9"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -629,6 +861,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -36,6 +36,11 @@ dependencies:
|
||||
cupertino_icons: ^1.0.8
|
||||
provider: ^6.1.5+1
|
||||
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
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -7,11 +7,14 @@
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <app_links/app_links_plugin_c_api.h>
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
AppLinksPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
app_links
|
||||
file_selector_windows
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user