Compare commits

..

17 Commits

Author SHA1 Message Date
ce25fe6499 sabado 2026-04-18 02:56:44 +01:00
4f2a220cd6 corrigir os pixel no botao de faltas 2026-04-15 12:46:54 +01:00
fb85566e3f fazer pdf 2026-04-14 17:19:21 +01:00
2544e52636 eliminar e tentativa de partilhar 2026-04-04 01:28:47 +01:00
1b08ed7d07 tempo e reservas e outos 2026-04-03 01:33:29 +01:00
c6255759c5 assist 2026-03-25 09:48:25 +00:00
9cf7915d12 domingo 2026-03-22 16:16:08 +00:00
be103c66b0 orientação 2026-03-22 15:28:56 +00:00
00fee30792 sabado 2026-03-22 01:40:29 +00:00
6c89b7ab8c nao aparecer para outro utilizador 2026-03-19 10:40:53 +00:00
8adea3f7b6 bora vamos 2026-03-18 12:39:03 +00:00
b77ae2eac6 new branch 2026-03-18 01:52:29 +00:00
ed4cff34f6 Reinício do projeto: Estado atual completo das páginas 2026-03-18 01:36:30 +00:00
2a987e517b melhorar o calor 2026-03-17 10:35:38 +00:00
ec5bdc4867 git lixo 2 2026-03-16 23:25:48 +00:00
a4ef651d64 Conflitos resolvidos: mantido o código local 2026-03-16 22:28:41 +00:00
cf0a9a9890 vai te lixar github 2026-03-16 22:09:01 +00:00
47 changed files with 7028 additions and 2482 deletions

View File

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

View File

@@ -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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 564 KiB

BIN
assets/playmaker-logos.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -45,5 +45,12 @@
<true/>
<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>

115
lib/classe/theme.dart Normal file
View File

@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
class AppTheme {
static const Color primaryRed = Color(0xFFE74C3C);
static const Color backgroundLight = Color(0xFFF5F7FA);
static const Color surfaceWhite = Colors.white;
static const Color successGreen = Color(0xFF00C853);
static const Color warningAmber = Colors.amber;
static const Color placarBackground = Color(0xFF266174);
static const Color placarDarkSurface = Color(0xFF16202C);
static const Color placarTimerBg = Color(0xFF2C3E50);
static const Color placarListCard = Color(0xFF263238);
static const Color myTeamBlue = Color(0xFF1E5BB2);
static const Color oppTeamRed = Color(0xFFD92C2C);
static const Color actionPoints = Colors.orange;
static const Color actionMiss = Colors.redAccent;
static const Color actionSteal = Colors.green;
static const Color actionAssist = Colors.blueGrey;
static const Color actionRebound = Color(0xFF1E2A38);
static const Color actionBlock = Colors.deepPurple;
static const Color statPtsBg = Color(0xFF1565C0);
static const Color statAstBg = Color(0xFF2E7D32);
static const Color statRebBg = Color(0xFF6A1B9A);
static const Color statPieBg = Color.fromARGB(255, 22, 32, 44);
static const Color coachBg = Color(0xFFFFF9C4);
// =========================================================
// ☀️ TEMA CLARO
// =========================================================
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: primaryRed,
brightness: Brightness.light,
primary: primaryRed,
surface: backgroundLight,
),
appBarTheme: const AppBarTheme(
backgroundColor: backgroundLight,
foregroundColor: Colors.black87,
centerTitle: true,
elevation: 0.0,
),
// 👇 CORRETO: Classe CardThemeData
cardTheme: const CardThemeData(
color: surfaceWhite,
surfaceTintColor: Colors.transparent, // Evita o tom rosado do Material 3
elevation: 3.0,
margin: EdgeInsets.only(bottom: 12.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(15.0)),
side: BorderSide(color: Color(0xFFEEEEEE), width: 1.0),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: surfaceWhite,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12.0),
borderSide: const BorderSide(color: Color(0xFFE0E0E0)),
),
),
);
}
// =========================================================
// 🌙 MODO ESCURO
// =========================================================
static ThemeData get darkTheme {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: primaryRed,
brightness: Brightness.dark,
primary: primaryRed,
surface: const Color(0xFF1E1E1E),
),
scaffoldBackgroundColor: const Color(0xFF121212),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF121212),
foregroundColor: Colors.white,
centerTitle: true,
elevation: 0.0,
),
// 👇 CORRETO: Classe CardThemeData
cardTheme: const CardThemeData(
color: Color(0xFF1E1E1E),
surfaceTintColor: Colors.transparent,
elevation: 3.0,
margin: EdgeInsets.only(bottom: 12.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(15.0)),
side: BorderSide(color: Color(0xFF2C2C2C), width: 1.0),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFF1E1E1E),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12.0),
borderSide: const BorderSide(color: Color(0xFF2C2C2C)),
),
),
);
}
}

View File

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

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
class HomeController extends ChangeNotifier {
// Se precisar de estado para a home screen
int _selectedCardIndex = 0;
int get selectedCardIndex => _selectedCardIndex;
@@ -11,10 +10,8 @@ class HomeController extends ChangeNotifier {
notifyListeners();
}
// Métodos adicionais para lógica da home
void navigateToDetails(String playerName) {
print('Navegando para detalhes de $playerName');
// Implementar navegação
}
void refreshData() {

View File

@@ -1,28 +1,54 @@
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 Offset position;
final double relativeX;
final double relativeY;
final bool isMake;
ShotRecord(this.position, this.isMake);
final String playerId;
final String playerName;
final String? zone;
final int? points;
ShotRecord({
required this.relativeX,
required this.relativeY,
required this.isMake,
required this.playerId,
required this.playerName,
this.zone,
this.points,
});
Map<String, dynamic> toJson() => {
'relativeX': relativeX, 'relativeY': relativeY, 'isMake': isMake,
'playerId': playerId, 'playerName': playerName, 'zone': zone, 'points': points,
};
factory ShotRecord.fromJson(Map<String, dynamic> json) => ShotRecord(
relativeX: json['relativeX'], relativeY: json['relativeY'], isMake: json['isMake'],
playerId: json['playerId'], playerName: json['playerName'], zone: json['zone'], points: json['points'],
);
}
class PlacarController {
class PlacarController extends ChangeNotifier {
final String gameId;
final String 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;
int opponentScore = 0;
@@ -40,70 +66,72 @@ 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);
List<String> playByPlay = [];
ValueNotifier<Duration> durationNotifier = ValueNotifier(const Duration(minutes: 10));
Timer? timer;
bool isRunning = false;
// --- 🔄 CARREGAMENTO COMPLETO (DADOS REAIS + ESTATÍSTICAS SALVAS) ---
bool isCalibrating = false;
double hoopBaseX = 0.088;
double arcRadius = 0.459;
double cornerY = 0.440;
Future<void> loadPlayers() async {
final supabase = Supabase.instance.client;
try {
await Future.delayed(const Duration(milliseconds: 1500));
// 1. Limpar estados para evitar duplicação
myCourt.clear();
myBench.clear();
oppCourt.clear();
oppBench.clear();
playerStats.clear();
playerNumbers.clear();
playerDbIds.clear();
myFouls = 0;
opponentFouls = 0;
myCourt.clear(); myBench.clear(); oppCourt.clear(); oppBench.clear();
playerNames.clear(); playerStats.clear(); playerNumbers.clear();
matchShots.clear(); playByPlay.clear(); myFouls = 0; opponentFouls = 0;
// 2. Buscar dados básicos do JOGO
final gameResponse = await supabase.from('games').select().eq('id', gameId).single();
myScore = int.tryParse(gameResponse['my_score']?.toString() ?? '0') ?? 0;
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;
currentQuarter = int.tryParse(gameResponse['current_quarter']?.toString() ?? '1') ?? 1;
// 3. Buscar os IDs das equipas
gameWasAlreadyFinished = gameResponse['status'] == 'Terminado';
if (gameResponse['play_by_play'] != null) {
playByPlay = List<String>.from(gameResponse['play_by_play']);
} else {
playByPlay = [];
}
final teamsResponse = await supabase.from('teams').select('id, name').inFilter('name', [myTeam, opponentTeam]);
for (var t in teamsResponse) {
if (t['name'] == myTeam) myTeamDbId = t['id'];
if (t['name'] == opponentTeam) oppTeamDbId = t['id'];
}
// 4. Buscar os Jogadores
List<dynamic> myPlayers = myTeamDbId != null ? await supabase.from('members').select().eq('team_id', myTeamDbId!).eq('type', 'Jogador') : [];
List<dynamic> oppPlayers = oppTeamDbId != null ? await supabase.from('members').select().eq('team_id', oppTeamDbId!).eq('type', 'Jogador') : [];
// 5. BUSCAR ESTATÍSTICAS JÁ SALVAS
final statsResponse = await supabase.from('player_stats').select().eq('game_id', gameId);
final Map<String, dynamic> savedStats = {
for (var item in statsResponse) item['member_id'].toString(): item
};
// 6. Registar a tua equipa
for (int i = 0; i < myPlayers.length; i++) {
String dbId = myPlayers[i]['id'].toString();
String name = myPlayers[i]['name'].toString();
@@ -112,17 +140,12 @@ class PlacarController {
if (savedStats.containsKey(dbId)) {
var s = savedStats[dbId];
playerStats[name] = {
"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,
};
_loadSavedPlayerStats(dbId, s);
myFouls += (s['fls'] as int? ?? 0);
}
}
_padTeam(myCourt, myBench, "Jogador", isMyTeam: true);
// 7. Registar a equipa adversária
for (int i = 0; i < oppPlayers.length; i++) {
String dbId = oppPlayers[i]['id'].toString();
String name = oppPlayers[i]['name'].toString();
@@ -131,37 +154,68 @@ class PlacarController {
if (savedStats.containsKey(dbId)) {
var s = savedStats[dbId];
playerStats[name] = {
"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,
};
_loadSavedPlayerStats(dbId, s);
opponentFouls += (s['fls'] as int? ?? 0);
}
}
_padTeam(oppCourt, oppBench, "Adversário", isMyTeam: false);
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,
));
}
await _loadLocalBackup();
isLoading = false;
onUpdate();
notifyListeners();
} 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 _loadSavedPlayerStats(String dbId, Map<String, dynamic> s) {
playerStats[dbId] = {
"pts": s['pts'] ?? 0, "rbs": s['rbs'] ?? 0, "ast": s['ast'] ?? 0,
"stl": s['stl'] ?? 0, "tov": s['tov'] ?? 0, "blk": s['blk'] ?? 0,
"fls": s['fls'] ?? 0, "fgm": s['fgm'] ?? 0, "fga": s['fga'] ?? 0,
"ftm": s['ftm'] ?? 0, "fta": s['fta'] ?? 0, "orb": s['orb'] ?? 0, "drb": s['drb'] ?? 0,
"p2m": s['p2m'] ?? 0, "p2a": s['p2a'] ?? 0, "p3m": s['p3m'] ?? 0, "p3a": s['p3a'] ?? 0,
"so": s['so'] ?? 0, "il": s['il'] ?? 0, "li": s['li'] ?? 0,
"pa": s['pa'] ?? 0, "tres_s": s['tres_seg'] ?? 0, "dr": s['dr'] ?? 0,
"min": s['minutos_jogados'] ?? 0,
"sec": (s['minutos_jogados'] ?? 0) * 60,
};
}
void _registerPlayer({required String name, required String number, String? dbId, required bool isMyTeam, required bool isCourt}) {
if (playerNumbers.containsKey(name)) name = "$name (Opp)";
playerNumbers[name] = number;
if (dbId != null) playerDbIds[name] = dbId;
playerStats[name] = {"pts": 0, "rbs": 0, "ast": 0, "stl": 0, "tov": 0, "blk": 0, "fls": 0, "fgm": 0, "fga": 0};
String id = dbId ?? "fake_${DateTime.now().millisecondsSinceEpoch}_${math.Random().nextInt(9999)}";
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,
"p2m": 0, "p2a": 0, "p3m": 0, "p3a": 0,
"so": 0, "il": 0, "li": 0, "pa": 0, "tres_s": 0, "dr": 0,
"min": 0, "sec": 0
};
if (isMyTeam) {
if (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);
}
}
@@ -171,32 +225,93 @@ class PlacarController {
}
}
// --- TEMPO E TIMEOUTS ---
Future<void> _saveLocalBackup() async {
try {
final prefs = await SharedPreferences.getInstance();
final backupData = {
'myScore': myScore, 'opponentScore': opponentScore,
'myFouls': myFouls, 'opponentFouls': opponentFouls,
'currentQuarter': currentQuarter, 'duration': durationNotifier.value.inSeconds,
'myTimeoutsUsed': myTimeoutsUsed, 'opponentTimeoutsUsed': opponentTimeoutsUsed,
'playerStats': playerStats,
'myCourt': myCourt, 'myBench': myBench, 'oppCourt': oppCourt, 'oppBench': oppBench,
'matchShots': matchShots.map((s) => s.toJson()).toList(),
'playByPlay': playByPlay,
};
await prefs.setString('backup_$gameId', jsonEncode(backupData));
} catch (e) {
debugPrint("Erro no Auto-Save: $e");
}
}
Future<void> _loadLocalBackup() async {
try {
final prefs = await SharedPreferences.getInstance();
final String? backupString = prefs.getString('backup_$gameId');
if (backupString != null) {
final data = jsonDecode(backupString);
myScore = data['myScore']; opponentScore = data['opponentScore'];
myFouls = data['myFouls']; opponentFouls = data['opponentFouls'];
currentQuarter = data['currentQuarter']; durationNotifier.value = Duration(seconds: data['duration']);
myTimeoutsUsed = data['myTimeoutsUsed']; opponentTimeoutsUsed = data['opponentTimeoutsUsed'];
myCourt = List<String>.from(data['myCourt']); myBench = List<String>.from(data['myBench']);
oppCourt = List<String>.from(data['oppCourt']); oppBench = List<String>.from(data['oppBench']);
Map<String, dynamic> decodedStats = data['playerStats'];
playerStats = decodedStats.map((k, v) => MapEntry(k, Map<String, int>.from(v)));
List<dynamic> decodedShots = data['matchShots'];
matchShots = decodedShots.map((s) => ShotRecord.fromJson(s)).toList();
playByPlay = List<String>.from(data['playByPlay'] ?? []);
}
} catch (e) {
debugPrint("Erro ao carregar Auto-Save: $e");
}
}
void toggleTimer(BuildContext context) {
if (isRunning) {
timer?.cancel();
_saveLocalBackup();
} 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);
void addTimeToCourt(List<String> court) {
for (String id in court) {
if (playerStats.containsKey(id)) {
int currentSec = playerStats[id]!["sec"] ?? 0;
playerStats[id]!["sec"] = currentSec + 1;
playerStats[id]!["min"] = (currentSec + 1) ~/ 60;
}
}
}
addTimeToCourt(myCourt);
addTimeToCourt(oppCourt);
notifyListeners();
} else {
timer.cancel();
isRunning = false;
if (currentQuarter < 4) {
currentQuarter++;
duration = const Duration(minutes: 10);
myFouls = 0;
opponentFouls = 0;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Período $currentQuarter iniciado. Faltas resetadas!'), backgroundColor: Colors.blue));
} else {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('FIM DO JOGO!'), backgroundColor: Colors.red));
durationNotifier.value = const Duration(minutes: 10);
myFouls = 0; opponentFouls = 0;
myTimeoutsUsed = 0; opponentTimeoutsUsed = 0;
_saveLocalBackup();
}
notifyListeners();
}
onUpdate();
});
}
isRunning = !isRunning;
onUpdate();
notifyListeners();
}
void useTimeout(bool isOpponent) {
@@ -207,15 +322,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')}";
// --- LÓGICA DE JOGO & VALIDAÇÃO GEOMÉTRICA DE ZONAS ---
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));
@@ -224,207 +338,312 @@ 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();
}
void registerShotFromPopup(BuildContext context, String action, String targetPlayer, String zone, int points, double relativeX, double relativeY) {
String playerId = targetPlayer.replaceAll("player_my_", "").replaceAll("player_opp_", "");
bool isMake = action.startsWith("add_");
String name = playerNames[playerId] ?? "Jogador";
matchShots.add(ShotRecord(
relativeX: relativeX, relativeY: relativeY, isMake: isMake,
playerId: playerId, playerName: name, zone: zone, points: points
));
String finalAction = isMake ? "add_pts_$points" : "miss_$points";
commitStat(finalAction, targetPlayer);
notifyListeners();
}
// AGORA RECEBE CONTEXT E SIZE PARA A MATEMÁTICA
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");
// Validação
if (is3Pt || is2Pt) {
bool isValid = _validateShotZone(position, size, is3Pt);
if (!isValid) {
// Se a validação falhar, fudeo. Bloqueia.
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(' Local de lançamento incompatível com a pontuação.'),
backgroundColor: Colors.red,
duration: Duration(seconds: 2),
)
);
return; // Aborta!
}
if (!isValid) return;
}
bool isMake = pendingAction!.startsWith("add_pts_");
matchShots.add(ShotRecord(position, isMake));
commitStat(pendingAction!, pendingPlayer!);
double relX = position.dx / size.width;
double relY = position.dy / size.height;
String pId = pendingPlayerId!.replaceAll("player_my_", "").replaceAll("player_opp_", "");
isSelectingShotLocation = false;
pendingAction = null;
pendingPlayer = null;
onUpdate();
matchShots.add(ShotRecord(relativeX: relX, relativeY: relY, isMake: isMake, playerId: pId, playerName: playerNames[pId]!));
commitStat(pendingAction!, pendingPlayerId!);
isSelectingShotLocation = false; pendingAction = null; pendingPlayerId = null;
_saveLocalBackup();
notifyListeners();
}
// A MATEMÁTICA DA ZONA
bool _validateShotZone(Offset pos, Size size, bool is3Pt) {
double w = size.width;
double h = size.height;
bool _validateShotZone(Offset position, Size size, bool is3Pt) {
double relX = position.dx / size.width;
double relY = position.dy / size.height;
// Ajusta o 0.12 e 0.88 se os teus cestos na imagem estiverem mais para o lado
Offset leftHoop = Offset(w * 0.12, h * 0.5);
Offset rightHoop = Offset(w * 0.88, h * 0.5);
bool isLeftHalf = relX < 0.5;
double hoopX = isLeftHalf ? hoopBaseX : (1.0 - hoopBaseX);
double hoopY = 0.50;
// O raio da linha de 3 pontos (Brinca com este 0.28 se a área ficar muito grande ou pequena)
double threePointRadius = w * 0.28;
double aspectRatio = size.width / size.height;
double distFromCenterY = (relY - hoopY).abs();
Offset activeHoop = pos.dx < w / 2 ? leftHoop : rightHoop;
double distanceToHoop = (pos - activeHoop).distance;
bool isInside2Pts;
// Zonas de canto (onde a linha de 3 é reta)
bool isCorner3 = (pos.dy < h * 0.15 || pos.dy > h * 0.85) &&
(pos.dx < w * 0.20 || pos.dx > w * 0.80);
if (is3Pt) {
return distanceToHoop >= threePointRadius || isCorner3;
if (distFromCenterY > cornerY) {
double distToBaseline = isLeftHalf ? relX : (1.0 - relX);
isInside2Pts = distToBaseline <= hoopBaseX;
} else {
return distanceToHoop < threePointRadius && !isCorner3;
double dx = (relX - hoopX) * aspectRatio;
double dy = (relY - hoopY);
double distanceToHoop = math.sqrt((dx * dx) + (dy * dy));
isInside2Pts = distanceToHoop < arcRadius;
}
if (is3Pt) return !isInside2Pts;
return isInside2Pts;
}
void cancelShotLocation() {
isSelectingShotLocation = false;
pendingAction = null;
pendingPlayer = null;
onUpdate();
isSelectingShotLocation = false; pendingAction = null; pendingPlayerId = null; notifyListeners();
}
void registerFoul(String committerData, String foulType, String victimData) {
bool isOpponent = committerData.startsWith("player_opp_");
String committerId = committerData.replaceAll("player_my_", "").replaceAll("player_opp_", "");
final committerStats = playerStats[committerId]!;
final committerName = playerNames[committerId] ?? "Jogador";
committerStats["fls"] = committerStats["fls"]! + 1;
if (isOpponent) opponentFouls++; else myFouls++;
if (foulType == "Desqualificante") {
committerStats["fls"] = 5;
}
String logText = "cometeu Falta $foulType";
if (victimData.isNotEmpty) {
String victimId = victimData.replaceAll("player_my_", "").replaceAll("player_opp_", "");
final victimStats = playerStats[victimId]!;
final victimName = playerNames[victimId] ?? "Jogador";
victimStats["so"] = victimStats["so"]! + 1;
logText += " sobre $victimName ⚠️";
} else {
logText += " (Equipa/Banco) ⚠️";
}
String time = "${durationNotifier.value.inMinutes.toString().padLeft(2, '0')}:${durationNotifier.value.inSeconds.remainder(60).toString().padLeft(2, '0')}";
playByPlay.insert(0, "P$currentQuarter - $time: $committerName $logText");
_saveLocalBackup();
notifyListeners();
}
void commitStat(String action, String playerData) {
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]!;
final name = playerNames[playerId] ?? "Jogador";
String logText = "";
if (action.startsWith("add_pts_")) {
int pts = int.parse(action.split("_").last);
if (isOpponent) opponentScore += pts; else myScore += pts;
stats["pts"] = stats["pts"]! + pts;
if (pts == 2 || pts == 3) { stats["fgm"] = stats["fgm"]! + 1; stats["fga"] = stats["fga"]! + 1; }
if (pts == 2) { stats["fgm"] = stats["fgm"]! + 1; stats["fga"] = stats["fga"]! + 1; stats["p2m"] = stats["p2m"]! + 1; stats["p2a"] = stats["p2a"]! + 1; }
if (pts == 3) { stats["fgm"] = stats["fgm"]! + 1; stats["fga"] = stats["fga"]! + 1; stats["p3m"] = stats["p3m"]! + 1; stats["p3a"] = stats["p3a"]! + 1; }
if (pts == 1) { stats["ftm"] = stats["ftm"]! + 1; stats["fta"] = stats["fta"]! + 1; }
logText = "marcou $pts pontos 🏀";
}
else if (action.startsWith("sub_pts_")) {
int pts = int.parse(action.split("_").last);
if (isOpponent) { opponentScore = (opponentScore - pts < 0) ? 0 : opponentScore - pts; }
else { myScore = (myScore - pts < 0) ? 0 : myScore - pts; }
stats["pts"] = (stats["pts"]! - pts < 0) ? 0 : stats["pts"]! - pts;
if (pts == 2 || pts == 3) {
if (stats["fgm"]! > 0) stats["fgm"] = stats["fgm"]! - 1;
if (stats["fga"]! > 0) stats["fga"] = stats["fga"]! - 1;
int ptsToAnul = int.parse(action.split("_").last);
int lastShotIndex = matchShots.lastIndexWhere((s) =>
s.playerId == playerId &&
s.isMake == true &&
s.points == ptsToAnul
);
if (lastShotIndex != -1) {
matchShots.removeAt(lastShotIndex);
if (isOpponent) opponentScore -= ptsToAnul; else myScore -= ptsToAnul;
stats["pts"] = stats["pts"]! - ptsToAnul;
if (ptsToAnul == 2) {
if(stats["fgm"]! > 0) stats["fgm"] = stats["fgm"]! - 1;
if(stats["fga"]! > 0) stats["fga"] = stats["fga"]! - 1;
if(stats["p2m"]! > 0) stats["p2m"] = stats["p2m"]! - 1;
if(stats["p2a"]! > 0) stats["p2a"] = stats["p2a"]! - 1;
} else if (ptsToAnul == 3) {
if(stats["fgm"]! > 0) stats["fgm"] = stats["fgm"]! - 1;
if(stats["fga"]! > 0) stats["fga"] = stats["fga"]! - 1;
if(stats["p3m"]! > 0) stats["p3m"] = stats["p3m"]! - 1;
if(stats["p3a"]! > 0) stats["p3a"] = stats["p3a"]! - 1;
} else if (ptsToAnul == 1) {
if(stats["ftm"]! > 0) stats["ftm"] = stats["ftm"]! - 1;
if(stats["fta"]! > 0) stats["fta"] = stats["fta"]! - 1;
}
logText = "anulou cesto de $ptsToAnul pts ⏪";
} else {
return;
}
}
else if (action == "miss_2" || action == "miss_3") { stats["fga"] = stats["fga"]! + 1; }
else if (action == "add_rbs") { stats["rbs"] = stats["rbs"]! + 1; }
else if (action == "add_ast") { stats["ast"] = stats["ast"]! + 1; }
else if (action == "add_stl") { stats["stl"] = stats["stl"]! + 1; }
else if (action == "add_tov") { stats["tov"] = stats["tov"]! + 1; }
else if (action == "add_blk") { stats["blk"] = stats["blk"]! + 1; }
else if (action == "add_foul") {
stats["fls"] = stats["fls"]! + 1;
if (isOpponent) { opponentFouls++; } else { myFouls++; }
}
else if (action == "miss_1") { stats["fta"] = stats["fta"]! + 1; logText = "falhou lance livre ❌"; }
else if (action == "miss_2") { stats["fga"] = stats["fga"]! + 1; stats["p2a"] = stats["p2a"]! + 1; logText = "falhou lançamento de 2 ❌"; }
else if (action == "miss_3") { stats["fga"] = stats["fga"]! + 1; stats["p3a"] = stats["p3a"]! + 1; logText = "falhou lançamento de 3 ❌"; }
else if (action == "add_orb") { stats["orb"] = stats["orb"]! + 1; stats["rbs"] = stats["rbs"]! + 1; logText = "ganhou ressalto ofensivo 🔄"; }
else if (action == "add_drb") { stats["drb"] = stats["drb"]! + 1; stats["rbs"] = stats["rbs"]! + 1; logText = "ganhou ressalto defensivo 🛡️"; }
else if (action == "add_ast") { stats["ast"] = stats["ast"]! + 1; logText = "fez uma assistência 🤝"; }
else if (action == "add_stl") { stats["stl"] = stats["stl"]! + 1; logText = "roubou a bola 🥷"; }
else if (action == "add_blk") { stats["blk"] = stats["blk"]! + 1; logText = "fez um desarme (bloco) ✋"; }
else if (action == "add_so") { stats["so"] = stats["so"]! + 1; logText = "sofreu uma falta 🤕"; }
else if (action == "add_il") { stats["il"] = stats["il"]! + 1; logText = "intercetou um lançamento 🛑"; }
else if (action == "add_li") { stats["li"] = stats["li"]! + 1; logText = "teve o lançamento intercetado 🚫"; }
else if (action == "add_tov") { stats["tov"] = stats["tov"]! + 1; logText = "fez um passe ruim 🤦"; }
else if (action == "add_pa") { stats["pa"] = stats["pa"]! + 1; stats["tov"] = stats["tov"]! + 1; logText = "cometeu passos 🚶"; }
else if (action == "add_3s") { stats["tres_s"] = stats["tres_s"]! + 1; stats["tov"] = stats["tov"]! + 1; logText = "violação de 3 segundos ⏱️"; }
else if (action == "add_24s") { stats["tov"] = stats["tov"]! + 1; logText = "violação de 24 segundos ⏱️"; }
else if (action == "add_dr") { stats["dr"] = stats["dr"]! + 1; stats["tov"] = stats["tov"]! + 1; logText = "fez drible duplo 🏀"; }
else if (action == "sub_foul") {
if (stats["fls"]! > 0) stats["fls"] = stats["fls"]! - 1;
if (isOpponent) { if (opponentFouls > 0) opponentFouls--; } else { if (myFouls > 0) myFouls--; }
}
logText = "teve falta anulada 🔄";
}
if (logText.isNotEmpty) {
String time = "${durationNotifier.value.inMinutes.toString().padLeft(2, '0')}:${durationNotifier.value.inSeconds.remainder(60).toString().padLeft(2, '0')}";
playByPlay.insert(0, "P$currentQuarter - $time: $name $logText");
}
_saveLocalBackup();
notifyListeners();
}
// --- 💾 FUNÇÃO PARA GUARDAR DADOS NA BD ---
Future<void> saveGameStats(BuildContext context) async {
final supabase = Supabase.instance.client;
isSaving = true;
onUpdate();
notifyListeners();
try {
bool isGameFinishedNow = currentQuarter >= 4 && durationNotifier.value.inSeconds == 0;
String newStatus = isGameFinishedNow ? 'Terminado' : 'Pausado';
String topPtsName = '---'; int maxPts = -1;
String topAstName = '---'; int maxAst = -1;
String topRbsName = '---'; int maxRbs = -1;
String mvpName = '---'; double maxMvpScore = -999.0;
playerStats.forEach((playerId, stats) {
int pts = stats['pts'] ?? 0;
int ast = stats['ast'] ?? 0;
int rbs = stats['rbs'] ?? 0;
double minJogados = (stats['sec'] ?? 0) / 60.0;
if (minJogados <= 0) minJogados = 40.0;
int tr = rbs;
int br = stats['stl'] ?? 0;
int bp = stats['tov'] ?? 0;
int lFalhados = (stats['fga'] ?? 0) - (stats['fgm'] ?? 0);
int llFalhados = (stats['fta'] ?? 0) - (stats['ftm'] ?? 0);
double mvpScore = ((pts * 0.30) + (tr * 0.20) + (ast * 0.35) + (br * 0.15)) -
((bp * 0.35) + (lFalhados * 0.30) + (llFalhados * 0.35));
mvpScore = mvpScore * (minJogados / 40.0);
String pName = playerNames[playerId] ?? '---';
if (pts > maxPts && pts > 0) { maxPts = pts; topPtsName = '$pName ($pts)'; }
if (ast > maxAst && ast > 0) { maxAst = ast; topAstName = '$pName ($ast)'; }
if (rbs > maxRbs && rbs > 0) { maxRbs = rbs; topRbsName = '$pName ($rbs)'; }
if (mvpScore > maxMvpScore) { maxMvpScore = mvpScore; mvpName = '$pName (${mvpScore.toStringAsFixed(1)})'; }
});
await supabase.from('games').update({
'my_score': myScore,
'opponent_score': opponentScore,
'remaining_seconds': duration.inSeconds,
'my_timeouts': myTimeoutsUsed,
'opp_timeouts': opponentTimeoutsUsed,
'current_quarter': currentQuarter,
'status': currentQuarter >= 4 && duration.inSeconds == 0 ? 'Terminado' : 'Pausado',
'my_score': myScore, 'opponent_score': opponentScore,
'remaining_seconds': durationNotifier.value.inSeconds,
'my_timeouts': myTimeoutsUsed, 'opp_timeouts': opponentTimeoutsUsed,
'current_quarter': currentQuarter, 'status': newStatus,
'top_pts_name': topPtsName, 'top_ast_name': topAstName,
'top_rbs_name': topRbsName, 'mvp_name': mvpName,
'play_by_play': playByPlay,
}).eq('id', gameId);
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);
String teamId = isMyTeamPlayer ? myTeamDbId! : oppTeamDbId!;
playerStats.forEach((playerId, stats) {
if (!playerId.startsWith("fake_")) {
bool isMyTeamPlayer = myCourt.contains(playerId) || myBench.contains(playerId);
batchStats.add({
'game_id': gameId,
'member_id': memberDbId,
'team_id': teamId,
'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'],
'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'], 'p2m': stats['p2m'], 'p2a': stats['p2a'],
'p3m': stats['p3m'], 'p3a': stats['p3a'],
'so': stats['so'], 'il': stats['il'], 'li': stats['li'], 'pa': stats['pa'], 'tres_seg': stats['tres_s'],
'dr': stats['dr'], 'minutos_jogados': stats['min'],
});
}
});
await supabase.from('player_stats').delete().eq('game_id', gameId);
if (batchStats.isNotEmpty) await supabase.from('player_stats').insert(batchStats);
if (batchStats.isNotEmpty) {
await supabase.from('player_stats').insert(batchStats);
}
final prefs = await SharedPreferences.getInstance();
await prefs.remove('backup_$gameId');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Estatísticas guardadas com Sucesso!'), backgroundColor: Colors.green));
}
if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Guardado 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();
}
}

View File

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

View File

@@ -1,38 +1,61 @@
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)
// Adicionei o .map() no final para garantir que o Dart entende que é uma List<Map>
Stream<List<Map<String, dynamic>>> get teamsStream {
final userId = _supabase.auth.currentUser?.id;
if (userId == null) return const Stream.empty();
return _supabase
.from('teams')
.stream(primaryKey: ['id'])
.order('name', ascending: true)
.map((data) => List<Map<String, dynamic>>.from(data));
.eq('user_id', userId); // ✅ Bem feito, este já estava certo!
}
// 2. CRIAR
// Alterei imageUrl para String? (pode ser nulo) para evitar erros se não houver imagem
Future<void> createTeam(String name, String season, String? imageUrl) async {
// 2. CRIAR (Agora guarda o dono da equipa!)
Future<void> createTeam(String name, String season, File? imageFile) async {
try {
final userId = _supabase.auth.currentUser?.id;
if (userId == null) throw Exception("Utilizador não autenticado.");
String? uploadedImageUrl;
// Se o utilizador escolheu uma imagem, fazemos o upload primeiro
if (imageFile != null) {
final fileName = '${userId}_${DateTime.now().millisecondsSinceEpoch}.png';
final storagePath = 'teams/$fileName';
await _supabase.storage.from('avatars').upload(
storagePath,
imageFile,
fileOptions: const FileOptions(cacheControl: '3600', upsert: true)
);
uploadedImageUrl = _supabase.storage.from('avatars').getPublicUrl(storagePath);
}
// Agora insere a equipa na base de dados com o ID DO DONO!
await _supabase.from('teams').insert({
'user_id': userId, // 👈 CRUCIAL: Diz à base de dados de quem é esta equipa!
'name': name,
'season': season,
'image_url': imageUrl,
'image_url': uploadedImageUrl ?? '',
'is_favorite': false,
});
print("✅ Equipa guardada no Supabase!");
print("✅ Equipa guardada no Supabase com dono associado!");
} catch (e) {
print("❌ Erro ao criar: $e");
print("❌ Erro ao criar equipa: $e");
}
}
// 3. ELIMINAR
Future<void> deleteTeam(String id) async {
try {
// Como segurança extra, podemos garantir que só apaga se for o dono (opcional se tiveres RLS no Supabase)
await _supabase.from('teams').delete().eq('id', id);
} catch (e) {
print("❌ Erro ao eliminar: $e");
@@ -44,28 +67,36 @@ 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");
}
}
// 5. CONTAR JOGADORES
// CORRIGIDO: A sintaxe antiga dava erro. O método .count() é o correto agora.
// 5. CONTAR JOGADORES (LEITURA ÚNICA)
Future<int> getPlayerCount(String teamId) async {
try {
final count = await _supabase
.from('members')
.count() // Retorna diretamente o número inteiro
.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;
}
}
// Mantemos o dispose vazio para não quebrar a chamada na TeamsPage
// 6. VIEW DAS EQUIPAS (AQUI ESTAVA O TEU ERRO DE LISTAGEM!)
Future<List<Map<String, dynamic>>> getTeamsWithStats() async {
final userId = _supabase.auth.currentUser?.id;
if (userId == null) return []; // Retorna lista vazia se não houver login
final data = await _supabase
.from('teams_with_stats')
.select('*')
.eq('user_id', userId) // 👈 CRUCIAL: Só puxa as estatísticas das tuas equipas!
.order('name', ascending: true);
return List<Map<String, dynamic>>.from(data);
}
void dispose() {}
}

View File

@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:playmaker/grafico%20de%20pizza/dados_grafico.dart';
import 'package:flutter/material.dart';
import '../dados_grafico.dart';
class PieChartController extends ChangeNotifier {
PieChartData _chartData = PieChartData(victories: 25, defeats: 10);
class PieChartController extends ChangeNotifier {
PieChartData _chartData = const PieChartData(victories: 0, defeats: 0, draws: 0);
PieChartData get chartData => _chartData;
@@ -15,15 +15,7 @@
notifyListeners();
}
void incrementVictories() {
updateData(victories: _chartData.victories + 1);
}
void incrementDefeats() {
updateData(defeats: _chartData.defeats + 1);
}
void reset() {
updateData(victories: 0, defeats: 0, draws: 0);
}
}
}

View File

@@ -22,5 +22,6 @@ class PieChartData {
'total': total,
'victoryPercentage': victoryPercentage,
'defeatPercentage': defeatPercentage,
'drawPercentage': drawPercentage,
};
}

View File

@@ -1,22 +1,29 @@
import 'package:flutter/material.dart';
import 'package:playmaker/grafico%20de%20pizza/widgets/grafico_widgets.dart';
import 'dados_grafico.dart';
import 'controllers/contollers_grafico.dart';
import 'package:playmaker/classe/home.config.dart';
import 'package:playmaker/classe/theme.dart'; // 👇 IMPORT DO TEMA ADICIONADO PARA USARMOS O primaryRed
import 'dart:math' as math;
class PieChartCard extends StatefulWidget {
final PieChartController? controller;
final int victories;
final int defeats;
final int draws;
final String title;
final String subtitle;
final Color backgroundColor;
final Color? backgroundColor;
final VoidCallback? onTap;
final double sf;
const PieChartCard({
super.key,
this.controller,
this.victories = 0,
this.defeats = 0,
this.draws = 0,
this.title = 'DESEMPENHO',
this.subtitle = 'Vitórias vs Derrotas',
this.onTap, required this.backgroundColor,
this.subtitle = 'Temporada',
this.onTap,
this.backgroundColor,
this.sf = 1.0,
});
@override
@@ -24,30 +31,26 @@ class PieChartCard extends StatefulWidget {
}
class _PieChartCardState extends State<PieChartCard> with SingleTickerProviderStateMixin {
late PieChartController _controller;
late AnimationController _animationController;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = widget.controller ?? PieChartController();
_animationController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
_animation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutBack,
),
);
_animationController = AnimationController(duration: const Duration(milliseconds: 600), vsync: this);
_animation = Tween<double>(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeOutBack));
_animationController.forward();
}
@override
void didUpdateWidget(PieChartCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.victories != widget.victories || oldWidget.defeats != widget.defeats || oldWidget.draws != widget.draws) {
_animationController.reset();
_animationController.forward();
}
}
@override
void dispose() {
_animationController.dispose();
@@ -56,349 +59,181 @@ class _PieChartCardState extends State<PieChartCard> with SingleTickerProviderSt
@override
Widget build(BuildContext context) {
final data = _controller.chartData;
final data = PieChartData(victories: widget.victories, defeats: widget.defeats, draws: widget.draws);
// 👇 BLINDAGEM DO FUNDO E DO TEXTO PARA MODO CLARO/ESCURO
final Color cardColor = widget.backgroundColor ?? Theme.of(context).cardTheme.color ?? (Theme.of(context).brightness == Brightness.dark ? const Color(0xFF1E1E1E) : Colors.white);
final Color textColor = Theme.of(context).colorScheme.onSurface;
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Transform.scale(
scale: 0.95 + (_animation.value * 0.05),
child: Opacity(
opacity: _animation.value,
child: child,
),
child: Opacity(opacity: _animation.value.clamp(0.0, 1.0), child: child),
);
},
child: Container(
width: HomeConfig.cardwidthPadding,
height: HomeConfig.cardheightPadding,
child: Card(
elevation: 0,
margin: EdgeInsets.zero,
elevation: 0, // Ajustado para não ter sombra dupla, já que o tema pode ter
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(color: Colors.grey.withOpacity(0.15)), // Borda suave igual ao resto da app
),
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(20),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
widget.backgroundColor.withOpacity(0.9),
widget.backgroundColor.withOpacity(0.7),
],
color: cardColor, // 👇 APLICA A COR BLINDADA
),
),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: LayoutBuilder(
builder: (context, constraints) {
final double ch = constraints.maxHeight;
final double cw = constraints.maxWidth;
return Padding(
padding: EdgeInsets.symmetric(horizontal: cw * 0.05, vertical: ch * 0.03),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Cabeçalho compacto
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.title,
// --- CABEÇALHO --- (👇 MANTIDO ALINHADO À ESQUERDA)
FittedBox(
fit: BoxFit.scaleDown,
child: Text(widget.title.toUpperCase(),
style: TextStyle(
fontSize: 14, // Pequeno
fontSize: ch * 0.045,
fontWeight: FontWeight.bold,
color: Colors.white.withOpacity(0.9),
letterSpacing: 1.5,
color: AppTheme.primaryRed, // 👇 USANDO O TEU primaryRed
letterSpacing: 1.2
)
),
),
SizedBox(height: 2), // Muito pequeno
Text(
widget.subtitle,
Text(widget.subtitle,
style: TextStyle(
fontSize: 16, // Moderado
fontSize: ch * 0.055,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
],
),
Container(
padding: EdgeInsets.all(6), // Pequeno
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.8),
shape: BoxShape.circle,
),
child: Icon(
Icons.pie_chart,
size: 18, // Pequeno
color: Colors.white,
),
),
],
color: AppTheme.backgroundLight, // 👇 USANDO O TEU backgroundLight
)
),
SizedBox(height: 8), // Pequeno espaço
const Expanded(flex: 1, child: SizedBox()),
// Conteúdo principal - COMPACTO E CONTROLADO
// --- MIOLO (GRÁFICO MAIOR À ESQUERDA + STATS) ---
Expanded(
flex: 9,
child: Row(
mainAxisAlignment: MainAxisAlignment.end, // Changed from spaceBetween to end to push stats more to the right
children: [
// Gráfico de pizza
Expanded(
flex: 3,
child: Center(
// 1. Lado Esquerdo: Donut Chart
// 👇 MUDANÇA AQUI: Gráfico ainda maior! cw * 0.52
SizedBox(
width: cw * 0.52,
height: cw * 0.52,
child: PieChartWidget(
victoryPercentage: data.victoryPercentage,
defeatPercentage: data.defeatPercentage,
drawPercentage: data.drawPercentage,
size: 130, // Pequeno para caber
),
sf: widget.sf,
),
),
SizedBox(width: 8), // Pequeno espaço
SizedBox(width: cw * 0.005), // Reduzi o espaço no meio para dar lugar ao gráfico
// Estatísticas - EXTREMAMENTE COMPACTAS
// 2. Lado Direito: Números Dinâmicos
Expanded(
flex: 2,
child: FittedBox(
alignment: Alignment.centerRight, // Encosta os números à direita
fit: BoxFit.scaleDown,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Vitórias - Layout horizontal ultra compacto
Container(
margin: EdgeInsets.only(bottom: 6), // Muito pequeno
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Número
Text(
data.victories.toString(),
style: TextStyle(
fontSize: 26, // Pequeno mas legível
fontWeight: FontWeight.bold,
color: Colors.green,
height: 0.8,
),
),
SizedBox(width: 6), // Pequeno
// Info compacta
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end, // Alinha os números à direita para ficar arrumado
children: [
Row(
children: [
_buildDynStatRow("VIT", data.victories.toString(), (data.victoryPercentage * 100).toStringAsFixed(0), Colors.green, textColor, ch, cw),
_buildDynStatRow("EMP", data.draws.toString(), (data.drawPercentage * 100).toStringAsFixed(0), Colors.amber, textColor, ch, cw),
_buildDynStatRow("DER", data.defeats.toString(), (data.defeatPercentage * 100).toStringAsFixed(0), Colors.red, textColor, ch, cw),
_buildDynDivider(cw, textColor),
_buildDynStatRow("TOT", data.total.toString(), "100", textColor, textColor, ch, cw),
],
),
),
),
],
),
),
const Expanded(flex: 1, child: SizedBox()),
// --- RODAPÉ: BOTÃO WIN RATE GIGANTE --- (👇 MUDANÇA AQUI: Alinhado à esquerda)
Container(
width: 8,
height: 8,
margin: EdgeInsets.only(right: 4),
width: double.infinity,
padding: EdgeInsets.symmetric(vertical: ch * 0.025),
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
),
),
Text(
'VIT',
style: TextStyle(
fontSize: 10, // Muito pequeno
color: Colors.white.withOpacity(0.8),
fontWeight: FontWeight.w600,
),
),
],
),
SizedBox(height: 2),
Text(
'${(data.victoryPercentage * 100).toStringAsFixed(0)}%',
style: TextStyle(
fontSize: 12, // Pequeno
color: Colors.green,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
),
// Divisor sutil
Container(
height: 0.5,
color: Colors.white.withOpacity(0.1),
margin: EdgeInsets.symmetric(vertical: 4),
),
// Derrotas
Container(
margin: EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
data.defeats.toString(),
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Colors.red,
height: 0.8,
),
),
SizedBox(width: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Container(
width: 8,
height: 8,
margin: EdgeInsets.only(right: 4),
decoration: BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
),
Text(
'DER',
style: TextStyle(
fontSize: 10,
color: Colors.white.withOpacity(0.8),
fontWeight: FontWeight.w600,
),
),
],
),
SizedBox(height: 2),
Text(
'${(data.defeatPercentage * 100).toStringAsFixed(0)}%',
style: TextStyle(
fontSize: 12,
color: Colors.red,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
),
// Divisor sutil
Container(
height: 0.5,
color: Colors.white.withOpacity(0.1),
margin: EdgeInsets.symmetric(vertical: 4),
),
// Total
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
data.total.toString(),
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Colors.white,
height: 0.8,
),
),
SizedBox(width: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Container(
width: 8,
height: 8,
margin: EdgeInsets.only(right: 4),
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
Text(
'TOT',
style: TextStyle(
fontSize: 10,
color: Colors.white.withOpacity(0.8),
fontWeight: FontWeight.w600,
),
),
],
),
SizedBox(height: 2),
Text(
'100%',
style: TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
],
),
),
],
),
),
SizedBox(height: 10), // Espaço controlado
// Win rate - Sempre visível e não sobreposto
Container(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
color: textColor.withOpacity(0.05), // 👇 Fundo adaptável
borderRadius: BorderRadius.circular(12),
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.start, // 👇 MUDANÇA AQUI: Letras mais para a esquerda!
children: [
Icon(
data.victoryPercentage > 0.5
? Icons.trending_up
: Icons.trending_down,
color: data.victoryPercentage > 0.5
? Colors.green
: Colors.red,
size: 18, // Pequeno
),
SizedBox(width: 8),
Text(
'Win Rate: ${(data.victoryPercentage * 100).toStringAsFixed(1)}%',
Icon(Icons.stars, color: Colors.green, size: ch * 0.075),
const SizedBox(width: 10),
Text('WIN RATE: ${(data.victoryPercentage * 100).toStringAsFixed(1)}%',
style: TextStyle(
fontSize: 14, // Pequeno
fontWeight: FontWeight.bold,
color: Colors.white,
color: AppTheme.backgroundLight,
fontWeight: FontWeight.w900,
letterSpacing: 1.0,
fontSize: ch * 0.06
),
),
],
),
),
),
],
),
),
);
}
),
),
),
),
);
}
// 👇 Ajustei a linha de stats para alinhar melhor agora que os números estão encostados à direita
Widget _buildDynStatRow(String label, String number, String percent, Color statColor, Color textColor, double ch, double cw) {
return Padding(
padding: EdgeInsets.symmetric(vertical: ch * 0.005),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: cw * 0.12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: TextStyle(fontSize: ch * 0.045, color: textColor.withOpacity(0.6), fontWeight: FontWeight.bold)), // 👇 TEXTO ADAPTÁVEL (increased from 0.035)
Text('$percent%', style: TextStyle(fontSize: ch * 0.05, color: statColor, fontWeight: FontWeight.bold)), // (increased from 0.04)
],
),
),
SizedBox(width: cw * 0.03),
Text(number, style: TextStyle(fontSize: ch * 0.15, fontWeight: FontWeight.w900, color: statColor, height: 1)), // (increased from 0.125)
],
),
);
}
Widget _buildDynDivider(double cw, Color textColor) {
return Container(
width: cw * 0.35,
height: 1.5,
color: textColor.withOpacity(0.2), // 👇 LINHA ADAPTÁVEL
margin: const EdgeInsets.symmetric(vertical: 4)
);
}
}

View File

@@ -5,65 +5,80 @@ class PieChartWidget extends StatelessWidget {
final double victoryPercentage;
final double defeatPercentage;
final double drawPercentage;
final double size;
final double sf;
const PieChartWidget({
super.key,
required this.victoryPercentage,
required this.defeatPercentage,
this.drawPercentage = 0,
this.size = 140, // Aumentado para 400x300
required this.sf,
});
@override
Widget build(BuildContext context) {
return SizedBox(
return LayoutBuilder(
builder: (context, constraints) {
final double w = constraints.maxWidth.isInfinite ? 100.0 : constraints.maxWidth;
final double h = constraints.maxHeight.isInfinite ? 100.0 : constraints.maxHeight;
final double size = math.min(w, h);
return Center(
child: SizedBox(
width: size,
height: size,
child: CustomPaint(
painter: _PieChartPainter(
painter: _DonutChartPainter(
victoryPercentage: victoryPercentage,
defeatPercentage: defeatPercentage,
drawPercentage: drawPercentage,
),
child: _buildCenterLabels(),
child: Center(
child: _buildCenterLabels(size),
),
),
),
);
},
);
}
Widget _buildCenterLabels() {
return Center(
child: Column(
Widget _buildCenterLabels(double size) {
final bool hasGames = victoryPercentage > 0 || defeatPercentage > 0 || drawPercentage > 0;
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${(victoryPercentage * 100).toStringAsFixed(1)}%',
// 👇 Casa decimal aplicada aqui!
hasGames ? '${(victoryPercentage * 100).toStringAsFixed(1)}%' : '---',
style: TextStyle(
fontSize: size * 0.2, // Tamanho responsivo (28px para 140px)
fontSize: size * (hasGames ? 0.20 : 0.15),
fontWeight: FontWeight.bold,
color: Colors.white,
color: hasGames ? Colors.white : Colors.white54,
),
),
SizedBox(height: 4),
SizedBox(height: size * 0.02),
Text(
'Vitórias',
hasGames ? 'Vitórias' : 'Sem Jogos',
style: TextStyle(
fontSize: size * 0.1, // Tamanho responsivo (14px para 140px)
color: Colors.white.withOpacity(0.8),
fontSize: size * 0.08,
color: hasGames ? Colors.white70 : Colors.white38,
),
),
],
),
);
}
}
class _PieChartPainter extends CustomPainter {
class _DonutChartPainter extends CustomPainter {
final double victoryPercentage;
final double defeatPercentage;
final double drawPercentage;
_PieChartPainter({
_DonutChartPainter({
required this.victoryPercentage,
required this.defeatPercentage,
required this.drawPercentage,
@@ -72,71 +87,40 @@ class _PieChartPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 5;
final radius = (size.width / 2) - (size.width * 0.1);
final strokeWidth = size.width * 0.2;
if (victoryPercentage == 0 && defeatPercentage == 0 && drawPercentage == 0) {
final bgPaint = Paint()
..color = Colors.white.withOpacity(0.05)
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;
canvas.drawCircle(center, radius, bgPaint);
return;
}
// Cores
const victoryColor = Colors.green;
const defeatColor = Colors.red;
const drawColor = Colors.yellow;
const borderColor = Colors.white30;
const drawColor = Colors.amber;
double startAngle = 0;
double startAngle = -math.pi / 2;
// Vitórias (verde)
if (victoryPercentage > 0) {
final sweepAngle = 2 * math.pi * victoryPercentage;
_drawSector(canvas, center, radius, startAngle, sweepAngle, victoryColor);
startAngle += sweepAngle;
}
// Empates (amarelo)
if (drawPercentage > 0) {
final sweepAngle = 2 * math.pi * drawPercentage;
_drawSector(canvas, center, radius, startAngle, sweepAngle, drawColor);
startAngle += sweepAngle;
}
// Derrotas (vermelho)
if (defeatPercentage > 0) {
final sweepAngle = 2 * math.pi * defeatPercentage;
_drawSector(canvas, center, radius, startAngle, sweepAngle, defeatColor);
}
// Borda
final borderPaint = Paint()
..color = borderColor
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawCircle(center, radius, borderPaint);
}
void _drawSector(Canvas canvas, Offset center, double radius,
double startAngle, double sweepAngle, Color color) {
void drawDonutSector(double percentage, Color color) {
if (percentage <= 0) return;
final sweepAngle = 2 * math.pi * percentage;
final paint = Paint()
..color = color
..style = PaintingStyle.fill;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startAngle,
sweepAngle,
true,
paint,
);
// Linha divisória
if (sweepAngle < 2 * math.pi) {
final linePaint = Paint()
..color = Colors.white.withOpacity(0.5)
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.butt;
final lineX = center.dx + radius * math.cos(startAngle);
final lineY = center.dy + radius * math.sin(startAngle);
canvas.drawLine(center, Offset(lineX, lineY), linePaint);
canvas.drawArc(Rect.fromCircle(center: center, radius: radius), startAngle, sweepAngle, false, paint);
startAngle += sweepAngle;
}
drawDonutSector(victoryPercentage, victoryColor);
drawDonutSector(drawPercentage, drawColor);
drawDonutSector(defeatPercentage, defeatColor);
}
@override

View File

@@ -1,35 +1,47 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; // Para as orientações
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:playmaker/classe/theme.dart';
import 'pages/login.dart';
// Variável global para controlar o Tema
final ValueNotifier<ThemeMode> themeNotifier = ValueNotifier(ThemeMode.system);
void main() async {
// 1. Inicializa os bindings do Flutter
WidgetsFlutterBinding.ensureInitialized();
// 2. Inicializa o Supabase
await Supabase.initialize(
url: 'https://sihwjdshexjyvsbettcd.supabase.co',
anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InNpaHdqZHNoZXhqeXZzYmV0dGNkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njg5MTQxMjgsImV4cCI6MjA4NDQ5MDEyOH0.gW3AvTJVNyE1Dqa72OTnhrUIKsndexrY3pKxMIAaAy8', // Uma string longa
anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InNpaHdqZHNoZXhqeXZzYmV0dGNkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njg5MTQxMjgsImV4cCI6MjA4NDQ5MDEyOH0.gW3AvTJVNyE1Dqa72OTnhrUIKsndexrY3pKxMIAaAy8',
);
// 3. Deixa a orientação livre (Portrait) para o arranque da App
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<ThemeMode>(
valueListenable: themeNotifier,
builder: (_, ThemeMode currentMode, __) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'PlayMaker',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFFE74C3C),
),
useMaterial3: true,
),
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: currentMode,
home: const LoginPage(),
);
},
);
}
}

View File

@@ -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 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() ?? '---',
);
}
}

View File

@@ -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
};
}
}

View File

@@ -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

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:playmaker/classe/theme.dart'; // 👇 IMPORT DO TEMA
import '../controllers/register_controller.dart';
import '../widgets/register_widgets.dart';
import '../utils/size_extension.dart'; // 👇 O NOSSO SUPERPODER!
class RegisterPage extends StatefulWidget {
const RegisterPage({super.key});
@@ -10,39 +12,53 @@ class RegisterPage extends StatefulWidget {
}
class _RegisterPageState extends State<RegisterPage> {
// Instancia o controller
final RegisterController _controller = RegisterController();
@override
void dispose() {
_controller.dispose(); // Limpa a memória ao sair
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Criar Conta")),
// 👇 BLINDADO: Adapta-se automaticamente ao Modo Claro/Escuro
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: AppBar(
title: Text(
"Criar Conta",
style: TextStyle(
fontSize: 18 * context.sf,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface, // 👇 Adaptável ao Modo Escuro
)
),
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
elevation: 0,
iconTheme: IconThemeData(color: Theme.of(context).colorScheme.onSurface),
),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
padding: EdgeInsets.all(24.0 * context.sf),
child: ListenableBuilder(
listenable: _controller, // Ouve as mudanças (loading)
listenable: _controller,
builder: (context, child) {
return Column(
return Container(
width: double.infinity,
constraints: BoxConstraints(maxWidth: 450 * context.sf), // Mesma largura do Login
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
"Junta-te à Equipa!",
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 30),
const RegisterHeader(),
SizedBox(height: 30 * context.sf),
// Widgets Extraídos
RegisterFormFields(controller: _controller),
const SizedBox(height: 24),
SizedBox(height: 24 * context.sf),
RegisterButton(controller: _controller),
],
),
);
},
),

View File

@@ -1,8 +1,305 @@
import 'package:flutter/material.dart';
import '../controllers/game_controller.dart';
import 'package:playmaker/pages/PlacarPage.dart';
import 'package:playmaker/classe/theme.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../controllers/team_controller.dart';
import '../controllers/game_controller.dart';
import '../models/game_model.dart';
import '../widgets/game_widgets.dart';
import '../utils/size_extension.dart';
import 'pdf_export_service.dart';
class GameResultCard extends StatelessWidget {
final String gameId, myTeam, opponentTeam, myScore, opponentScore, status, season;
final String? myTeamLogo, opponentTeamLogo;
final double sf;
final VoidCallback onDelete;
const GameResultCard({
super.key, required this.gameId, required this.myTeam, required this.opponentTeam,
required this.myScore, required this.opponentScore, required this.status, required this.season,
this.myTeamLogo, this.opponentTeamLogo, required this.sf, required this.onDelete,
});
@override
Widget build(BuildContext context) {
final bgColor = Theme.of(context).cardTheme.color ?? Theme.of(context).colorScheme.surface;
final textColor = Theme.of(context).colorScheme.onSurface;
return Container(
margin: EdgeInsets.only(bottom: 16 * sf),
padding: EdgeInsets.all(16 * sf),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(20 * sf),
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10 * sf)],
border: Border.all(color: Colors.grey.withOpacity(0.1)),
),
child: Stack(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: _buildTeamInfo(myTeam, AppTheme.primaryRed, myTeamLogo, sf, textColor)),
_buildScoreCenter(context, gameId, sf, textColor),
Expanded(child: _buildTeamInfo(opponentTeam, Colors.grey.shade600, opponentTeamLogo, sf, textColor)),
],
),
Positioned(
top: -10 * sf,
right: -10 * sf,
child: Row(
children: [
IconButton(
icon: Icon(Icons.picture_as_pdf, color: AppTheme.primaryRed.withOpacity(0.8), size: 22 * sf),
splashRadius: 20 * sf,
tooltip: 'Gerar PDF',
onPressed: () async {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('A gerar PDF...'), duration: Duration(seconds: 1)));
await PdfExportService.generateAndPrintBoxScore(
gameId: gameId,
myTeam: myTeam,
opponentTeam: opponentTeam,
myScore: myScore,
opponentScore: opponentScore,
season: season,
);
},
),
IconButton(
icon: Icon(Icons.delete_outline, color: Colors.grey.shade400, size: 22 * sf),
splashRadius: 20 * sf,
tooltip: 'Eliminar Jogo',
onPressed: () => _showDeleteConfirmation(context),
),
],
),
),
],
),
);
}
void _showDeleteConfirmation(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: Theme.of(context).colorScheme.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * sf)),
title: Text('Eliminar Jogo', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16 * sf, color: Theme.of(context).colorScheme.onSurface)),
content: Text('Tem a certeza que deseja eliminar este jogo? Esta ação apagará todas as estatísticas associadas e não pode ser desfeita.', style: TextStyle(fontSize: 14 * sf, color: Theme.of(context).colorScheme.onSurface)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text('CANCELAR', style: TextStyle(color: Colors.grey, fontSize: 14 * sf))
),
TextButton(
onPressed: () {
Navigator.pop(ctx);
onDelete();
},
child: Text('ELIMINAR', style: TextStyle(color: AppTheme.primaryRed, fontWeight: FontWeight.bold, fontSize: 14 * sf))
),
],
)
);
}
Widget _buildTeamInfo(String name, Color color, String? logoUrl, double sf, Color textColor) {
final double avatarSize = 48 * sf;
return Column(
children: [
ClipOval(
child: Container(
width: avatarSize,
height: avatarSize,
color: color.withOpacity(0.1),
child: (logoUrl != null && logoUrl.isNotEmpty)
? CachedNetworkImage(
imageUrl: logoUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
placeholder: (context, url) => Center(child: Icon(Icons.shield, color: color, size: 24 * sf)),
errorWidget: (context, url, error) => Center(child: Icon(Icons.shield, color: color, size: 24 * sf)),
)
: Center(child: Icon(Icons.shield, color: color, size: 24 * sf)),
),
),
SizedBox(height: 6 * sf),
Text(name, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13 * sf, color: textColor), textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, maxLines: 2),
],
);
}
Widget _buildScoreCenter(BuildContext context, String id, double sf, Color textColor) {
return Column(
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
_scoreBox(myScore, AppTheme.successGreen, sf),
Text(" : ", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 22 * sf, color: textColor)),
_scoreBox(opponentScore, Colors.grey, sf),
],
),
SizedBox(height: 10 * sf),
TextButton.icon(
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (context) => PlacarPage(gameId: id, myTeam: myTeam, opponentTeam: opponentTeam))),
icon: Icon(Icons.play_circle_fill, size: 18 * sf, color: AppTheme.primaryRed),
label: Text("RETORNAR", style: TextStyle(fontSize: 11 * sf, color: AppTheme.primaryRed, fontWeight: FontWeight.bold)),
style: TextButton.styleFrom(backgroundColor: AppTheme.primaryRed.withOpacity(0.1), padding: EdgeInsets.symmetric(horizontal: 14 * sf, vertical: 8 * sf), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20 * sf)), visualDensity: VisualDensity.compact),
),
SizedBox(height: 6 * sf),
Text(status, style: TextStyle(fontSize: 12 * sf, color: Colors.blue, fontWeight: FontWeight.bold)),
],
);
}
Widget _scoreBox(String pts, Color c, double sf) => Container(
padding: EdgeInsets.symmetric(horizontal: 12 * sf, vertical: 6 * sf),
decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(8 * sf)),
child: Text(pts, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16 * sf)),
);
}
class CreateGameDialogManual extends StatefulWidget {
final TeamController teamController;
final GameController gameController;
final double sf;
const CreateGameDialogManual({super.key, required this.teamController, required this.gameController, required this.sf});
@override
State<CreateGameDialogManual> createState() => _CreateGameDialogManualState();
}
class _CreateGameDialogManualState extends State<CreateGameDialogManual> {
late TextEditingController _seasonController;
final TextEditingController _myTeamController = TextEditingController();
final TextEditingController _opponentController = TextEditingController();
bool _isLoading = false;
@override
void initState() {
super.initState();
_seasonController = TextEditingController(text: _calculateSeason());
}
String _calculateSeason() {
final now = DateTime.now();
return now.month >= 7 ? "${now.year}/${(now.year + 1).toString().substring(2)}" : "${now.year - 1}/${now.year.toString().substring(2)}";
}
@override
Widget build(BuildContext context) {
return AlertDialog(
backgroundColor: Theme.of(context).colorScheme.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20 * widget.sf)),
title: Text('Configurar Partida', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18 * widget.sf, color: Theme.of(context).colorScheme.onSurface)),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _seasonController,
style: TextStyle(fontSize: 14 * widget.sf, color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration(labelText: 'Temporada', labelStyle: TextStyle(fontSize: 14 * widget.sf), border: const OutlineInputBorder(), prefixIcon: Icon(Icons.calendar_today, size: 20 * widget.sf))
),
SizedBox(height: 15 * widget.sf),
_buildSearch(context, "Minha Equipa", _myTeamController),
Padding(padding: EdgeInsets.symmetric(vertical: 10 * widget.sf), child: Text("VS", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey, fontSize: 16 * widget.sf))),
_buildSearch(context, "Adversário", _opponentController),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text('CANCELAR', style: TextStyle(fontSize: 14 * widget.sf, color: Colors.grey))),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryRed, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10 * widget.sf)), padding: EdgeInsets.symmetric(horizontal: 16 * widget.sf, vertical: 10 * widget.sf)),
onPressed: _isLoading ? null : () async {
if (_myTeamController.text.isNotEmpty && _opponentController.text.isNotEmpty) {
setState(() => _isLoading = true);
String? newGameId = await widget.gameController.createGame(_myTeamController.text, _opponentController.text, _seasonController.text);
setState(() => _isLoading = false);
if (newGameId != null && context.mounted) {
Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(builder: (context) => PlacarPage(gameId: newGameId, myTeam: _myTeamController.text, opponentTeam: _opponentController.text)));
}
}
},
child: _isLoading ? SizedBox(width: 20 * widget.sf, height: 20 * widget.sf, child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : Text('CRIAR', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14 * widget.sf)),
),
],
);
}
Widget _buildSearch(BuildContext context, String label, TextEditingController controller) {
return StreamBuilder<List<Map<String, dynamic>>>(
stream: widget.teamController.teamsStream,
builder: (context, snapshot) {
List<Map<String, dynamic>> teamList = snapshot.hasData ? snapshot.data! : [];
return Autocomplete<Map<String, dynamic>>(
displayStringForOption: (Map<String, dynamic> option) => option['name'].toString(),
optionsBuilder: (TextEditingValue val) {
if (val.text.isEmpty) return const Iterable<Map<String, dynamic>>.empty();
return teamList.where((t) => t['name'].toString().toLowerCase().contains(val.text.toLowerCase()));
},
onSelected: (Map<String, dynamic> selection) { controller.text = selection['name'].toString(); },
optionsViewBuilder: (context, onSelected, options) {
return Align(
alignment: Alignment.topLeft,
child: Material(
color: Theme.of(context).colorScheme.surface,
elevation: 4.0, borderRadius: BorderRadius.circular(8 * widget.sf),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: 250 * widget.sf, maxWidth: MediaQuery.of(context).size.width * 0.7),
child: ListView.builder(
padding: EdgeInsets.zero, shrinkWrap: true, itemCount: options.length,
itemBuilder: (BuildContext context, int index) {
final option = options.elementAt(index);
final String name = option['name'].toString();
final String? imageUrl = option['image_url'];
return ListTile(
leading: ClipOval(
child: Container(
width: 40 * widget.sf,
height: 40 * widget.sf,
color: Colors.grey.withOpacity(0.2),
child: (imageUrl != null && imageUrl.isNotEmpty)
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
placeholder: (context, url) => Icon(Icons.shield, color: Colors.grey, size: 20 * widget.sf),
errorWidget: (context, url, error) => Icon(Icons.shield, color: Colors.grey, size: 20 * widget.sf),
)
: Icon(Icons.shield, color: Colors.grey, size: 20 * widget.sf),
),
),
title: Text(name, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14 * widget.sf, color: Theme.of(context).colorScheme.onSurface)),
onTap: () { onSelected(option); },
);
},
),
),
),
);
},
fieldViewBuilder: (ctx, txtCtrl, node, submit) {
if (txtCtrl.text.isEmpty && controller.text.isNotEmpty) txtCtrl.text = controller.text;
txtCtrl.addListener(() { controller.text = txtCtrl.text; });
return TextField(
controller: txtCtrl, focusNode: node,
style: TextStyle(fontSize: 14 * widget.sf, color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration(labelText: label, labelStyle: TextStyle(fontSize: 14 * widget.sf), prefixIcon: Icon(Icons.search, size: 20 * widget.sf, color: AppTheme.primaryRed)),
);
},
);
},
);
}
}
class GamePage extends StatefulWidget {
const GamePage({super.key});
@@ -14,58 +311,51 @@ class GamePage extends StatefulWidget {
class _GamePageState extends State<GamePage> {
final GameController gameController = GameController();
final TeamController teamController = TeamController();
String selectedSeason = 'Todas';
String selectedTeam = 'Todas';
@override
Widget build(BuildContext context) {
bool isFilterActive = selectedSeason != 'Todas' || selectedTeam != 'Todas';
return Scaffold(
backgroundColor: const Color(0xFFF5F7FA),
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: AppBar(
title: const Text("Jogos", style: TextStyle(fontWeight: FontWeight.bold)),
backgroundColor: Colors.white,
title: Text("Jogos", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20 * context.sf)),
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
elevation: 0,
actions: [
Padding(
padding: EdgeInsets.only(right: 8.0 * context.sf),
child: IconButton(
icon: Icon(isFilterActive ? Icons.filter_list_alt : Icons.filter_list, color: isFilterActive ? AppTheme.primaryRed : Theme.of(context).colorScheme.onSurface, size: 26 * context.sf),
onPressed: () => _showFilterPopup(context),
),
)
],
),
// 1º STREAM: Lemos as equipas para ter as imagens
body: StreamBuilder<List<Map<String, dynamic>>>(
stream: teamController.teamsStream,
builder: (context, teamSnapshot) {
final List<Map<String, dynamic>> teamsList = teamSnapshot.data ?? [];
// 2º STREAM: Lemos os jogos
return StreamBuilder<List<Game>>(
stream: gameController.gamesStream,
stream: gameController.getFilteredGames(teamFilter: selectedTeam, seasonFilter: selectedSeason),
builder: (context, gameSnapshot) {
if (gameSnapshot.connectionState == ConnectionState.waiting && teamsList.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (gameSnapshot.hasError) {
return Center(child: Text("Erro: ${gameSnapshot.error}"));
}
if (gameSnapshot.connectionState == ConnectionState.waiting && teamsList.isEmpty) return const Center(child: CircularProgressIndicator());
if (gameSnapshot.hasError) return Center(child: Text("Erro: ${gameSnapshot.error}", style: TextStyle(fontSize: 14 * context.sf, color: Theme.of(context).colorScheme.onSurface)));
if (!gameSnapshot.hasData || gameSnapshot.data!.isEmpty) {
return const Center(child: Text("Nenhum jogo registado."));
return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Icon(Icons.search_off, size: 48 * context.sf, color: Colors.grey.withOpacity(0.3)), SizedBox(height: 10 * context.sf), Text("Nenhum jogo encontrado.", style: TextStyle(fontSize: 14 * context.sf, color: Colors.grey))]));
}
return ListView.builder(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.all(16 * context.sf),
itemCount: gameSnapshot.data!.length,
itemBuilder: (context, index) {
final game = gameSnapshot.data![index];
// --- LÓGICA PARA ENCONTRAR A IMAGEM PELO NOME ---
String? myLogo;
String? oppLogo;
String? myLogo, oppLogo;
for (var team in teamsList) {
if (team['name'] == game.myTeam) {
myLogo = team['image_url'];
if (team['name'] == game.myTeam) myLogo = team['image_url'];
if (team['name'] == game.opponentTeam) oppLogo = team['image_url'];
}
if (team['name'] == game.opponentTeam) {
oppLogo = team['image_url'];
}
}
// Agora já passamos as imagens para o cartão!
return GameResultCard(
gameId: game.id,
myTeam: game.myTeam,
@@ -74,8 +364,24 @@ class _GamePageState extends State<GamePage> {
opponentScore: game.opponentScore,
status: game.status,
season: game.season,
myTeamLogo: myLogo, // <-- IMAGEM DA TUA EQUIPA
opponentTeamLogo: oppLogo, // <-- IMAGEM DA EQUIPA ADVERSÁRIA
myTeamLogo: myLogo,
opponentTeamLogo: oppLogo,
sf: context.sf,
onDelete: () async {
bool success = await gameController.deleteGame(game.id);
if (context.mounted) {
if (success) {
setState(() {});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Jogo eliminado com sucesso!'), backgroundColor: Colors.green)
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Erro ao eliminar o jogo.'), backgroundColor: Colors.red)
);
}
}
},
);
},
);
@@ -84,20 +390,80 @@ class _GamePageState extends State<GamePage> {
},
),
floatingActionButton: FloatingActionButton(
backgroundColor: const Color(0xFFE74C3C),
child: const Icon(Icons.add, color: Colors.white),
onPressed: () => _showCreateDialog(context),
heroTag: 'add_game_btn',
backgroundColor: AppTheme.primaryRed,
child: Icon(Icons.add, color: Colors.white, size: 24 * context.sf),
onPressed: () => showDialog(context: context, builder: (context) => CreateGameDialogManual(teamController: teamController, gameController: gameController, sf: context.sf)),
),
);
}
void _showCreateDialog(BuildContext context) {
void _showFilterPopup(BuildContext context) {
String tempSeason = selectedSeason;
String tempTeam = selectedTeam;
showDialog(
context: context,
builder: (context) => CreateGameDialogManual(
teamController: teamController,
gameController: gameController,
builder: (context) {
return StatefulBuilder(
builder: (context, setPopupState) {
return AlertDialog(
backgroundColor: Theme.of(context).colorScheme.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20 * context.sf)),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Filtrar Jogos', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18 * context.sf, color: Theme.of(context).colorScheme.onSurface)),
IconButton(icon: const Icon(Icons.close, color: Colors.grey), onPressed: () => Navigator.pop(context), padding: EdgeInsets.zero, constraints: const BoxConstraints())
],
),
content: Column(
mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Temporada", style: TextStyle(fontSize: 12 * context.sf, color: Colors.grey, fontWeight: FontWeight.bold)),
SizedBox(height: 6 * context.sf),
Container(
padding: EdgeInsets.symmetric(horizontal: 12 * context.sf), decoration: BoxDecoration(color: Theme.of(context).cardTheme.color, borderRadius: BorderRadius.circular(10 * context.sf), border: Border.all(color: Colors.grey.withOpacity(0.2))),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
dropdownColor: Theme.of(context).colorScheme.surface,
isExpanded: true, value: tempSeason, style: TextStyle(fontSize: 14 * context.sf, color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold),
items: ['Todas', '2024/25', '2025/26'].map((String value) => DropdownMenuItem<String>(value: value, child: Text(value))).toList(),
onChanged: (newValue) => setPopupState(() => tempSeason = newValue!),
),
),
),
SizedBox(height: 20 * context.sf),
Text("Equipa", style: TextStyle(fontSize: 12 * context.sf, color: Colors.grey, fontWeight: FontWeight.bold)),
SizedBox(height: 6 * context.sf),
Container(
padding: EdgeInsets.symmetric(horizontal: 12 * context.sf), decoration: BoxDecoration(color: Theme.of(context).cardTheme.color, borderRadius: BorderRadius.circular(10 * context.sf), border: Border.all(color: Colors.grey.withOpacity(0.2))),
child: StreamBuilder<List<Map<String, dynamic>>>(
stream: teamController.teamsStream,
builder: (context, snapshot) {
List<String> teamNames = ['Todas'];
if (snapshot.hasData) teamNames.addAll(snapshot.data!.map((t) => t['name'].toString()));
if (!teamNames.contains(tempTeam)) tempTeam = 'Todas';
return DropdownButtonHideUnderline(
child: DropdownButton<String>(
dropdownColor: Theme.of(context).colorScheme.surface,
isExpanded: true, value: tempTeam, style: TextStyle(fontSize: 14 * context.sf, color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold),
items: teamNames.map((String value) => DropdownMenuItem<String>(value: value, child: Text(value, overflow: TextOverflow.ellipsis))).toList(),
onChanged: (newValue) => setPopupState(() => tempTeam = newValue!),
),
);
}
),
),
],
),
actions: [
TextButton(onPressed: () { setState(() { selectedSeason = 'Todas'; selectedTeam = 'Todas'; }); Navigator.pop(context); }, child: Text('LIMPAR', style: TextStyle(fontSize: 12 * context.sf, color: Colors.grey))),
ElevatedButton(style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryRed, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10 * context.sf))), onPressed: () { setState(() { selectedSeason = tempSeason; selectedTeam = tempTeam; }); Navigator.pop(context); }, child: Text('APLICAR', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13 * context.sf))),
],
);
}
);
},
);
}
}

View File

@@ -1,8 +1,15 @@
import 'package:flutter/material.dart';
import 'package:playmaker/classe/home.config.dart';
import 'package:playmaker/classe/theme.dart';
import 'package:playmaker/grafico%20de%20pizza/grafico.dart';
import 'package:playmaker/pages/gamePage.dart';
import 'package:playmaker/pages/teamPage.dart';
import 'package:playmaker/controllers/team_controller.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:playmaker/pages/status_page.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../utils/size_extension.dart';
import 'settings_screen.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@@ -13,214 +20,563 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> {
int _selectedIndex = 0;
final TeamController _teamController = TeamController();
String? _selectedTeamId;
String _selectedTeamName = "Selecionar Equipa";
int _teamWins = 0;
int _teamLosses = 0;
int _teamDraws = 0;
late final List<Widget> _pages;
final _supabase = Supabase.instance.client;
String? _avatarUrl;
bool _isMemoryLoaded = false; // A variável mágica que impede o "piscar" inicial
@override
void initState() {
super.initState();
_pages = [
_buildHomeContent(),
const GamePage(),
const TeamsPage(),
const Center(child: Text('Tela de Status')),
];
_loadUserAvatar();
}
void _onItemSelected(int index) {
// FUNÇÃO OTIMIZADA: Carrega da memória instantaneamente e atualiza em background
Future<void> _loadUserAvatar() async {
// 1. LÊ DA MEMÓRIA RÁPIDA PRIMEIRO
final prefs = await SharedPreferences.getInstance();
final savedUrl = prefs.getString('meu_avatar_guardado');
if (mounted) {
setState(() {
_selectedIndex = index;
if (savedUrl != null) _avatarUrl = savedUrl;
_isMemoryLoaded = true; // Avisa o ecrã que a memória já respondeu!
});
}
// 2. VAI AO SUPABASE VERIFICAR SE TROCASTE DE FOTO
final userId = _supabase.auth.currentUser?.id;
if (userId == null) return;
try {
final data = await _supabase
.from('profiles')
.select('avatar_url')
.eq('id', userId)
.maybeSingle();
if (mounted && data != null && data['avatar_url'] != null) {
final urlDoSupabase = data['avatar_url'];
// Se a foto na base de dados for nova, ele guarda e atualiza!
if (urlDoSupabase != savedUrl) {
await prefs.setString('meu_avatar_guardado', urlDoSupabase);
setState(() {
_avatarUrl = urlDoSupabase;
});
}
}
} catch (e) {
debugPrint("Erro ao carregar avatar na Home: $e");
}
}
@override
Widget build(BuildContext context) {
final List<Widget> pages = [
_buildHomeContent(context),
const GamePage(),
const TeamsPage(),
const StatusPage(),
];
return Scaffold(
backgroundColor: Colors.white,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: AppBar(
title: const Text('PlayMaker'),
backgroundColor: HomeConfig.primaryColor,
title: Text('PlayMaker', style: TextStyle(fontSize: 20 * context.sf, fontWeight: FontWeight.bold)),
backgroundColor: AppTheme.primaryRed,
foregroundColor: Colors.white,
leading: IconButton(
icon: const Icon(Icons.person),
onPressed: () {},
elevation: 0,
leading: Padding(
padding: EdgeInsets.all(10.0 * context.sf),
child: InkWell(
borderRadius: BorderRadius.circular(100),
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
);
_loadUserAvatar();
},
// SÓ MOSTRA A IMAGEM OU O BONECO DEPOIS DE LER A MEMÓRIA
child: !_isMemoryLoaded
// Nos primeiros 0.05 segs, mostra só o círculo de fundo (sem boneco)
? CircleAvatar(backgroundColor: Colors.white.withOpacity(0.2))
// Depois da memória responder:
: _avatarUrl != null && _avatarUrl!.isNotEmpty
? CachedNetworkImage(
imageUrl: _avatarUrl!,
fadeInDuration: Duration.zero, // Corta o atraso visual!
imageBuilder: (context, imageProvider) => CircleAvatar(
backgroundColor: Colors.white.withOpacity(0.2),
backgroundImage: imageProvider,
),
placeholder: (context, url) => CircleAvatar(backgroundColor: Colors.white.withOpacity(0.2)),
errorWidget: (context, url, error) => CircleAvatar(
backgroundColor: Colors.white.withOpacity(0.2),
child: Icon(Icons.person, color: Colors.white, size: 20 * context.sf),
),
)
// Se não tiver foto nenhuma, aí sim mostra o boneco
: CircleAvatar(
backgroundColor: Colors.white.withOpacity(0.2),
child: Icon(Icons.person, color: Colors.white, size: 20 * context.sf),
),
),
),
),
body: IndexedStack(
index: _selectedIndex,
children: _pages,
children: pages,
),
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: _onItemSelected,
onDestinationSelected: (index) => setState(() => _selectedIndex = index),
backgroundColor: Theme.of(context).colorScheme.surface,
surfaceTintColor: Theme.of(context).colorScheme.surfaceTint,
elevation: 1,
height: 70,
height: 70 * (context.sf < 1.2 ? context.sf : 1.2),
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home_filled),
label: 'Home',
NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home_filled), label: 'Home'),
NavigationDestination(icon: Icon(Icons.sports_soccer_outlined), selectedIcon: Icon(Icons.sports_soccer), label: 'Jogo'),
NavigationDestination(icon: Icon(Icons.people_outline), selectedIcon: Icon(Icons.people), label: 'Equipas'),
NavigationDestination(icon: Icon(Icons.insights_outlined), selectedIcon: Icon(Icons.insights), label: 'Status'),
],
),
NavigationDestination(
icon: Icon(Icons.sports_soccer_outlined),
selectedIcon: Icon(Icons.sports_soccer),
label: 'Jogo',
);
}
void _showTeamSelector(BuildContext context) {
showModalBottomSheet(
context: context,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20 * context.sf))),
builder: (context) {
return StreamBuilder<List<Map<String, dynamic>>>(
stream: _teamController.teamsStream,
builder: (context, snapshot) {
// Correção: Verifica hasData para evitar piscar tela de loading
if (!snapshot.hasData && snapshot.connectionState == ConnectionState.waiting) {
return const SizedBox(height: 200, child: Center(child: CircularProgressIndicator()));
}
if (!snapshot.hasData || snapshot.data!.isEmpty) {
return SizedBox(height: 200 * context.sf, child: Center(child: Text("Nenhuma equipa criada.", style: TextStyle(color: Theme.of(context).colorScheme.onSurface))));
}
final teams = snapshot.data!;
return ListView.builder(
shrinkWrap: true,
itemCount: teams.length,
itemBuilder: (context, index) {
final team = teams[index];
return ListTile(
leading: const Icon(Icons.shield, color: AppTheme.primaryRed),
title: Text(team['name'] ?? 'Sem Nome', style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold)),
onTap: () {
setState(() {
_selectedTeamId = team['id'].toString();
_selectedTeamName = team['name'] ?? 'Desconhecido';
_teamWins = int.tryParse(team['wins']?.toString() ?? '0') ?? 0;
_teamLosses = int.tryParse(team['losses']?.toString() ?? '0') ?? 0;
_teamDraws = int.tryParse(team['draws']?.toString() ?? '0') ?? 0;
});
Navigator.pop(context);
},
);
},
);
},
);
},
);
}
Widget _buildHomeContent(BuildContext context) {
final double wScreen = MediaQuery.of(context).size.width;
final double cardHeight = wScreen * 0.5;
final textColor = Theme.of(context).colorScheme.onSurface;
return StreamBuilder<List<Map<String, dynamic>>>(
stream: _selectedTeamId != null
? _supabase.from('player_stats_with_names').stream(primaryKey: ['id']).eq('team_id', _selectedTeamId!)
: const Stream.empty(),
builder: (context, snapshot) {
Map<String, dynamic> leaders = _calculateLeaders(snapshot.data ?? []);
return SingleChildScrollView(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 22.0 * context.sf, vertical: 16.0 * context.sf),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () => _showTeamSelector(context),
child: Container(
padding: EdgeInsets.all(12 * context.sf),
decoration: BoxDecoration(
color: Theme.of(context).cardTheme.color,
borderRadius: BorderRadius.circular(15 * context.sf),
border: Border.all(color: Colors.grey.withOpacity(0.2))
),
NavigationDestination(
icon: Icon(Icons.people_outline),
selectedIcon: Icon(Icons.people),
label: 'Equipas',
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(children: [
Icon(Icons.shield, color: AppTheme.primaryRed, size: 24 * context.sf),
SizedBox(width: 10 * context.sf),
Text(_selectedTeamName, style: TextStyle(fontSize: 16 * context.sf, fontWeight: FontWeight.bold, color: textColor))
]),
Icon(Icons.arrow_drop_down, color: textColor),
],
),
NavigationDestination(
icon: Icon(Icons.insights_outlined),
selectedIcon: Icon(Icons.insights),
label: 'Status',
),
),
SizedBox(height: 20 * context.sf),
SizedBox(
height: cardHeight,
child: Row(
children: [
Expanded(child: _buildStatCard(context: context, title: 'Mais Pontos', playerName: leaders['pts_name'], statValue: leaders['pts_val'].toString(), statLabel: 'TOTAL', color: AppTheme.statPtsBg, isHighlighted: true)),
SizedBox(width: 12 * context.sf),
Expanded(child: _buildStatCard(context: context, title: 'Assistências', playerName: leaders['ast_name'], statValue: leaders['ast_val'].toString(), statLabel: 'TOTAL', color: AppTheme.statAstBg)),
],
),
),
SizedBox(height: 12 * context.sf),
SizedBox(
height: cardHeight,
child: Row(
children: [
Expanded(child: _buildStatCard(context: context, title: 'Rebotes', playerName: leaders['rbs_name'], statValue: leaders['rbs_val'].toString(), statLabel: 'TOTAL', color: AppTheme.statRebBg)),
SizedBox(width: 12 * context.sf),
Expanded(
child: PieChartCard(
victories: _teamWins,
defeats: _teamLosses,
draws: _teamDraws,
title: 'DESEMPENHO',
subtitle: 'Temporada',
backgroundColor: AppTheme.statPieBg,
sf: context.sf
),
),
],
),
),
SizedBox(height: 40 * context.sf),
Text('Histórico de Jogos', style: TextStyle(fontSize: 20 * context.sf, fontWeight: FontWeight.bold, color: textColor)),
SizedBox(height: 16 * context.sf),
_selectedTeamName == "Selecionar Equipa"
? Container(
width: double.infinity,
padding: EdgeInsets.all(24.0 * context.sf),
decoration: BoxDecoration(
color: Theme.of(context).cardTheme.color ?? Colors.white,
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))],
),
child: Column(
children: [
Container(
padding: EdgeInsets.all(18 * context.sf),
decoration: BoxDecoration(color: AppTheme.primaryRed.withOpacity(0.08), shape: BoxShape.circle),
child: Icon(Icons.shield_outlined, color: AppTheme.primaryRed, size: 42 * context.sf),
),
SizedBox(height: 20 * context.sf),
Text("Nenhuma Equipa Ativa", style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold, color: textColor)),
SizedBox(height: 8 * context.sf),
Text(
"Escolha uma equipa no seletor acima para ver as estatísticas e o histórico.",
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13 * context.sf, color: Colors.grey.shade600, height: 1.4),
),
SizedBox(height: 24 * context.sf),
SizedBox(
width: double.infinity,
height: 48 * context.sf,
child: ElevatedButton.icon(
onPressed: () => _showTeamSelector(context),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryRed,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10 * context.sf)),
),
icon: Icon(Icons.touch_app, size: 20 * context.sf),
label: Text("Selecionar Agora", style: TextStyle(fontSize: 15 * context.sf, fontWeight: FontWeight.bold)),
),
),
],
),
)
: StreamBuilder<List<Map<String, dynamic>>>(
stream: _supabase.from('games').stream(primaryKey: ['id']).order('game_date', ascending: false),
builder: (context, gameSnapshot) {
if (gameSnapshot.hasError) return Text("Erro: ${gameSnapshot.error}", style: const TextStyle(color: Colors.red));
// Correção: Verifica hasData em vez de ConnectionState para manter a lista na tela enquanto atualiza em plano de fundo
if (!gameSnapshot.hasData && gameSnapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final todosOsJogos = gameSnapshot.data ?? [];
final gamesList = todosOsJogos.where((game) {
String myT = game['my_team']?.toString() ?? '';
String oppT = game['opponent_team']?.toString() ?? '';
String status = game['status']?.toString() ?? '';
return (myT == _selectedTeamName || oppT == _selectedTeamName) && status == 'Terminado';
}).take(3).toList();
if (gamesList.isEmpty) {
return Container(
width: double.infinity,
padding: EdgeInsets.all(20 * context.sf),
decoration: BoxDecoration(color: Theme.of(context).cardTheme.color, borderRadius: BorderRadius.circular(14)),
alignment: Alignment.center,
child: const Text("Ainda não há jogos terminados.", style: TextStyle(color: Colors.grey)),
);
}
return Column(
children: gamesList.map((game) {
String dbMyTeam = game['my_team']?.toString() ?? '';
String dbOppTeam = game['opponent_team']?.toString() ?? '';
int dbMyScore = int.tryParse(game['my_score']?.toString() ?? '0') ?? 0;
int dbOppScore = int.tryParse(game['opponent_score']?.toString() ?? '0') ?? 0;
String opponent; int myScore; int oppScore;
if (dbMyTeam == _selectedTeamName) {
opponent = dbOppTeam; myScore = dbMyScore; oppScore = dbOppScore;
} else {
opponent = dbMyTeam; myScore = dbOppScore; oppScore = dbMyScore;
}
String rawDate = game['game_date']?.toString() ?? '---';
String date = rawDate.length >= 10 ? rawDate.substring(0, 10) : rawDate;
String result = 'E';
if (myScore > oppScore) result = 'V';
if (myScore < oppScore) result = 'D';
return _buildGameHistoryCard(
context: context, opponent: opponent, result: result,
myScore: myScore, oppScore: oppScore, date: date,
topPts: game['top_pts_name'] ?? '---', topAst: game['top_ast_name'] ?? '---',
topRbs: game['top_rbs_name'] ?? '---', topDef: game['top_def_name'] ?? '---', mvp: game['mvp_name'] ?? '---',
);
}).toList(),
);
},
),
SizedBox(height: 20 * context.sf),
],
),
),
);
},
);
}
Map<String, dynamic> _calculateLeaders(List<Map<String, dynamic>> data) {
Map<String, int> ptsMap = {}; Map<String, int> astMap = {}; Map<String, int> rbsMap = {}; Map<String, String> namesMap = {};
for (var row in data) {
String pid = row['member_id']?.toString() ?? "unknown";
namesMap[pid] = row['player_name']?.toString() ?? "Desconhecido";
ptsMap[pid] = (ptsMap[pid] ?? 0) + (int.tryParse(row['pts']?.toString() ?? '0') ?? 0);
astMap[pid] = (astMap[pid] ?? 0) + (int.tryParse(row['ast']?.toString() ?? '0') ?? 0);
rbsMap[pid] = (rbsMap[pid] ?? 0) + (int.tryParse(row['rbs']?.toString() ?? '0') ?? 0);
}
if (ptsMap.isEmpty) {
return {'pts_name': '---', 'pts_val': 0, 'ast_name': '---', 'ast_val': 0, 'rbs_name': '---', 'rbs_val': 0};
}
String getBest(Map<String, int> map) {
if (map.isEmpty) return '---';
var bestId = map.entries.reduce((a, b) => a.value > b.value ? a : b).key;
return namesMap[bestId] ?? '---';
}
int getBestVal(Map<String, int> map) {
if (map.isEmpty) return 0;
return map.values.reduce((a, b) => a > b ? a : b);
}
return {
'pts_name': getBest(ptsMap), 'pts_val': getBestVal(ptsMap),
'ast_name': getBest(astMap), 'ast_val': getBestVal(astMap),
'rbs_name': getBest(rbsMap), 'rbs_val': getBestVal(rbsMap)
};
}
Widget _buildStatCard({required BuildContext context, required String title, required String playerName, required String statValue, required String statLabel, required Color color, bool isHighlighted = false}) {
return Card(
elevation: 4, margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14), side: isHighlighted ? const BorderSide(color: AppTheme.warningAmber, width: 2) : BorderSide.none),
child: Container(
decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), gradient: LinearGradient(begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [color.withOpacity(0.9), color])),
child: LayoutBuilder(
builder: (context, constraints) {
final double ch = constraints.maxHeight;
final double cw = constraints.maxWidth;
return Padding(
padding: EdgeInsets.all(cw * 0.06),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title.toUpperCase(), style: TextStyle(fontSize: ch * 0.06, fontWeight: FontWeight.bold, color: Colors.white70), maxLines: 1, overflow: TextOverflow.ellipsis),
SizedBox(height: ch * 0.011),
SizedBox(
width: double.infinity,
child: FittedBox(
fit: BoxFit.scaleDown, alignment: Alignment.centerLeft,
child: Text(playerName, style: TextStyle(fontSize: ch * 0.08, fontWeight: FontWeight.bold, color: Colors.white)),
),
),
const Spacer(),
Center(child: FittedBox(fit: BoxFit.scaleDown, child: Text(statValue, style: TextStyle(fontSize: ch * 0.18, fontWeight: FontWeight.bold, color: Colors.white, height: 1.0)))),
SizedBox(height: ch * 0.015),
Center(child: Text(statLabel, style: TextStyle(fontSize: ch * 0.05, color: Colors.white70))),
const Spacer(),
Container(
width: double.infinity, padding: EdgeInsets.symmetric(vertical: ch * 0.035),
decoration: BoxDecoration(color: Colors.white24, borderRadius: BorderRadius.circular(ch * 0.03)),
child: Center(child: Text('DETALHES', style: TextStyle(color: Colors.white, fontSize: ch * 0.05, fontWeight: FontWeight.bold)))
),
],
),
);
}
Widget _buildHomeContent() {
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildStatCard(
title: 'Mais Pontos',
playerName: 'Michael Jordan',
statValue: '34.5',
statLabel: 'PPG',
color: Colors.blue[800]!,
icon: Icons.sports_basketball,
isHighlighted: true,
),
const SizedBox(width: 20),
_buildStatCard(
title: 'Mais Assistências',
playerName: 'Magic Johnson',
statValue: '12.8',
statLabel: 'APG',
color: Colors.green[800]!,
icon: Icons.sports_basketball,
isHighlighted: false,
),
],
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildStatCard(
title: 'Mais Rebotes',
playerName: 'Dennis Rodman',
statValue: '15.3',
statLabel: 'RPG',
color: Colors.purple[800]!,
icon: Icons.sports_basketball,
isHighlighted: false,
),
const SizedBox(width: 20),
PieChartCard(
title: 'DESEMPENHO',
subtitle: 'Vitórias vs Derrotas',
backgroundColor: Colors.red[800]!,
onTap: () {},
),
],
),
const SizedBox(height: 40),
Text(
'Histórico de Jogos',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.grey[800],
),
),
],
),
),
);
}
Widget _buildStatCard({
required String title,
required String playerName,
required String statValue,
required String statLabel,
required Color color,
required IconData icon,
bool isHighlighted = false,
Widget _buildGameHistoryCard({
required BuildContext context, required String opponent, required String result, required int myScore, required int oppScore, required String date,
required String topPts, required String topAst, required String topRbs, required String topDef, required String mvp
}) {
return SizedBox(
width: HomeConfig.cardwidthPadding,
height: HomeConfig.cardheightPadding,
child: Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: isHighlighted
? const BorderSide(color: Colors.amber, width: 2)
: BorderSide.none,
),
child: Container(
bool isWin = result == 'V';
bool isDraw = result == 'E';
Color statusColor = isWin ? AppTheme.successGreen : (isDraw ? AppTheme.warningAmber : AppTheme.oppTeamRed);
final bgColor = Theme.of(context).cardTheme.color;
final textColor = Theme.of(context).colorScheme.onSurface;
return Container(
margin: EdgeInsets.only(bottom: 14 * context.sf),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [color.withOpacity(0.9), color.withOpacity(0.7)],
color: bgColor, borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.withOpacity(0.1)), boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 8, offset: const Offset(0, 4))],
),
),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
Padding(
padding: EdgeInsets.all(14 * context.sf),
child: Row(
children: [
Container(
width: 36 * context.sf, height: 36 * context.sf,
decoration: BoxDecoration(color: statusColor.withOpacity(0.15), shape: BoxShape.circle),
child: Center(child: Text(result, style: TextStyle(color: statusColor, fontWeight: FontWeight.bold, fontSize: 16 * context.sf))),
),
SizedBox(width: 14 * context.sf),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title.toUpperCase(), style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.white70)),
Text(playerName, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
Text(date, style: TextStyle(fontSize: 11 * context.sf, color: Colors.grey, fontWeight: FontWeight.w600)),
SizedBox(height: 6 * context.sf),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: Text(_selectedTeamName == "Selecionar Equipa" ? "Minha Equipa" : _selectedTeamName, style: TextStyle(fontSize: 14 * context.sf, fontWeight: FontWeight.bold, color: textColor), maxLines: 1, overflow: TextOverflow.ellipsis)),
Padding(
padding: EdgeInsets.symmetric(horizontal: 8 * context.sf),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 8 * context.sf, vertical: 4 * context.sf),
decoration: BoxDecoration(color: Theme.of(context).colorScheme.onSurface.withOpacity(0.05), borderRadius: BorderRadius.circular(8)),
child: Text('$myScore - $oppScore', style: TextStyle(fontSize: 15 * context.sf, fontWeight: FontWeight.w900, letterSpacing: 1.5, color: textColor)),
),
),
Expanded(child: Text(opponent, style: TextStyle(fontSize: 14 * context.sf, fontWeight: FontWeight.bold, color: textColor), textAlign: TextAlign.right, maxLines: 1, overflow: TextOverflow.ellipsis)),
],
),
],
),
),
if (isHighlighted) const Icon(Icons.star, color: Colors.amber, size: 20),
],
),
const Spacer(),
Center(
child: Text(statValue, style: const TextStyle(fontSize: 38, fontWeight: FontWeight.bold, color: Colors.white)),
),
Center(
child: Text(statLabel, style: const TextStyle(fontSize: 12, color: Colors.white70)),
),
const Spacer(),
Divider(height: 1, color: Colors.grey.withOpacity(0.1), thickness: 1.5),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(color: Colors.white24, borderRadius: BorderRadius.circular(10)),
child: const Center(child: Text('VER DETALHES', style: TextStyle(color: Colors.white, fontSize: 12))),
width: double.infinity, padding: EdgeInsets.symmetric(horizontal: 16 * context.sf, vertical: 12 * context.sf),
decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface, borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(16), bottomRight: Radius.circular(16))),
child: Column(
children: [
Row(
children: [
Expanded(child: _buildGridStatRow(context, Icons.workspace_premium, Colors.amber.shade700, "MVP", mvp, isMvp: true)),
Expanded(child: _buildGridStatRow(context, Icons.shield, Colors.deepOrange.shade700, "Defesa", topDef)),
],
),
SizedBox(height: 8 * context.sf),
Row(
children: [
Expanded(child: _buildGridStatRow(context, Icons.bolt, Colors.blue.shade700, "Pontos", topPts)),
Expanded(child: _buildGridStatRow(context, Icons.trending_up, Colors.purple.shade700, "Rebotes", topRbs)),
],
),
SizedBox(height: 8 * context.sf),
Row(
children: [
Expanded(child: _buildGridStatRow(context, Icons.star, Colors.green.shade700, "Assists", topAst)),
const Expanded(child: SizedBox()),
],
),
],
),
),
),
)
],
),
);
}
Widget _buildGridStatRow(BuildContext context, IconData icon, Color color, String label, String value, {bool isMvp = false}) {
return Row(
children: [
Icon(icon, size: 14 * context.sf, color: color),
SizedBox(width: 4 * context.sf),
Text('$label: ', style: TextStyle(fontSize: 11 * context.sf, color: Colors.grey, fontWeight: FontWeight.bold)),
Expanded(
child: Text(
value,
style: TextStyle(
fontSize: 11 * context.sf,
color: isMvp ? AppTheme.warningAmber : Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.bold
),
maxLines: 1, overflow: TextOverflow.ellipsis
)
),
],
);
}
}

View File

@@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:playmaker/controllers/login_controller.dart';
import '../widgets/login_widgets.dart';
import 'home.dart'; // <--- IMPORTANTE: Importa a tua HomeScreen
import 'home.dart';
import '../utils/size_extension.dart';
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@@ -22,35 +23,30 @@ class _LoginPageState extends State<LoginPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
// 👇 Adaptável ao Modo Claro/Escuro do Flutter
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: SafeArea(
child: ListenableBuilder(
listenable: controller,
builder: (context, child) {
return LayoutBuilder(
builder: (context, constraints) {
final screenWidth = constraints.maxWidth;
return Center(
child: SingleChildScrollView(
child: Container(
width: screenWidth * 0.6,
constraints: const BoxConstraints(minWidth: 340),
padding: const EdgeInsets.all(32),
width: double.infinity,
constraints: BoxConstraints(maxWidth: 450 * context.sf),
padding: EdgeInsets.all(32 * context.sf),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const BasketTrackHeader(),
const SizedBox(height: 40),
SizedBox(height: 40 * context.sf),
LoginFormFields(controller: controller),
const SizedBox(height: 24),
SizedBox(height: 24 * context.sf),
// AQUI ESTÁ A MUDANÇA PRINCIPAL
LoginButton(
controller: controller,
onLoginSuccess: () {
// Verifica se o widget ainda está no ecrã antes de navegar
if (mounted) {
Navigator.pushReplacement(
context,
@@ -59,7 +55,7 @@ class _LoginPageState extends State<LoginPage> {
}
},
),
const SizedBox(height: 16),
SizedBox(height: 16 * context.sf),
const CreateAccountButton(),
],
@@ -68,8 +64,6 @@ class _LoginPageState extends State<LoginPage> {
),
);
},
);
},
),
),
);

View File

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

View File

@@ -0,0 +1,385 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:playmaker/classe/theme.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:image_picker/image_picker.dart';
import 'package:cached_network_image/cached_network_image.dart'; // 👇 IMPORTAÇÃO PARA CACHE
import 'package:shared_preferences/shared_preferences.dart'; // 👇 IMPORTAÇÃO PARA MEMÓRIA RÁPIDA
import '../utils/size_extension.dart';
import 'login.dart';
import '../main.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
File? _localImageFile;
String? _uploadedImageUrl;
bool _isUploadingImage = false;
bool _isMemoryLoaded = false; // 👇 VARIÁVEL MÁGICA CONTRA O PISCAR
final supabase = Supabase.instance.client;
@override
void initState() {
super.initState();
_loadUserAvatar();
}
// 👇 LÊ A IMAGEM DA MEMÓRIA INSTANTANEAMENTE E CONFIRMA NA BD
Future<void> _loadUserAvatar() async {
// 1. Lê da memória rápida primeiro!
final prefs = await SharedPreferences.getInstance();
final savedUrl = prefs.getString('meu_avatar_guardado');
if (mounted) {
setState(() {
if (savedUrl != null) _uploadedImageUrl = savedUrl;
_isMemoryLoaded = true; // Avisa que já leu a memória
});
}
final userId = supabase.auth.currentUser?.id;
if (userId == null) return;
try {
final data = await supabase
.from('profiles')
.select('avatar_url')
.eq('id', userId)
.maybeSingle();
if (mounted && data != null && data['avatar_url'] != null) {
final urlDoSupabase = data['avatar_url'];
// Atualiza a memória se a foto na base de dados for diferente
if (urlDoSupabase != savedUrl) {
await prefs.setString('meu_avatar_guardado', urlDoSupabase);
setState(() {
_uploadedImageUrl = urlDoSupabase;
});
}
}
} catch (e) {
print("Erro ao carregar avatar: $e");
}
}
Future<void> _handleImageChange() async {
final ImagePicker picker = ImagePicker();
final XFile? pickedFile = await picker.pickImage(source: ImageSource.gallery);
if (pickedFile == null || !mounted) return;
try {
setState(() {
_localImageFile = File(pickedFile.path);
_isUploadingImage = true;
});
final userId = supabase.auth.currentUser?.id;
if (userId == null) throw Exception("Utilizador não autenticado.");
final String storagePath = '$userId/profile_picture.png';
await supabase.storage.from('avatars').upload(
storagePath,
_localImageFile!,
fileOptions: const FileOptions(cacheControl: '3600', upsert: true)
);
final String publicUrl = supabase.storage.from('avatars').getPublicUrl(storagePath);
await supabase
.from('profiles')
.upsert({
'id': userId,
'avatar_url': publicUrl
});
// 👇 MÁGICA: GUARDA LOGO O NOVO URL NA MEMÓRIA PARA A HOME SABER!
final prefs = await SharedPreferences.getInstance();
await prefs.setString('meu_avatar_guardado', publicUrl);
if (mounted) {
setState(() {
_uploadedImageUrl = publicUrl;
_isUploadingImage = false;
_localImageFile = null;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Foto atualizada!"), backgroundColor: Colors.green)
);
}
} catch (e) {
if (mounted) {
setState(() {
_isUploadingImage = false;
_localImageFile = null;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Erro: $e"), backgroundColor: AppTheme.primaryRed)
);
}
}
}
@override
Widget build(BuildContext context) {
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);
bool isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: bgColor,
appBar: AppBar(
backgroundColor: primaryRed,
foregroundColor: Colors.white,
elevation: 0,
centerTitle: true,
title: Text(
"Perfil e Definições",
style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.w600),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
),
body: SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: 16.0 * context.sf, vertical: 24.0 * context.sf),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: EdgeInsets.all(20 * context.sf),
decoration: BoxDecoration(
color: cardColor,
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)),
],
),
child: Row(
children: [
_buildTappableProfileAvatar(context, primaryRed),
SizedBox(width: 16 * context.sf),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Treinador",
style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold, color: textColor),
),
SizedBox(height: 4 * context.sf),
Text(
supabase.auth.currentUser?.email ?? "sem@email.com",
style: TextStyle(color: textLightColor, fontSize: 14 * context.sf),
),
],
),
),
],
),
),
SizedBox(height: 32 * context.sf),
Padding(
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),
),
),
Container(
decoration: BoxDecoration(
color: cardColor,
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)),
],
),
child: ListTile(
contentPadding: EdgeInsets.symmetric(horizontal: 20 * context.sf, vertical: 8 * context.sf),
leading: Icon(isDark ? Icons.dark_mode : Icons.light_mode, color: primaryRed, size: 28 * context.sf),
title: Text(
"Modo Escuro",
style: TextStyle(fontWeight: FontWeight.bold, color: textColor, fontSize: 16 * context.sf),
),
subtitle: Text(
"Altera as cores da aplicação",
style: TextStyle(color: textLightColor, fontSize: 13 * context.sf),
),
trailing: Switch(
value: isDark,
activeColor: primaryRed,
onChanged: (bool value) {
themeNotifier.value = value ? ThemeMode.dark : ThemeMode.light;
},
),
),
),
SizedBox(height: 32 * context.sf),
Padding(
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),
),
),
Container(
decoration: BoxDecoration(
color: cardColor,
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)),
],
),
child: ListTile(
contentPadding: EdgeInsets.symmetric(horizontal: 20 * context.sf, vertical: 4 * context.sf),
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),
),
onTap: () => _confirmLogout(context),
),
),
SizedBox(height: 50 * context.sf),
Center(
child: Text(
"PlayMaker v1.0.0",
style: TextStyle(color: textLightColor.withOpacity(0.7), fontSize: 13 * context.sf),
),
),
SizedBox(height: 20 * context.sf),
],
),
),
);
}
// 👇 AVATAR OTIMIZADO: SEM LAG, COM CACHE E MEMÓRIA
Widget _buildTappableProfileAvatar(BuildContext context, Color primaryRed) {
return GestureDetector(
onTap: () {
_handleImageChange();
},
child: Stack(
alignment: Alignment.center,
children: [
Container(
width: 72 * context.sf,
height: 72 * context.sf,
decoration: BoxDecoration(
color: primaryRed.withOpacity(0.1),
shape: BoxShape.circle,
),
child: ClipOval(
child: _isUploadingImage && _localImageFile != null
// 1. Mostrar imagem local (galeria) ENQUANTO está a fazer upload
? Image.file(_localImageFile!, fit: BoxFit.cover)
// 2. Antes da memória carregar, fica só o fundo (evita piscar)
: !_isMemoryLoaded
? const SizedBox()
// 3. Depois da memória carregar, se houver URL, desenha com Cache!
: _uploadedImageUrl != null && _uploadedImageUrl!.isNotEmpty
? CachedNetworkImage(
imageUrl: _uploadedImageUrl!,
fit: BoxFit.cover,
fadeInDuration: Duration.zero, // Fica instantâneo!
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) => Icon(Icons.person, color: primaryRed, size: 36 * context.sf),
)
// 4. Se não houver URL, mete o boneco
: Icon(Icons.person, color: primaryRed, size: 36 * context.sf),
),
),
// ÍCONE DE LÁPIS
Positioned(
bottom: 0,
right: 0,
child: Container(
padding: EdgeInsets.all(6 * context.sf),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(color: Colors.grey.withOpacity(0.2)),
),
child: Icon(Icons.edit_outlined, color: primaryRed, size: 16 * context.sf),
),
),
// LOADING OVERLAY (Enquanto faz o upload)
if (_isUploadingImage)
Positioned.fill(
child: Container(
decoration: BoxDecoration(color: Colors.black.withOpacity(0.4), shape: BoxShape.circle),
child: const Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 3),
),
),
),
],
),
);
}
void _confirmLogout(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: Theme.of(context).colorScheme.surface,
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))),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryRed, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
onPressed: () async {
// Limpa a memória do Avatar ao sair para não aparecer na conta de outra pessoa!
final prefs = await SharedPreferences.getInstance();
await prefs.remove('meu_avatar_guardado');
await Supabase.instance.client.auth.signOut();
if (ctx.mounted) {
Navigator.of(ctx).pushAndRemoveUntil(
MaterialPageRoute(builder: (context) => const LoginPage()),
(Route<dynamic> route) => false,
);
}
},
child: const Text("Sair", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold))
),
],
),
);
}
}

257
lib/pages/status_page.dart Normal file
View File

@@ -0,0 +1,257 @@
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:playmaker/classe/theme.dart';
import 'package:cached_network_image/cached_network_image.dart'; // 👇 A MAGIA DO CACHE
import '../controllers/team_controller.dart';
import '../utils/size_extension.dart';
class StatusPage extends StatefulWidget {
const StatusPage({super.key});
@override
State<StatusPage> createState() => _StatusPageState();
}
class _StatusPageState extends State<StatusPage> {
final TeamController _teamController = TeamController();
final _supabase = Supabase.instance.client;
String? _selectedTeamId;
String _selectedTeamName = "Selecionar Equipa";
String _sortColumn = 'pts';
bool _isAscending = false;
@override
Widget build(BuildContext context) {
final bgColor = Theme.of(context).cardTheme.color ?? Colors.white;
final textColor = Theme.of(context).colorScheme.onSurface;
return Column(
children: [
Padding(
padding: EdgeInsets.all(16.0 * context.sf),
child: InkWell(
onTap: () => _showTeamSelector(context),
child: Container(
padding: EdgeInsets.all(12 * context.sf),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(15 * context.sf),
border: Border.all(color: Colors.grey.withOpacity(0.2)),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 5)]
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(children: [
Icon(Icons.shield, color: AppTheme.primaryRed, size: 24 * context.sf),
SizedBox(width: 10 * context.sf),
Text(_selectedTeamName, style: TextStyle(fontSize: 16 * context.sf, fontWeight: FontWeight.bold, color: textColor))
]),
Icon(Icons.arrow_drop_down, color: textColor),
],
),
),
),
),
Expanded(
child: _selectedTeamId == null
? Center(child: Text("Seleciona uma equipa acima.", style: TextStyle(color: Colors.grey, fontSize: 14 * context.sf)))
: StreamBuilder<List<Map<String, dynamic>>>(
stream: _supabase.from('player_stats_with_names').stream(primaryKey: ['id']).eq('team_id', _selectedTeamId!),
builder: (context, statsSnapshot) {
return StreamBuilder<List<Map<String, dynamic>>>(
stream: _supabase.from('games').stream(primaryKey: ['id']).eq('my_team', _selectedTeamName),
builder: (context, gamesSnapshot) {
return StreamBuilder<List<Map<String, dynamic>>>(
stream: _supabase.from('members').stream(primaryKey: ['id']).eq('team_id', _selectedTeamId!),
builder: (context, membersSnapshot) {
if (statsSnapshot.connectionState == ConnectionState.waiting || gamesSnapshot.connectionState == ConnectionState.waiting || membersSnapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator(color: AppTheme.primaryRed));
}
final membersData = membersSnapshot.data ?? [];
if (membersData.isEmpty) return Center(child: Text("Esta equipa não tem jogadores registados.", style: TextStyle(color: Colors.grey, fontSize: 14 * context.sf)));
final statsData = statsSnapshot.data ?? [];
final gamesData = gamesSnapshot.data ?? [];
final totalGamesPlayedByTeam = gamesData.where((g) => g['status'] == 'Terminado').length;
final List<Map<String, dynamic>> playerTotals = _aggregateStats(statsData, gamesData, membersData);
final teamTotals = _calculateTeamTotals(playerTotals, totalGamesPlayedByTeam);
playerTotals.sort((a, b) {
var valA = a[_sortColumn] ?? 0;
var valB = b[_sortColumn] ?? 0;
return _isAscending ? valA.compareTo(valB) : valB.compareTo(valA);
});
return _buildStatsGrid(context, playerTotals, teamTotals, bgColor, textColor);
}
);
}
);
}
),
),
],
);
}
// 👇 AGORA GUARDA TAMBÉM O IMAGE_URL DO MEMBRO PARA MOSTRAR NA TABELA
List<Map<String, dynamic>> _aggregateStats(List<dynamic> stats, List<dynamic> games, List<dynamic> members) {
Map<String, Map<String, dynamic>> aggregated = {};
for (var member in members) {
String name = member['name']?.toString() ?? "Desconhecido";
String? imageUrl = member['image_url']?.toString(); // 👈 CAPTURA A IMAGEM AQUI
aggregated[name] = {'name': name, 'image_url': imageUrl, 'j': 0, 'pts': 0, 'ast': 0, 'rbs': 0, 'stl': 0, 'blk': 0, 'mvp': 0, 'def': 0};
}
for (var row in stats) {
String name = row['player_name']?.toString() ?? "Desconhecido";
if (!aggregated.containsKey(name)) aggregated[name] = {'name': name, 'image_url': null, 'j': 0, 'pts': 0, 'ast': 0, 'rbs': 0, 'stl': 0, 'blk': 0, 'mvp': 0, 'def': 0};
aggregated[name]!['j'] += 1;
aggregated[name]!['pts'] += (row['pts'] ?? 0);
aggregated[name]!['ast'] += (row['ast'] ?? 0);
aggregated[name]!['rbs'] += (row['rbs'] ?? 0);
aggregated[name]!['stl'] += (row['stl'] ?? 0);
aggregated[name]!['blk'] += (row['blk'] ?? 0);
}
for (var game in games) {
String? mvp = game['mvp_name'];
String? defRaw = game['top_def_name'];
if (mvp != null && aggregated.containsKey(mvp)) aggregated[mvp]!['mvp'] += 1;
if (defRaw != null) {
String defName = defRaw.split(' (')[0].trim();
if (aggregated.containsKey(defName)) aggregated[defName]!['def'] += 1;
}
}
return aggregated.values.toList();
}
Map<String, dynamic> _calculateTeamTotals(List<Map<String, dynamic>> players, int teamGames) {
int tPts = 0, tAst = 0, tRbs = 0, tStl = 0, tBlk = 0, tMvp = 0, tDef = 0;
for (var p in players) {
tPts += (p['pts'] as int); tAst += (p['ast'] as int); tRbs += (p['rbs'] as int); tStl += (p['stl'] as int); tBlk += (p['blk'] as int); tMvp += (p['mvp'] as int); tDef += (p['def'] as int);
}
return {'name': 'TOTAL EQUIPA', 'image_url': null, 'j': teamGames, 'pts': tPts, 'ast': tAst, 'rbs': tRbs, 'stl': tStl, 'blk': tBlk, 'mvp': tMvp, 'def': tDef};
}
Widget _buildStatsGrid(BuildContext context, List<Map<String, dynamic>> players, Map<String, dynamic> teamTotals, Color bgColor, Color textColor) {
return Container(
color: Colors.transparent,
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columnSpacing: 25 * context.sf,
headingRowColor: WidgetStateProperty.all(Theme.of(context).colorScheme.surface),
dataRowMaxHeight: 60 * context.sf,
dataRowMinHeight: 60 * context.sf,
columns: [
DataColumn(label: Text('JOGADOR', style: TextStyle(color: textColor))),
_buildSortableColumn(context, 'J', 'j', textColor),
_buildSortableColumn(context, 'PTS', 'pts', textColor),
_buildSortableColumn(context, 'AST', 'ast', textColor),
_buildSortableColumn(context, 'RBS', 'rbs', textColor),
_buildSortableColumn(context, 'STL', 'stl', textColor),
_buildSortableColumn(context, 'BLK', 'blk', textColor),
_buildSortableColumn(context, 'DEF 🛡️', 'def', textColor),
_buildSortableColumn(context, 'MVP 🏆', 'mvp', textColor),
],
rows: [
...players.map((player) => DataRow(cells: [
DataCell(
Row(
children: [
// 👇 FOTO DO JOGADOR NA TABELA (COM CACHE!) 👇
ClipOval(
child: Container(
width: 30 * context.sf,
height: 30 * context.sf,
color: Colors.grey.withOpacity(0.2),
child: (player['image_url'] != null && player['image_url'].toString().isNotEmpty)
? CachedNetworkImage(
imageUrl: player['image_url'],
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
placeholder: (context, url) => Icon(Icons.person, size: 18 * context.sf, color: Colors.grey),
errorWidget: (context, url, error) => Icon(Icons.person, size: 18 * context.sf, color: Colors.grey),
)
: Icon(Icons.person, size: 18 * context.sf, color: Colors.grey),
),
),
SizedBox(width: 10 * context.sf),
Text(player['name'], style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13 * context.sf, color: textColor))
]
)
),
DataCell(Center(child: Text(player['j'].toString(), style: TextStyle(color: textColor)))),
_buildStatCell(context, player['pts'], textColor, isHighlight: true),
_buildStatCell(context, player['ast'], textColor),
_buildStatCell(context, player['rbs'], textColor),
_buildStatCell(context, player['stl'], textColor),
_buildStatCell(context, player['blk'], textColor),
_buildStatCell(context, player['def'], textColor, isBlue: true),
_buildStatCell(context, player['mvp'], textColor, isGold: true),
])),
DataRow(
color: WidgetStateProperty.all(Theme.of(context).colorScheme.surface.withOpacity(0.5)),
cells: [
DataCell(Text('TOTAL EQUIPA', style: TextStyle(fontWeight: FontWeight.w900, color: textColor, fontSize: 12 * context.sf))),
DataCell(Center(child: Text(teamTotals['j'].toString(), style: TextStyle(fontWeight: FontWeight.bold, color: textColor)))),
_buildStatCell(context, teamTotals['pts'], textColor, isHighlight: true),
_buildStatCell(context, teamTotals['ast'], textColor),
_buildStatCell(context, teamTotals['rbs'], textColor),
_buildStatCell(context, teamTotals['stl'], textColor),
_buildStatCell(context, teamTotals['blk'], textColor),
_buildStatCell(context, teamTotals['def'], textColor, isBlue: true),
_buildStatCell(context, teamTotals['mvp'], textColor, isGold: true),
]
)
],
),
),
),
);
}
DataColumn _buildSortableColumn(BuildContext context, String title, String sortKey, Color textColor) {
return DataColumn(label: InkWell(
onTap: () => setState(() {
if (_sortColumn == sortKey) _isAscending = !_isAscending;
else { _sortColumn = sortKey; _isAscending = false; }
}),
child: Row(children: [
Text(title, style: TextStyle(fontSize: 12 * context.sf, fontWeight: FontWeight.bold, color: textColor)),
if (_sortColumn == sortKey) Icon(_isAscending ? Icons.arrow_drop_up : Icons.arrow_drop_down, size: 18 * context.sf, color: AppTheme.primaryRed),
]),
));
}
DataCell _buildStatCell(BuildContext context, int value, Color textColor, {bool isHighlight = false, bool isGold = false, bool isBlue = false}) {
return DataCell(Center(child: Container(
padding: EdgeInsets.symmetric(horizontal: 8 * context.sf, vertical: 4 * context.sf),
decoration: BoxDecoration(color: isGold && value > 0 ? Colors.amber.withOpacity(0.2) : (isBlue && value > 0 ? Colors.blue.withOpacity(0.1) : Colors.transparent), borderRadius: BorderRadius.circular(6)),
child: Text(value == 0 ? "-" : value.toString(), style: TextStyle(
fontWeight: (isHighlight || isGold || isBlue) ? FontWeight.w900 : FontWeight.w600,
fontSize: 14 * context.sf, color: isGold && value > 0 ? Colors.orange.shade900 : (isBlue && value > 0 ? Colors.blue.shade800 : (isHighlight ? AppTheme.successGreen : textColor))
)),
)));
}
void _showTeamSelector(BuildContext context) {
showModalBottomSheet(context: context, backgroundColor: Theme.of(context).colorScheme.surface, builder: (context) => StreamBuilder<List<Map<String, dynamic>>>(
stream: _teamController.teamsStream,
builder: (context, snapshot) {
final teams = snapshot.data ?? [];
return ListView.builder(itemCount: teams.length, itemBuilder: (context, i) => ListTile(
title: Text(teams[i]['name'], style: TextStyle(color: Theme.of(context).colorScheme.onSurface)),
onTap: () { setState(() { _selectedTeamId = teams[i]['id']; _selectedTeamName = teams[i]['name']; }); Navigator.pop(context); },
));
},
));
}
}

View File

@@ -1,8 +1,13 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:image_cropper/image_cropper.dart';
import 'package:cached_network_image/cached_network_image.dart'; // 👇 A MAGIA DO CACHE AQUI
import 'package:playmaker/screens/team_stats_page.dart';
import 'package:playmaker/classe/theme.dart';
import '../controllers/team_controller.dart';
import '../models/team_model.dart';
import '../widgets/team_widgets.dart';
import '../utils/size_extension.dart';
class TeamsPage extends StatefulWidget {
const TeamsPage({super.key});
@@ -25,7 +30,6 @@ class _TeamsPageState extends State<TeamsPage> {
super.dispose();
}
// --- POPUP DE FILTROS ---
void _showFilterDialog(BuildContext context) {
showDialog(
context: context,
@@ -33,17 +37,14 @@ class _TeamsPageState extends State<TeamsPage> {
return StatefulBuilder(
builder: (context, setModalState) {
return AlertDialog(
backgroundColor: const Color(0xFF2C3E50), // 2. CORRIGIDO: Fundo escuro
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
backgroundColor: Theme.of(context).colorScheme.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20 * context.sf)),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Filtros de pesquisa",
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
),
Text("Filtros de pesquisa", style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 18 * context.sf, fontWeight: FontWeight.bold)),
IconButton(
icon: const Icon(Icons.close, color: Colors.white, size: 20),
icon: Icon(Icons.close, color: Colors.grey, size: 20 * context.sf),
onPressed: () => Navigator.pop(context),
)
],
@@ -51,12 +52,11 @@ class _TeamsPageState extends State<TeamsPage> {
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Divider(color: Colors.white24),
const SizedBox(height: 16),
Divider(color: Colors.grey.withOpacity(0.2)),
SizedBox(height: 16 * context.sf),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Coluna Temporada
Expanded(
child: _buildPopupColumn(
title: "TEMPORADA",
@@ -68,8 +68,7 @@ class _TeamsPageState extends State<TeamsPage> {
},
),
),
const SizedBox(width: 20),
// Coluna Ordenar
SizedBox(width: 20 * context.sf),
Expanded(
child: _buildPopupColumn(
title: "ORDENAR POR",
@@ -88,7 +87,7 @@ class _TeamsPageState extends State<TeamsPage> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("CONCLUÍDO", style: TextStyle(color: Color(0xFFE74C3C), fontWeight: FontWeight.bold)),
child: Text("CONCLUÍDO", style: TextStyle(color: AppTheme.primaryRed, fontWeight: FontWeight.bold, fontSize: 14 * context.sf)),
),
],
);
@@ -98,29 +97,24 @@ class _TeamsPageState extends State<TeamsPage> {
);
}
Widget _buildPopupColumn({
required String title,
required List<String> options,
required String currentValue,
required Function(String) onSelect,
}) {
Widget _buildPopupColumn({required String title, required List<String> options, required String currentValue, required Function(String) onSelect}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(color: Colors.grey, fontSize: 11, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
Text(title, style: TextStyle(color: Colors.grey, fontSize: 11 * context.sf, fontWeight: FontWeight.bold)),
SizedBox(height: 12 * context.sf),
...options.map((opt) {
final isSelected = currentValue == opt;
return InkWell(
onTap: () => onSelect(opt),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
padding: EdgeInsets.symmetric(vertical: 8.0 * context.sf),
child: Text(
opt,
style: TextStyle(
// 3. CORRIGIDO: Cor do texto (Branco se não selecionado, Vermelho se selecionado)
color: isSelected ? const Color(0xFFE74C3C) : Colors.white70,
color: isSelected ? AppTheme.primaryRed : Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
fontSize: 14 * context.sf,
),
),
),
@@ -133,14 +127,14 @@ class _TeamsPageState extends State<TeamsPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F7FA),
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: AppBar(
title: const Text("Minhas Equipas", style: TextStyle(fontWeight: FontWeight.bold)),
backgroundColor: const Color(0xFFF5F7FA),
title: Text("Minhas Equipas", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20 * context.sf)),
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.filter_list, color: Color(0xFFE74C3C)),
icon: Icon(Icons.filter_list, color: AppTheme.primaryRed, size: 24 * context.sf),
onPressed: () => _showFilterDialog(context),
),
],
@@ -152,8 +146,9 @@ class _TeamsPageState extends State<TeamsPage> {
],
),
floatingActionButton: FloatingActionButton(
backgroundColor: const Color(0xFFE74C3C),
child: const Icon(Icons.add, color: Colors.white),
heroTag: 'add_team_btn',
backgroundColor: AppTheme.primaryRed,
child: Icon(Icons.add, color: Colors.white, size: 24 * context.sf),
onPressed: () => _showCreateDialog(context),
),
);
@@ -161,82 +156,67 @@ class _TeamsPageState extends State<TeamsPage> {
Widget _buildSearchBar() {
return Padding(
padding: const EdgeInsets.all(16.0),
padding: EdgeInsets.all(16.0 * context.sf),
child: TextField(
controller: _searchController,
onChanged: (v) => setState(() => _searchQuery = v.toLowerCase()),
style: TextStyle(fontSize: 16 * context.sf, color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration(
hintText: 'Pesquisar equipa...',
prefixIcon: const Icon(Icons.search, color: Color(0xFFE74C3C)),
hintStyle: TextStyle(fontSize: 16 * context.sf, color: Colors.grey),
prefixIcon: Icon(Icons.search, color: AppTheme.primaryRed, size: 22 * context.sf),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(15), borderSide: BorderSide.none),
fillColor: Theme.of(context).colorScheme.surface,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(15 * context.sf), borderSide: BorderSide.none),
),
),
);
}
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 const Center(child: CircularProgressIndicator());
}
if (!snapshot.hasData || snapshot.data!.isEmpty) {
return const Center(child: Text("Nenhuma equipa encontrada."));
}
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)));
var data = List<Map<String, dynamic>>.from(snapshot.data!);
// --- 1. FILTROS ---
if (_selectedSeason != 'Todas') {
data = data.where((t) => t['season'] == _selectedSeason).toList();
}
if (_searchQuery.isNotEmpty) {
data = data.where((t) => t['name'].toString().toLowerCase().contains(_searchQuery)).toList();
}
if (_selectedSeason != 'Todas') data = data.where((t) => t['season'] == _selectedSeason).toList();
if (_searchQuery.isNotEmpty) data = data.where((t) => t['name'].toString().toLowerCase().contains(_searchQuery)).toList();
// --- 2. ORDENAÇÃO (FAVORITOS PRIMEIRO) ---
data.sort((a, b) {
// Apanhar o estado de favorito (tratando null como false)
bool favA = a['is_favorite'] ?? false;
bool favB = b['is_favorite'] ?? false;
// REGRA 1: Favoritos aparecem sempre primeiro
if (favA && !favB) return -1; // A sobe
if (!favA && favB) return 1; // B sobe
// REGRA 2: Se o estado de favorito for igual, aplica o filtro do utilizador
if (_currentSort == 'Nome') {
return a['name'].toString().compareTo(b['name'].toString());
} else { // Recentes
return (b['created_at'] ?? '').toString().compareTo((a['created_at'] ?? '').toString());
}
if (favA && !favB) return -1;
if (!favA && favB) return 1;
if (_currentSort == 'Nome') return a['name'].toString().compareTo(b['name'].toString());
else return (b['created_at'] ?? '').toString().compareTo((a['created_at'] ?? '').toString());
});
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
return RefreshIndicator(
color: AppTheme.primaryRed,
onRefresh: () async => setState(() {}),
child: ListView.builder(
padding: EdgeInsets.symmetric(horizontal: 16 * context.sf),
itemCount: data.length,
itemBuilder: (context, index) {
final team = Team.fromMap(data[index]);
// Navegação para estatísticas
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => TeamStatsPage(team: team)),
);
},
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (context) => TeamStatsPage(team: team))).then((_) => setState(() {})),
child: TeamCard(
team: team,
controller: controller,
onFavoriteTap: () => controller.toggleFavorite(team.id, team.isFavorite),
onFavoriteTap: () async {
await controller.toggleFavorite(team.id, team.isFavorite);
setState(() {});
},
onDelete: () => setState(() {}),
sf: context.sf,
),
);
},
),
);
},
);
@@ -246,10 +226,290 @@ class _TeamsPageState extends State<TeamsPage> {
showDialog(
context: context,
builder: (context) => CreateTeamDialog(
onConfirm: (name, season, imageUrl) => controller.createTeam(name, season, imageUrl),
sf: context.sf,
onConfirm: (name, season, imageFile) async {
await controller.createTeam(name, season, imageFile);
setState(() {});
}
),
);
}
}
// --- TEAM CARD ---
class TeamCard extends StatelessWidget {
final Team team;
final TeamController controller;
final VoidCallback onFavoriteTap;
final VoidCallback onDelete;
final double sf;
const TeamCard({
super.key,
required this.team,
required this.controller,
required this.onFavoriteTap,
required this.onDelete,
required this.sf,
});
@override
Widget build(BuildContext context) {
final bgColor = Theme.of(context).cardTheme.color ?? Theme.of(context).colorScheme.surface;
final textColor = Theme.of(context).colorScheme.onSurface;
final double avatarSize = 56 * sf; // 2 * radius (28)
return Container(
margin: EdgeInsets.only(bottom: 12 * sf),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(15 * sf),
border: Border.all(color: Colors.grey.withOpacity(0.15)),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10 * sf)]
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(15 * sf),
child: ListTile(
contentPadding: EdgeInsets.symmetric(horizontal: 16 * sf, vertical: 8 * sf),
leading: Stack(
clipBehavior: Clip.none,
children: [
// 👇 AVATAR DA EQUIPA OTIMIZADO COM CACHE 👇
ClipOval(
child: Container(
width: avatarSize,
height: avatarSize,
color: Colors.grey.withOpacity(0.2),
child: (team.imageUrl.isNotEmpty && team.imageUrl.startsWith('http'))
? CachedNetworkImage(
imageUrl: team.imageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero, // Fica instantâneo no scroll
placeholder: (context, url) => const SizedBox(), // Fica só o fundo cinza
errorWidget: (context, url, error) => Center(
child: Text("🏀", style: TextStyle(fontSize: 24 * sf)),
),
)
: Center(
child: Text(
team.imageUrl.isEmpty ? "🏀" : team.imageUrl,
style: TextStyle(fontSize: 24 * sf),
),
),
),
),
Positioned(
left: -15 * sf,
top: -10 * sf,
child: IconButton(
icon: Icon(
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)],
),
onPressed: onFavoriteTap,
),
),
],
),
title: Text(
team.name,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16 * sf, color: textColor),
overflow: TextOverflow.ellipsis,
),
subtitle: Padding(
padding: EdgeInsets.only(top: 6.0 * sf),
child: Row(
children: [
Icon(Icons.groups_outlined, size: 16 * sf, color: Colors.grey),
SizedBox(width: 4 * sf),
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),
),
],
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
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))).then((_) => onDelete()),
),
IconButton(
tooltip: 'Eliminar Equipa',
icon: Icon(Icons.delete_outline, color: AppTheme.primaryRed, size: 24 * sf),
onPressed: () => _confirmDelete(context, sf, bgColor, textColor),
),
],
),
),
),
);
}
void _confirmDelete(BuildContext context, double sf, Color cardColor, Color textColor) {
showDialog(
context: context,
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(ctx),
child: Text('Cancelar', style: TextStyle(fontSize: 14 * sf, color: Colors.grey)),
),
TextButton(
onPressed: () {
Navigator.pop(ctx);
onDelete();
controller.deleteTeam(team.id).catchError((e) {
if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Erro ao eliminar: $e'), backgroundColor: Colors.red));
});
},
child: Text('Eliminar', style: TextStyle(color: AppTheme.primaryRed, fontSize: 14 * sf)),
),
],
),
);
}
}
// --- DIALOG DE CRIAÇÃO (COM CROPPER E ESCUDO) ---
class CreateTeamDialog extends StatefulWidget {
final Function(String name, String season, File? imageFile) onConfirm;
final double sf;
const CreateTeamDialog({super.key, required this.onConfirm, required this.sf});
@override
State<CreateTeamDialog> createState() => _CreateTeamDialogState();
}
class _CreateTeamDialogState extends State<CreateTeamDialog> {
final TextEditingController _nameController = TextEditingController();
String _selectedSeason = '2024/25';
File? _selectedImage;
bool _isLoading = false;
bool _isPickerActive = false;
Future<void> _pickImage() async {
if (_isPickerActive) return;
setState(() => _isPickerActive = true);
try {
final ImagePicker picker = ImagePicker();
final XFile? pickedFile = await picker.pickImage(source: ImageSource.gallery);
if (pickedFile != null) {
CroppedFile? croppedFile = await ImageCropper().cropImage(
sourcePath: pickedFile.path,
aspectRatio: const CropAspectRatio(ratioX: 1, ratioY: 1),
uiSettings: [
AndroidUiSettings(
toolbarTitle: 'Recortar Logo',
toolbarColor: AppTheme.primaryRed,
toolbarWidgetColor: Colors.white,
initAspectRatio: CropAspectRatioPreset.square,
lockAspectRatio: true,
hideBottomControls: true,
),
IOSUiSettings(title: 'Recortar Logo', aspectRatioLockEnabled: true, resetButtonHidden: true),
],
);
if (croppedFile != null && mounted) {
setState(() {
_selectedImage = File(croppedFile.path);
});
}
}
} finally {
if (mounted) setState(() => _isPickerActive = false);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
backgroundColor: Theme.of(context).colorScheme.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * widget.sf)),
title: Text('Nova Equipa', style: TextStyle(fontSize: 18 * widget.sf, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface)),
content: SingleChildScrollView(
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>(
dropdownColor: Theme.of(context).colorScheme.surface,
value: _selectedSeason, decoration: InputDecoration(labelText: 'Temporada', labelStyle: TextStyle(fontSize: 14 * widget.sf)),
style: TextStyle(fontSize: 14 * widget.sf, color: Theme.of(context).colorScheme.onSurface),
items: ['2023/24', '2024/25', '2025/26'].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
onChanged: (val) => setState(() => _selectedSeason = val!),
),
],
),
),
actions: [
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: _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)),
),
],
);
}
}

View File

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

View File

@@ -1,53 +1,103 @@
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'; // 👇 MAGIA DO CACHE AQUI
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:playmaker/classe/theme.dart';
import '../models/team_model.dart';
import '../models/person_model.dart';
import '../utils/size_extension.dart';
// --- CABEÇALHO ---
// ==========================================
// 1. CABEÇALHO (AGORA COM CACHE DE IMAGEM INSTANTÂNEO)
// ==========================================
class StatsHeader extends StatelessWidget {
final Team team;
final String? currentImageUrl;
final VoidCallback onEditPhoto;
final bool isUploading;
const StatsHeader({super.key, required this.team});
const StatsHeader({
super.key,
required this.team,
required this.currentImageUrl,
required this.onEditPhoto,
required this.isUploading,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.only(top: 50, left: 20, right: 20, bottom: 20),
decoration: const BoxDecoration(
color: Color(0xFF2C3E50),
borderRadius: BorderRadius.only(bottomLeft: Radius.circular(30), bottomRight: Radius.circular(30)),
padding: EdgeInsets.only(top: 50 * context.sf, left: 20 * context.sf, right: 20 * context.sf, bottom: 20 * context.sf),
decoration: BoxDecoration(
color: AppTheme.primaryRed,
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(30 * context.sf),
bottomRight: Radius.circular(30 * context.sf)
),
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
icon: Icon(Icons.arrow_back, color: Colors.white, size: 24 * context.sf),
onPressed: () => Navigator.pop(context)
),
const SizedBox(width: 10),
SizedBox(width: 10 * context.sf),
// IMAGEM OU EMOJI DA EQUIPA AQUI!
CircleAvatar(
radius: 24,
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: const TextStyle(fontSize: 20),
GestureDetector(
onTap: onEditPhoto,
child: Stack(
alignment: Alignment.center,
children: [
// 👇 AVATAR DA EQUIPA SEM LAG 👇
ClipOval(
child: Container(
width: 56 * context.sf,
height: 56 * context.sf,
color: Colors.white24,
child: (currentImageUrl != null && currentImageUrl!.isNotEmpty && currentImageUrl!.startsWith('http'))
? CachedNetworkImage(
imageUrl: currentImageUrl!,
fit: BoxFit.cover,
fadeInDuration: Duration.zero, // Corta o atraso
placeholder: (context, url) => Center(child: Text("🛡️", style: TextStyle(fontSize: 24 * context.sf))),
errorWidget: (context, url, error) => Center(child: Text("🛡️", style: TextStyle(fontSize: 24 * context.sf))),
)
: null,
: Center(
child: Text(
(currentImageUrl != null && currentImageUrl!.isNotEmpty) ? currentImageUrl! : "🛡️",
style: TextStyle(fontSize: 24 * context.sf)
),
),
),
),
Positioned(
bottom: 0, right: 0,
child: Container(
padding: EdgeInsets.all(4 * context.sf),
decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle),
child: Icon(Icons.edit, color: AppTheme.primaryRed, size: 12 * context.sf),
),
),
if (isUploading)
Container(
width: 56 * context.sf, height: 56 * context.sf,
decoration: const BoxDecoration(color: Colors.black45, shape: BoxShape.circle),
child: const Padding(padding: EdgeInsets.all(12.0), child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)),
)
],
),
),
const SizedBox(width: 15),
Expanded( // Expanded evita overflow se o nome for muito longo
SizedBox(width: 15 * context.sf),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(team.name, style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold), overflow: TextOverflow.ellipsis),
Text(team.season, style: const TextStyle(color: Colors.white70, fontSize: 14)),
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)),
],
),
),
@@ -60,25 +110,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) {
final Color bgColor = Theme.of(context).brightness == Brightness.dark ? const Color(0xFF1E1E1E) : Colors.white;
return Card(
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20 * context.sf)),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(colors: [Colors.blue.shade700, Colors.blue.shade400]),
),
padding: EdgeInsets.all(20 * context.sf),
decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(20 * context.sf), border: Border.all(color: Colors.grey.withOpacity(0.15))),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("Total de Membros", style: TextStyle(color: Colors.white, fontSize: 16)),
Text("$total", style: const TextStyle(color: Colors.white, fontSize: 28, fontWeight: FontWeight.bold)),
Row(
children: [
Icon(Icons.groups, color: AppTheme.primaryRed, size: 28 * context.sf),
SizedBox(width: 10 * context.sf),
Text("Total de Membros", style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 16 * context.sf, fontWeight: FontWeight.w600)),
],
),
Text("$total", style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 28 * context.sf, fontWeight: FontWeight.bold)),
],
),
),
@@ -89,7 +142,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
@@ -97,63 +149,119 @@ class StatsSectionTitle extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Color(0xFF2C3E50))),
const Divider(),
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 SEM LAG) ---
class PersonCard extends StatelessWidget {
final Person person;
final bool isCoach;
final VoidCallback onEdit;
final VoidCallback onDelete;
const PersonCard({
super.key,
required this.person,
required this.isCoach,
required this.onEdit,
required this.onDelete,
});
const PersonCard({super.key, required this.person, required this.isCoach, required this.onEdit, required this.onDelete});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(top: 12),
elevation: 2,
color: isCoach ? const Color(0xFFFFF9C4) : Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
child: ListTile(
leading: isCoach
? const CircleAvatar(backgroundColor: Colors.orange, child: Icon(Icons.person, color: Colors.white))
: Container(
width: 45,
height: 45,
alignment: Alignment.center,
decoration: BoxDecoration(color: Colors.blue.withOpacity(0.1), borderRadius: BorderRadius.circular(10)),
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)),
final Color defaultBg = Theme.of(context).brightness == Brightness.dark ? const Color(0xFF1E1E1E) : Colors.white;
final Color coachBg = Theme.of(context).brightness == Brightness.dark ? AppTheme.warningAmber.withOpacity(0.1) : const Color(0xFFFFF9C4);
final String? pImage = person.imageUrl;
final Color iconColor = isCoach ? Colors.white : AppTheme.primaryRed;
// --- CANTO DIREITO (Trailing) ---
trailing: Row(
return Card(
margin: EdgeInsets.only(top: 12 * context.sf),
elevation: 2,
color: isCoach ? coachBg : defaultBg,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * context.sf)),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16 * context.sf, vertical: 12 * context.sf),
child: Row(
children: [
// 👇 FOTO DO JOGADOR/TREINADOR INSTANTÂNEA 👇
ClipOval(
child: Container(
width: 44 * context.sf,
height: 44 * context.sf,
color: isCoach ? AppTheme.warningAmber : AppTheme.primaryRed.withOpacity(0.1),
child: (pImage != null && pImage.isNotEmpty)
? CachedNetworkImage(
imageUrl: pImage,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
placeholder: (context, url) => Icon(Icons.person, color: iconColor, size: 24 * context.sf),
errorWidget: (context, url, error) => Icon(Icons.person, color: iconColor, size: 24 * context.sf),
)
: Icon(Icons.person, color: iconColor, size: 24 * context.sf),
),
),
SizedBox(width: 12 * context.sf),
Expanded(
child: Row(
children: [
if (!isCoach && person.number != null && person.number!.isNotEmpty) ...[
Container(
padding: EdgeInsets.symmetric(horizontal: 8 * context.sf, vertical: 4 * context.sf),
decoration: BoxDecoration(color: AppTheme.primaryRed.withOpacity(0.1), borderRadius: BorderRadius.circular(6 * context.sf)),
child: Text(person.number!, style: TextStyle(color: AppTheme.primaryRed, fontWeight: FontWeight.bold, fontSize: 14 * context.sf)),
),
SizedBox(width: 10 * context.sf),
],
Expanded(
child: Text(person.name, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16 * context.sf, color: Theme.of(context).colorScheme.onSurface), overflow: TextOverflow.ellipsis)
),
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
// IMAGEM DA EQUIPA NO CARD DO JOGADOR
const SizedBox(width: 5), // Espaço
IconButton(
icon: const Icon(Icons.edit_outlined, color: Colors.blue),
onPressed: onEdit,
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()),
],
),
IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: onDelete,
],
),
),
);
}
}
// ==========================================
// WIDGET NOVO: SKELETON LOADING (SHIMMER)
// ==========================================
class SkeletonLoadingStats extends StatelessWidget {
const SkeletonLoadingStats({super.key});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final baseColor = isDark ? Colors.grey[800]! : Colors.grey[300]!;
final highlightColor = isDark ? Colors.grey[700]! : Colors.grey[100]!;
return Shimmer.fromColors(
baseColor: baseColor,
highlightColor: highlightColor,
child: SingleChildScrollView(
padding: EdgeInsets.all(16.0 * context.sf),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(height: 80 * context.sf, width: double.infinity, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20 * context.sf))),
SizedBox(height: 30 * context.sf),
Container(height: 20 * context.sf, width: 150 * context.sf, color: Colors.white),
SizedBox(height: 10 * context.sf),
for (int i = 0; i < 3; i++) ...[
Container(
height: 60 * context.sf, width: double.infinity,
margin: EdgeInsets.only(top: 12 * context.sf),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(15 * context.sf)),
),
]
],
),
),
@@ -164,10 +272,8 @@ class PersonCard extends StatelessWidget {
// ==========================================
// 2. PÁGINA PRINCIPAL
// ==========================================
class TeamStatsPage extends StatefulWidget {
final Team team;
const TeamStatsPage({super.key, required this.team});
@override
@@ -177,71 +283,103 @@ 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: const Color(0xFFF5F7FA),
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: Column(
children: [
// Cabeçalho
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 const Center(child: CircularProgressIndicator());
return const SkeletonLoadingStats();
}
if (snapshot.hasError) {
return Center(child: Text("Erro ao carregar: ${snapshot.error}"));
}
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,
onRefresh: () async => setState(() {}),
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16.0),
padding: EdgeInsets.all(16.0 * context.sf),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StatsSummaryCard(total: members.length),
const SizedBox(height: 30),
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),
)),
const SizedBox(height: 30),
...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)
const Padding(
padding: EdgeInsets.only(top: 20),
child: Text("Nenhum jogador nesta equipa.", style: TextStyle(color: Colors.grey, fontSize: 16)),
)
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),
)),
const SizedBox(height: 80),
...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),
],
),
),
@@ -254,63 +392,102 @@ class _TeamStatsPageState extends State<TeamStatsPage> {
floatingActionButton: FloatingActionButton(
heroTag: 'fab_team_${widget.team.id}',
onPressed: () => _controller.showAddPersonDialog(context, widget.team.id),
backgroundColor: const Color(0xFF00C853),
child: const Icon(Icons.add, color: Colors.white),
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(
title: const Text("Eliminar Membro?"),
content: Text("Tens a certeza que queres remover ${person.name}?"),
backgroundColor: Theme.of(context).colorScheme.surface,
title: Text("Eliminar Membro?", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)),
content: Text("Tens a certeza que queres remover ${person.name}?", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("Cancelar")),
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: () {
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("A remover ${person.name}..."), duration: const Duration(seconds: 1)));
_controller.deletePerson(person).catchError((e) {
if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Erro: $e"), backgroundColor: AppTheme.primaryRed));
});
},
child: const Text("Eliminar", style: TextStyle(color: Colors.red)),
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 {
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', personId);
await _supabase.from('members').delete().eq('id', person.id);
if (person.imageUrl != null && person.imageUrl!.isNotEmpty) {
final path = extractPathFromUrl(person.imageUrl!, 'avatars');
if (path != null) await _supabase.storage.from('avatars').remove([path]);
}
} catch (e) {
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}) {
@@ -319,37 +496,101 @@ class StatsController {
final numCtrl = TextEditingController(text: person?.number ?? '');
String selectedType = person?.type ?? 'Jogador';
File? selectedImage;
bool isUploading = false;
bool isPickerActive = false;
String? currentImageUrl = isEdit ? person.imageUrl : null;
String? nameError;
String? numError;
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setState) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
title: Text(isEdit ? "Editar Membro" : "Novo Membro"),
backgroundColor: Theme.of(context).colorScheme.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15 * context.sf)),
title: Text(isEdit ? "Editar Membro" : "Novo Membro", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () async {
if (isPickerActive) return;
setState(() => isPickerActive = true);
try {
final File? croppedFile = await pickAndCropImage(context);
if (croppedFile != null) {
setState(() => selectedImage = croppedFile);
}
} finally {
setState(() => isPickerActive = false);
}
},
child: Stack(
alignment: Alignment.center,
children: [
// 👇 PREVIEW DA FOTO NO POPUP SEM LAG 👇
ClipOval(
child: Container(
width: 80 * context.sf,
height: 80 * context.sf,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.05),
child: selectedImage != null
? Image.file(selectedImage!, fit: BoxFit.cover)
: (currentImageUrl != null && currentImageUrl!.isNotEmpty)
? CachedNetworkImage(
imageUrl: currentImageUrl!,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
placeholder: (context, url) => Icon(Icons.add_a_photo, size: 30 * context.sf, color: Colors.grey),
errorWidget: (context, url, error) => Icon(Icons.add_a_photo, size: 30 * context.sf, color: Colors.grey),
)
: Icon(Icons.add_a_photo, size: 30 * context.sf, color: Colors.grey),
),
),
Positioned(
bottom: 0, right: 0,
child: Container(
padding: EdgeInsets.all(6 * context.sf),
decoration: BoxDecoration(color: AppTheme.primaryRed, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2)),
child: Icon(Icons.edit, color: Colors.white, size: 14 * context.sf),
),
),
],
),
),
SizedBox(height: 20 * context.sf),
TextField(
controller: nameCtrl,
decoration: const InputDecoration(labelText: "Nome Completo"),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration(
labelText: "Nome Completo",
errorText: nameError,
),
textCapitalization: TextCapitalization.words,
),
const SizedBox(height: 15),
SizedBox(height: 15 * context.sf),
DropdownButtonFormField<String>(
value: selectedType,
dropdownColor: Theme.of(context).colorScheme.surface,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 16 * context.sf),
decoration: const InputDecoration(labelText: "Função"),
items: ["Jogador", "Treinador"]
.map((e) => DropdownMenuItem(value: e, child: Text(e)))
.toList(),
onChanged: (v) {
if (v != null) setState(() => selectedType = v);
},
items: ["Jogador", "Treinador"].map((e) => DropdownMenuItem(value: e, child: Text(e))).toList(),
onChanged: (v) { if (v != null) setState(() => selectedType = v); },
),
if (selectedType == "Jogador") ...[
const SizedBox(height: 15),
SizedBox(height: 15 * context.sf),
TextField(
controller: numCtrl,
decoration: const InputDecoration(labelText: "Número da Camisola"),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration(
labelText: "Número da Camisola",
errorText: numError,
),
keyboardType: TextInputType.number,
),
]
@@ -357,28 +598,45 @@ class StatsController {
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text("Cancelar")
),
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("Cancelar", style: TextStyle(color: Colors.grey))),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF00C853),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))
),
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 {
String? numeroFinal = (selectedType == "Treinador")
? null
: (numCtrl.text.trim().isEmpty ? null : numCtrl.text.trim());
setState(() {
nameError = null;
numError = null;
});
if (nameCtrl.text.trim().isEmpty) {
setState(() => nameError = "O nome é obrigatório");
return;
}
setState(() => isUploading = true);
String? numeroFinal = (selectedType == "Treinador") ? null : (numCtrl.text.trim().isEmpty ? null : numCtrl.text.trim());
try {
String? finalImageUrl = currentImageUrl;
if (selectedImage != null) {
final fileName = 'person_${DateTime.now().millisecondsSinceEpoch}.png';
await _supabase.storage.from('avatars').upload(fileName, selectedImage!, fileOptions: const FileOptions(upsert: true));
finalImageUrl = _supabase.storage.from('avatars').getPublicUrl(fileName);
if (currentImageUrl != null && currentImageUrl!.isNotEmpty) {
final oldPath = extractPathFromUrl(currentImageUrl!, 'avatars');
if (oldPath != null) await _supabase.storage.from('avatars').remove([oldPath]);
}
}
if (isEdit) {
await _supabase.from('members').update({
'name': nameCtrl.text.trim(),
'type': selectedType,
'number': numeroFinal,
'image_url': finalImageUrl,
}).eq('id', person.id);
} else {
await _supabase.from('members').insert({
@@ -386,23 +644,24 @@ 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.";
}
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(errorMsg), backgroundColor: Colors.red)
);
setState(() {
isUploading = false;
if (e is PostgrestException && e.code == '23505') {
numError = "Este número já está em uso!";
} else if (e.toString().toLowerCase().contains('unique') || e.toString().toLowerCase().contains('duplicate')) {
numError = "Este número já está em uso!";
} else {
nameError = "Erro ao guardar. Tente novamente.";
}
});
}
},
child: const Text("Guardar", style: TextStyle(color: Colors.white)),
child: isUploading ? SizedBox(width: 16 * context.sf, height: 16 * context.sf, child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : const Text("Guardar"),
)
],
),

View File

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

View File

@@ -1,302 +1,97 @@
import 'package:flutter/material.dart';
import 'package:playmaker/pages/PlacarPage.dart'; // Garante que o import está correto
import 'package:playmaker/pages/PlacarPage.dart';
import 'package:playmaker/classe/theme.dart'; // 👇 IMPORT DO TEMA!
import '../controllers/team_controller.dart';
import '../controllers/game_controller.dart';
// --- CARD DE EXIBIÇÃO DO JOGO ---
class GameResultCard extends StatelessWidget {
final String gameId;
final String myTeam, opponentTeam, myScore, opponentScore, status, season;
final String? myTeamLogo; // NOVA VARIÁVEL
final String? opponentTeamLogo; // NOVA VARIÁVEL
final String gameId, myTeam, opponentTeam, myScore, opponentScore, status, season;
final String? myTeamLogo, opponentTeamLogo;
final double sf;
const GameResultCard({
super.key,
required this.gameId,
required this.myTeam,
required this.opponentTeam,
required this.myScore,
required this.opponentScore,
required this.status,
required this.season,
this.myTeamLogo, // ADICIONADO AO CONSTRUTOR
this.opponentTeamLogo, // ADICIONADO AO CONSTRUTOR
super.key, required this.gameId, required this.myTeam, required this.opponentTeam,
required this.myScore, required this.opponentScore, required this.status, required this.season,
this.myTeamLogo, this.opponentTeamLogo, required this.sf,
});
@override
Widget build(BuildContext context) {
// 👇 Puxa as cores de fundo dependendo do Modo (Claro/Escuro)
final bgColor = Theme.of(context).colorScheme.surface;
final textColor = Theme.of(context).colorScheme.onSurface;
return Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
margin: EdgeInsets.only(bottom: 16 * sf),
padding: EdgeInsets.all(16 * sf),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 10)],
color: bgColor, // Usa a cor do tema
borderRadius: BorderRadius.circular(20 * sf),
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10 * sf)],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Passamos a imagem para a função
Expanded(child: _buildTeamInfo(myTeam, const Color(0xFFE74C3C), myTeamLogo)),
_buildScoreCenter(context, gameId),
// Passamos a imagem para a função
Expanded(child: _buildTeamInfo(opponentTeam, Colors.black87, opponentTeamLogo)),
Expanded(child: _buildTeamInfo(myTeam, AppTheme.primaryRed, myTeamLogo, sf, textColor)), // Usa o primaryRed
_buildScoreCenter(context, gameId, sf),
Expanded(child: _buildTeamInfo(opponentTeam, textColor, opponentTeamLogo, sf, textColor)),
],
),
);
}
// ATUALIZADO para desenhar a imagem
Widget _buildTeamInfo(String name, Color color, String? logoUrl) {
Widget _buildTeamInfo(String name, Color color, String? logoUrl, double sf, Color textColor) {
return Column(
children: [
CircleAvatar(
radius: 24 * sf,
backgroundColor: color,
backgroundImage: (logoUrl != null && logoUrl.isNotEmpty)
? NetworkImage(logoUrl)
: null,
child: (logoUrl == null || logoUrl.isEmpty)
? const Icon(Icons.shield, color: Colors.white)
: null,
backgroundImage: (logoUrl != null && logoUrl.isNotEmpty) ? NetworkImage(logoUrl) : null,
child: (logoUrl == null || logoUrl.isEmpty) ? Icon(Icons.shield, color: Colors.white, size: 24 * sf) : null,
),
const SizedBox(height: 4),
SizedBox(height: 6 * sf),
Text(name,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13 * sf, color: textColor), // Adapta à noite/dia
textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, maxLines: 2,
),
],
);
}
Widget _buildScoreCenter(BuildContext context, String id) {
Widget _buildScoreCenter(BuildContext context, String id, double sf) {
final textColor = Theme.of(context).colorScheme.onSurface;
return Column(
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
_scoreBox(myScore, Colors.green),
const Text(" : ", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
_scoreBox(opponentScore, Colors.grey),
_scoreBox(myScore, AppTheme.successGreen, sf), // Verde do tema
Text(" : ", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 22 * sf, color: textColor)),
_scoreBox(opponentScore, Colors.grey, sf),
],
),
const SizedBox(height: 8),
SizedBox(height: 10 * sf),
TextButton.icon(
onPressed: () {
// NAVEGAÇÃO PARA O PLACAR (Usando o ID real)
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PlacarPage(
gameId: id,
myTeam: myTeam,
opponentTeam: opponentTeam,
),
),
);
},
icon: const Icon(Icons.play_circle_fill, size: 16, color: Color(0xFFE74C3C)),
label: const Text("RETORNAR", style: TextStyle(fontSize: 10, color: Color(0xFFE74C3C), fontWeight: FontWeight.bold)),
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (context) => PlacarPage(gameId: id, myTeam: myTeam, opponentTeam: opponentTeam))),
icon: Icon(Icons.play_circle_fill, size: 18 * sf, color: AppTheme.primaryRed),
label: Text("RETORNAR", style: TextStyle(fontSize: 11 * sf, color: AppTheme.primaryRed, fontWeight: FontWeight.bold)),
style: TextButton.styleFrom(
backgroundColor: const Color(0xFFE74C3C).withOpacity(0.1),
padding: const EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
backgroundColor: AppTheme.primaryRed.withOpacity(0.1),
padding: EdgeInsets.symmetric(horizontal: 14 * sf, vertical: 8 * sf),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20 * sf)),
visualDensity: VisualDensity.compact,
),
),
const SizedBox(height: 4),
Text(status, style: const TextStyle(fontSize: 10, color: Colors.blue, fontWeight: FontWeight.bold)),
SizedBox(height: 6 * sf),
Text(status, style: TextStyle(fontSize: 12 * sf, color: Colors.blue, fontWeight: FontWeight.bold)),
],
);
}
Widget _scoreBox(String pts, Color c) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(8)),
child: Text(pts, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
Widget _scoreBox(String pts, Color c, double sf) => Container(
padding: EdgeInsets.symmetric(horizontal: 12 * sf, vertical: 6 * sf),
decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(8 * sf)),
child: Text(pts, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16 * sf)),
);
}
// --- POPUP DE CRIAÇÃO ---
class CreateGameDialogManual extends StatefulWidget {
final TeamController teamController;
final GameController gameController;
const CreateGameDialogManual({
super.key,
required this.teamController,
required this.gameController
});
@override
State<CreateGameDialogManual> createState() => _CreateGameDialogManualState();
}
class _CreateGameDialogManualState extends State<CreateGameDialogManual> {
late TextEditingController _seasonController;
final TextEditingController _myTeamController = TextEditingController();
final TextEditingController _opponentController = TextEditingController();
bool _isLoading = false;
@override
void initState() {
super.initState();
_seasonController = TextEditingController(text: _calculateSeason());
}
String _calculateSeason() {
final now = DateTime.now();
return now.month >= 7 ? "${now.year}/${(now.year + 1).toString().substring(2)}" : "${now.year - 1}/${now.year.toString().substring(2)}";
}
@override
Widget build(BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Text('Configurar Partida', style: TextStyle(fontWeight: FontWeight.bold)),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _seasonController,
decoration: const InputDecoration(labelText: 'Temporada', border: OutlineInputBorder(), prefixIcon: Icon(Icons.calendar_today)),
),
const SizedBox(height: 15),
_buildSearch(label: "Minha Equipa", controller: _myTeamController),
const Padding(padding: EdgeInsets.symmetric(vertical: 8), child: Text("VS", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey))),
_buildSearch(label: "Adversário", controller: _opponentController),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('CANCELAR')),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFE74C3C),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: _isLoading ? null : () async {
if (_myTeamController.text.isNotEmpty && _opponentController.text.isNotEmpty) {
setState(() => _isLoading = true);
String? newGameId = await widget.gameController.createGame(
_myTeamController.text,
_opponentController.text,
_seasonController.text,
);
setState(() => _isLoading = false);
if (newGameId != null && context.mounted) {
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PlacarPage(
gameId: newGameId,
myTeam: _myTeamController.text,
opponentTeam: _opponentController.text,
),
),
);
}
}
},
child: _isLoading
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Text('CRIAR', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
),
],
);
}
// ATUALIZADO para usar Map e mostrar a imagem na lista de pesquisa
Widget _buildSearch({required String label, required TextEditingController controller}) {
return StreamBuilder<List<Map<String, dynamic>>>(
stream: widget.teamController.teamsStream,
builder: (context, snapshot) {
List<Map<String, dynamic>> teamList = snapshot.hasData ? snapshot.data! : [];
return Autocomplete<Map<String, dynamic>>(
displayStringForOption: (Map<String, dynamic> option) => option['name'].toString(),
optionsBuilder: (TextEditingValue val) {
if (val.text.isEmpty) return const Iterable<Map<String, dynamic>>.empty();
return teamList.where((t) =>
t['name'].toString().toLowerCase().contains(val.text.toLowerCase()));
},
onSelected: (Map<String, dynamic> selection) {
controller.text = selection['name'].toString();
},
optionsViewBuilder: (context, onSelected, options) {
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4.0,
borderRadius: BorderRadius.circular(8),
child: ConstrainedBox(
// Ajuste do tamanho máximo do pop-up de sugestões
constraints: BoxConstraints(maxHeight: 250, maxWidth: MediaQuery.of(context).size.width * 0.7),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (BuildContext context, int index) {
final option = options.elementAt(index);
final String name = option['name'].toString();
final String? imageUrl = option['image_url'];
return ListTile(
leading: CircleAvatar(
backgroundColor: Colors.grey.shade200,
backgroundImage: (imageUrl != null && imageUrl.isNotEmpty)
? NetworkImage(imageUrl)
: null,
child: (imageUrl == null || imageUrl.isEmpty)
? const Icon(Icons.shield, color: Colors.grey)
: null,
),
title: Text(name, style: const TextStyle(fontWeight: FontWeight.bold)),
onTap: () {
onSelected(option);
},
);
},
),
),
),
);
},
fieldViewBuilder: (ctx, txtCtrl, node, submit) {
if (txtCtrl.text.isEmpty && controller.text.isNotEmpty) {
txtCtrl.text = controller.text;
}
txtCtrl.addListener(() {
controller.text = txtCtrl.text;
});
return TextField(
controller: txtCtrl,
focusNode: node,
decoration: InputDecoration(
labelText: label,
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder()
),
);
},
);
},
);
}
}

View File

@@ -25,7 +25,7 @@ class StatCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
return SizedBox(
width: HomeConfig.cardwidthPadding,
height: HomeConfig.cardheightPadding,
child: Card(
@@ -33,7 +33,7 @@ class StatCard extends StatelessWidget {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: isHighlighted
? BorderSide(color: Colors.amber, width: 2)
? const BorderSide(color: Colors.amber, width: 2)
: BorderSide.none,
),
child: InkWell(
@@ -68,7 +68,7 @@ class StatCard extends StatelessWidget {
title.toUpperCase(),
style: HomeConfig.titleStyle,
),
SizedBox(height: 5),
const SizedBox(height: 5),
Text(
playerName,
style: HomeConfig.playerNameStyle,
@@ -80,22 +80,16 @@ class StatCard extends StatelessWidget {
),
if (isHighlighted)
Container(
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
padding: const EdgeInsets.all(8),
decoration: const BoxDecoration(
color: Colors.amber,
shape: BoxShape.circle,
),
child: Icon(
Icons.star,
size: 20,
color: Colors.white,
),
child: const Icon(Icons.star, size: 20, color: Colors.white),
),
],
),
SizedBox(height: 10),
const SizedBox(height: 10),
// Ícone
Container(
width: 60,
@@ -104,51 +98,32 @@ class StatCard extends StatelessWidget {
color: Colors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 30,
color: Colors.white,
child: Icon(icon, size: 30, color: Colors.white),
),
),
Spacer(),
const Spacer(),
// Estatística
Center(
child: Column(
children: [
Text(
statValue,
style: HomeConfig.statValueStyle,
),
SizedBox(height: 5),
Text(
statLabel.toUpperCase(),
style: HomeConfig.statLabelStyle,
),
Text(statValue, style: HomeConfig.statValueStyle),
const SizedBox(height: 5),
Text(statLabel.toUpperCase(), style: HomeConfig.statLabelStyle),
],
),
),
Spacer(),
const Spacer(),
// Botão
Container(
width: double.infinity,
padding: EdgeInsets.symmetric(vertical: 12),
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(15),
),
child: Center(
child: const Center(
child: Text(
'VER DETALHES',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
letterSpacing: 1,
),
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14, letterSpacing: 1),
),
),
),
@@ -174,11 +149,10 @@ class SportGrid extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (children.isEmpty) return SizedBox();
if (children.isEmpty) return const SizedBox();
return Column(
children: [
// Primeira linha
if (children.length >= 2)
Padding(
padding: EdgeInsets.only(bottom: spacing),
@@ -191,8 +165,6 @@ class SportGrid extends StatelessWidget {
],
),
),
// Segunda linha
if (children.length >= 4)
Row(
mainAxisAlignment: MainAxisAlignment.center,

View File

@@ -1,194 +1,173 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
import 'package:playmaker/controllers/login_controller.dart';
import 'package:playmaker/pages/RegisterPage.dart';
import 'package:playmaker/classe/theme.dart'; // 👇 IMPORT DO TEMA
import '../utils/size_extension.dart';
class BasketTrackHeader extends StatelessWidget {
class BasketTrackHeader extends StatelessWidget {
const BasketTrackHeader({super.key});
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
// TAMANHOS AUMENTADOS para tablets
final logoSize = screenWidth > 600 ? 400.0 : 300.0; // ↑ Aumentado
final titleFontSize = screenWidth > 600 ? 48.0 : 36.0; // ↑ Aumentado
final subtitleFontSize = screenWidth > 600 ? 22.0 : 18.0; // ↑ Aumentado
return Column(
children: [
Container(
width: logoSize,
height: logoSize,
// Usamos um Stack para controlar a sobreposição exata
Stack(
alignment: Alignment.center,
children: [
// 1. A Imagem (Aumentada para 320)
SizedBox(
width: 320 * context.sf,
height: 350 * context.sf,
child: Image.asset(
'assets/playmaker-logo.png',
'assets/playmaker-logos.png',
fit: BoxFit.contain,
),
),
SizedBox(height: screenWidth > 600 ? 1.0 : 1.0),
// 2. O Texto "subido" para dentro da área da imagem
Positioned(
bottom: 5 * context.sf, // Ajusta este valor para aproximar/afastar do centro da logo
child: Column(
children: [
Text(
'BasketTrack',
style: TextStyle(
fontSize: titleFontSize,
fontSize: 36 * context.sf,
fontWeight: FontWeight.bold,
color: Colors.grey[900],
color: Theme.of(context).colorScheme.onSurface,
),
),
SizedBox(height: screenWidth > 600 ? 1.0 : 1.0),
SizedBox(height: 4 * context.sf),
Text(
'Gere as tuas equipas e estatísticas',
style: TextStyle(
fontSize: subtitleFontSize,
color: Colors.grey[600],
fontWeight: FontWeight.w500, // ↑ Adicionado peso da fonte
fontSize: 16 * context.sf,
color: Colors.grey,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
],
),
),
],
),
// Espaço extra para não bater nos campos de login logo a seguir
SizedBox(height: 10 * context.sf),
],
);
}
}
}
class LoginFormFields extends StatelessWidget {
class LoginFormFields extends StatelessWidget {
final LoginController controller;
const LoginFormFields({super.key, required this.controller});
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final verticalPadding = screenWidth > 600 ? 22.0 : 16.0;
return Column(
children: [
TextField(
controller: controller.emailController,
style: TextStyle(fontSize: 15 * context.sf, color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration(
labelText: 'E-mail',
prefixIcon: const Icon(Icons.email_outlined),
// O erro agora vem diretamente do controller
labelStyle: TextStyle(fontSize: 15 * context.sf),
prefixIcon: Icon(Icons.email_outlined, size: 22 * context.sf, color: AppTheme.primaryRed), // 👇 Cor do tema
errorText: controller.emailError,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: EdgeInsets.symmetric(vertical: verticalPadding, horizontal: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12 * context.sf)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12 * context.sf),
borderSide: BorderSide(color: AppTheme.primaryRed, width: 2), // 👇 Cor do tema ao focar
),
contentPadding: EdgeInsets.symmetric(vertical: 18 * context.sf, horizontal: 16 * context.sf),
),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 20),
SizedBox(height: 20 * context.sf),
TextField(
controller: controller.passwordController,
obscureText: controller.obscurePassword,
style: TextStyle(fontSize: 15 * context.sf, color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration(
labelText: 'Palavra-passe',
prefixIcon: const Icon(Icons.lock_outlined),
labelStyle: TextStyle(fontSize: 15 * context.sf),
prefixIcon: Icon(Icons.lock_outlined, size: 22 * context.sf, color: AppTheme.primaryRed), // 👇 Cor do tema
errorText: controller.passwordError,
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12 * context.sf),
borderSide: BorderSide(color: AppTheme.primaryRed, width: 2), // 👇 Cor do tema ao focar
),
suffixIcon: IconButton(
icon: Icon(controller.obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined),
icon: Icon(
controller.obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined,
size: 22 * context.sf,
color: Colors.grey,
),
onPressed: controller.togglePasswordVisibility,
),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: EdgeInsets.symmetric(vertical: verticalPadding, horizontal: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12 * context.sf)),
contentPadding: EdgeInsets.symmetric(vertical: 18 * context.sf, horizontal: 16 * context.sf),
),
),
],
);
}
}
class LoginButton extends StatelessWidget {
class LoginButton extends StatelessWidget {
final LoginController controller;
final VoidCallback onLoginSuccess;
const LoginButton({
super.key,
required this.controller,
required this.onLoginSuccess,
});
const LoginButton({super.key, required this.controller, required this.onLoginSuccess});
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
// BOTÕES MAIORES
final buttonHeight = screenWidth > 600 ? 70.0 : 58.0; // ↑ Aumentado
final fontSize = screenWidth > 600 ? 22.0 : 18.0; // ↑ Aumentado
return SizedBox(
width: double.infinity,
height: buttonHeight,
height: 58 * context.sf,
child: ElevatedButton(
onPressed: controller.isLoading ? null : () async {
final success = await controller.login();
if (success) {
onLoginSuccess();
}
if (success) onLoginSuccess();
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFE74C3C),
backgroundColor: AppTheme.primaryRed, // 👇 Usando a cor do tema
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14), // ↑ Bordas mais arredondadas
),
elevation: 3, // ↑ Sombra mais pronunciada
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14 * context.sf)),
elevation: 3,
),
child: controller.isLoading
? SizedBox(
width: 28, // ↑ Aumentado
height: 28, // ↑ Aumentado
child: CircularProgressIndicator(
strokeWidth: 3, // ↑ Aumentado
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
width: 28 * context.sf, height: 28 * context.sf,
child: const CircularProgressIndicator(strokeWidth: 3, valueColor: AlwaysStoppedAnimation<Color>(Colors.white)),
)
: Text(
'Entrar',
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.w700, //
),
),
: Text('Entrar', style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold)),
),
);
}
}
}
class CreateAccountButton extends StatelessWidget {
const CreateAccountButton({super.key});
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final buttonHeight = screenWidth > 600 ? 70.0 : 58.0;
final fontSize = screenWidth > 600 ? 22.0 : 18.0;
return SizedBox(
width: double.infinity,
height: buttonHeight,
height: 58 * context.sf,
child: OutlinedButton(
onPressed: () {
// Navega para a página de registo que criaste
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const RegisterPage()),
);
Navigator.push(context, MaterialPageRoute(builder: (context) => const RegisterPage()));
},
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFE74C3C),
side: const BorderSide(color: Color(0xFFE74C3C), width: 2),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: Text(
'Criar Conta',
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.w700,
),
foregroundColor: AppTheme.primaryRed, // 👇 Usando a cor do tema
side: BorderSide(color: AppTheme.primaryRed, width: 2 * context.sf), // 👇 Usando a cor do tema
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14 * context.sf)),
),
child: Text('Criar Conta', style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold)),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,41 +1,29 @@
import 'package:flutter/material.dart';
import '../controllers/register_controller.dart'; // Garante que o caminho está certo
import 'package:playmaker/classe/theme.dart'; // 👇 IMPORT DO TEMA
import '../controllers/register_controller.dart';
import '../utils/size_extension.dart'; // 👇 O NOSSO SUPERPODER!
class RegisterHeader extends StatelessWidget {
const RegisterHeader({super.key});
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final logoSize = screenWidth > 600 ? 150.0 : 100.0;
final titleFontSize = screenWidth > 600 ? 48.0 : 36.0;
final subtitleFontSize = screenWidth > 600 ? 22.0 : 18.0;
return Column(
children: [
Icon(
Icons.person_add_outlined,
size: logoSize,
color: const Color(0xFFE74C3C)
),
const SizedBox(height: 10),
Icon(Icons.person_add_outlined, size: 100 * context.sf, color: AppTheme.primaryRed), // 👇 Cor do tema
SizedBox(height: 10 * context.sf),
Text(
'Nova Conta',
style: TextStyle(
fontSize: titleFontSize,
fontSize: 36 * context.sf,
fontWeight: FontWeight.bold,
color: Colors.grey[900],
color: Theme.of(context).colorScheme.onSurface, // 👇 Adaptável ao Modo Escuro
),
),
const SizedBox(height: 5),
SizedBox(height: 5 * context.sf),
Text(
'Cria o teu perfil no BasketTrack',
style: TextStyle(
fontSize: subtitleFontSize,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
style: TextStyle(fontSize: 16 * context.sf, color: Colors.grey, fontWeight: FontWeight.w500),
textAlign: TextAlign.center,
),
],
@@ -45,7 +33,6 @@ class RegisterHeader extends StatelessWidget {
class RegisterFormFields extends StatefulWidget {
final RegisterController controller;
const RegisterFormFields({super.key, required this.controller});
@override
@@ -57,69 +44,84 @@ class _RegisterFormFieldsState extends State<RegisterFormFields> {
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final verticalPadding = screenWidth > 600 ? 22.0 : 16.0;
// IMPORTANTE: Envolvemos tudo num Form usando a chave do controller
return Form(
key: widget.controller.formKey,
child: Column(
children: [
// Campo Nome (Opcional, mas útil)
TextFormField(
controller: widget.controller.nameController,
style: TextStyle(fontSize: 15 * context.sf, color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration(
labelText: 'Nome Completo',
prefixIcon: const Icon(Icons.person_outline),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: EdgeInsets.symmetric(vertical: verticalPadding, horizontal: 16),
labelStyle: TextStyle(fontSize: 15 * context.sf),
prefixIcon: Icon(Icons.person_outline, size: 22 * context.sf, color: AppTheme.primaryRed), // 👇 Cor do tema
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12 * context.sf)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12 * context.sf),
borderSide: BorderSide(color: AppTheme.primaryRed, width: 2), // 👇 Destaque ao focar
),
contentPadding: EdgeInsets.symmetric(vertical: 18 * context.sf, horizontal: 16 * context.sf),
),
),
const SizedBox(height: 20),
SizedBox(height: 20 * context.sf),
// Campo Email
TextFormField(
controller: widget.controller.emailController,
// Validação automática ligada ao controller
validator: widget.controller.validateEmail,
style: TextStyle(fontSize: 15 * context.sf, color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration(
labelText: 'E-mail',
prefixIcon: const Icon(Icons.email_outlined),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: EdgeInsets.symmetric(vertical: verticalPadding, horizontal: 16),
labelStyle: TextStyle(fontSize: 15 * context.sf),
prefixIcon: Icon(Icons.email_outlined, size: 22 * context.sf, color: AppTheme.primaryRed),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12 * context.sf)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12 * context.sf),
borderSide: BorderSide(color: AppTheme.primaryRed, width: 2),
),
contentPadding: EdgeInsets.symmetric(vertical: 18 * context.sf, horizontal: 16 * context.sf),
),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 20),
SizedBox(height: 20 * context.sf),
// Campo Password
TextFormField(
controller: widget.controller.passwordController,
obscureText: _obscurePassword,
validator: widget.controller.validatePassword,
style: TextStyle(fontSize: 15 * context.sf, color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration(
labelText: 'Palavra-passe',
prefixIcon: const Icon(Icons.lock_outlined),
labelStyle: TextStyle(fontSize: 15 * context.sf),
prefixIcon: Icon(Icons.lock_outlined, size: 22 * context.sf, color: AppTheme.primaryRed),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12 * context.sf),
borderSide: BorderSide(color: AppTheme.primaryRed, width: 2),
),
suffixIcon: IconButton(
icon: Icon(_obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined),
icon: Icon(_obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 22 * context.sf, color: Colors.grey),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: EdgeInsets.symmetric(vertical: verticalPadding, horizontal: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12 * context.sf)),
contentPadding: EdgeInsets.symmetric(vertical: 18 * context.sf, horizontal: 16 * context.sf),
),
),
const SizedBox(height: 20),
SizedBox(height: 20 * context.sf),
// Campo Confirmar Password
TextFormField(
controller: widget.controller.confirmPasswordController,
obscureText: _obscurePassword,
validator: widget.controller.validateConfirmPassword,
style: TextStyle(fontSize: 15 * context.sf, color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration(
labelText: 'Confirmar Palavra-passe',
prefixIcon: const Icon(Icons.lock_clock_outlined),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: EdgeInsets.symmetric(vertical: verticalPadding, horizontal: 16),
labelStyle: TextStyle(fontSize: 15 * context.sf),
prefixIcon: Icon(Icons.lock_clock_outlined, size: 22 * context.sf, color: AppTheme.primaryRed),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12 * context.sf),
borderSide: BorderSide(color: AppTheme.primaryRed, width: 2),
),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12 * context.sf)),
contentPadding: EdgeInsets.symmetric(vertical: 18 * context.sf, horizontal: 16 * context.sf),
),
),
],
@@ -130,49 +132,27 @@ class _RegisterFormFieldsState extends State<RegisterFormFields> {
class RegisterButton extends StatelessWidget {
final RegisterController controller;
const RegisterButton({
super.key,
required this.controller,
});
const RegisterButton({super.key, required this.controller});
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final buttonHeight = screenWidth > 600 ? 70.0 : 58.0;
final fontSize = screenWidth > 600 ? 22.0 : 18.0;
return SizedBox(
width: double.infinity,
height: buttonHeight,
height: 58 * context.sf,
child: ElevatedButton(
// Passamos o context para o controller lidar com as SnackBars e Navegação
onPressed: controller.isLoading ? null : () => controller.signUp(context),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFE74C3C),
backgroundColor: AppTheme.primaryRed, // 👇 Cor do tema
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14 * context.sf)),
elevation: 3,
),
child: controller.isLoading
? const SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
? SizedBox(
width: 28 * context.sf, height: 28 * context.sf,
child: const CircularProgressIndicator(strokeWidth: 3, valueColor: AlwaysStoppedAnimation<Color>(Colors.white)),
)
: Text(
'Criar Conta',
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.w700,
),
),
: Text('Criar Conta', style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold)),
),
);
}

View File

@@ -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,

View File

@@ -1,226 +1,232 @@
import 'package:flutter/material.dart';
import 'package:playmaker/screens/team_stats_page.dart';
import 'package:playmaker/classe/theme.dart'; // 👇 IMPORT DO TEMA
import '../models/team_model.dart';
import '../controllers/team_controller.dart';
import '../models/person_model.dart';
import '../utils/size_extension.dart'; // 👇 O NOSSO SUPERPODER!
class TeamCard extends StatelessWidget {
// --- CABEÇALHO ---
class StatsHeader extends StatelessWidget {
final Team team;
final TeamController controller;
final VoidCallback onFavoriteTap;
const TeamCard({
super.key,
required this.team,
required this.controller,
required this.onFavoriteTap,
});
const StatsHeader({super.key, required this.team});
@override
Widget build(BuildContext context) {
return Card(
color: Colors.white,
elevation: 3,
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
// --- 1. IMAGEM + FAVORITO ---
leading: Stack(
clipBehavior: Clip.none,
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 do teu tema!
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(30 * context.sf),
bottomRight: Radius.circular(30 * context.sf)
),
),
child: Row(
children: [
IconButton(
icon: Icon(Icons.arrow_back, color: Colors.white, size: 24 * context.sf),
onPressed: () => Navigator.pop(context),
),
SizedBox(width: 10 * context.sf),
CircleAvatar(
radius: 28,
backgroundColor: Colors.grey[200],
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: const TextStyle(fontSize: 24),
team.imageUrl.isEmpty ? "🛡️" : team.imageUrl,
style: TextStyle(fontSize: 20 * context.sf),
)
: null,
),
Positioned(
left: -15,
top: -10,
child: IconButton(
icon: Icon(
team.isFavorite ? Icons.star : Icons.star_border,
color: team.isFavorite ? Colors.amber : Colors.black.withOpacity(0.1),
size: 28,
shadows: [
Shadow(
color: Colors.black.withOpacity(team.isFavorite ? 0.3 : 0.1),
blurRadius: 4,
),
],
),
onPressed: onFavoriteTap,
),
),
],
),
// --- 2. TÍTULO ---
title: Text(
team.name,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
// --- 3. SUBTÍTULO (Contagem + Época) ---
subtitle: Padding(
padding: const EdgeInsets.only(top: 6.0),
child: Row(
SizedBox(width: 15 * context.sf),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.groups_outlined, size: 16, color: Colors.grey),
const SizedBox(width: 4),
FutureBuilder<int>(
future: controller.getPlayerCount(team.id),
initialData: 0,
builder: (context, snapshot) {
final count = snapshot.data ?? 0;
return Text(
"$count Jogadores",
style: TextStyle(
color: count > 0 ? Colors.green[700] : Colors.orange,
fontWeight: FontWeight.bold,
fontSize: 13,
),
);
},
),
const SizedBox(width: 10),
Text(
"| ${team.season}",
style: const TextStyle(color: Colors.grey, fontSize: 13),
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)
),
],
),
),
// --- 4. BOTÕES (Estatísticas e Apagar) ---
trailing: SizedBox(
width: 96, // Aumentei um pouco para caberem bem os dois botões
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
IconButton(
tooltip: 'Ver Estatísticas',
icon: const Icon(Icons.bar_chart_rounded, color: Colors.blue),
onPressed: () {
// CORRIGIDO: Agora chama a classe TeamStatsPage corretamente
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => TeamStatsPage(team: team),
),
);
},
),
IconButton(
tooltip: 'Eliminar Equipa',
icon: const Icon(Icons.delete_outline, color: Color(0xFFE74C3C)),
onPressed: () => _confirmDelete(context),
),
],
),
),
),
);
}
// Função de confirmação de exclusão
void _confirmDelete(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Eliminar Equipa?'),
content: Text('Tens a certeza que queres eliminar "${team.name}"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancelar'),
),
TextButton(
onPressed: () {
controller.deleteTeam(team.id);
Navigator.pop(context);
},
child: const Text('Eliminar', style: TextStyle(color: Colors.red)),
),
],
),
);
}
}
// --- DIALOG DE CRIAÇÃO ---
class CreateTeamDialog extends StatefulWidget {
final Function(String name, String season, String imageUrl) onConfirm;
// --- CARD DE RESUMO ---
class StatsSummaryCard extends StatelessWidget {
final int total;
const CreateTeamDialog({super.key, required this.onConfirm});
@override
State<CreateTeamDialog> createState() => _CreateTeamDialogState();
}
class _CreateTeamDialogState extends State<CreateTeamDialog> {
final TextEditingController _nameController = TextEditingController();
final TextEditingController _imageController = TextEditingController();
String _selectedSeason = '2024/25';
const StatsSummaryCard({super.key, required this.total});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Nova Equipa'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
// 👇 Adaptável ao Modo Escuro
final cardColor = 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: cardColor,
borderRadius: BorderRadius.circular(20 * context.sf),
border: Border.all(color: Colors.grey.withOpacity(0.15)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Nome da Equipa'),
textCapitalization: TextCapitalization.words,
Row(
children: [
Icon(Icons.groups, color: AppTheme.primaryRed, size: 28 * context.sf), // 👇 Cor do tema
SizedBox(width: 10 * context.sf),
Text(
"Total de Membros",
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface, // 👇 Adaptável
fontSize: 16 * context.sf,
fontWeight: FontWeight.w600
)
),
const SizedBox(height: 15),
DropdownButtonFormField<String>(
value: _selectedSeason,
decoration: const InputDecoration(labelText: 'Temporada'),
items: ['2023/24', '2024/25', '2025/26']
.map((s) => DropdownMenuItem(value: s, child: Text(s)))
.toList(),
onChanged: (val) => setState(() => _selectedSeason = val!),
),
const SizedBox(height: 15),
TextField(
controller: _imageController,
decoration: const InputDecoration(
labelText: 'URL Imagem ou Emoji',
hintText: 'Ex: 🏀 ou https://...',
],
),
Text(
"$total",
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface, // 👇 Adaptável
fontSize: 28 * context.sf,
fontWeight: FontWeight.bold
)
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancelar')),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFFE74C3C)),
onPressed: () {
if (_nameController.text.trim().isNotEmpty) {
widget.onConfirm(
_nameController.text.trim(),
_selectedSeason,
_imageController.text.trim(),
);
Navigator.pop(context);
}
},
child: const Text('Criar', style: TextStyle(color: Colors.white)),
}
// --- TÍTULO DE SECÇÃO ---
class StatsSectionTitle extends StatelessWidget {
final String title;
const StatsSectionTitle({super.key, required this.title});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 18 * context.sf,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface // 👇 Adaptável
)
),
Divider(color: Colors.grey.withOpacity(0.3)),
],
);
}
}
// --- CARD DA PESSOA (JOGADOR/TREINADOR) ---
class PersonCard extends StatelessWidget {
final Person person;
final bool isCoach;
final VoidCallback onEdit;
final VoidCallback onDelete;
const PersonCard({
super.key,
required this.person,
required this.isCoach,
required this.onEdit,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
// 👇 Cores adaptáveis para o Card
final defaultBg = Theme.of(context).brightness == Brightness.dark
? const Color(0xFF1E1E1E)
: Colors.white;
final coachBg = Theme.of(context).brightness == Brightness.dark
? AppTheme.warningAmber.withOpacity(0.1) // Amarelo escuro se for modo noturno
: const Color(0xFFFFF9C4); // Amarelo claro original
return Card(
margin: EdgeInsets.only(top: 12 * context.sf),
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, // 👇 Cor do tema
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, // 👇 Adaptável
)
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(Icons.edit_outlined, color: Colors.blue, size: 22 * context.sf),
onPressed: onEdit,
),
IconButton(
icon: Icon(Icons.delete_outline, color: AppTheme.primaryRed, size: 22 * context.sf), // 👇 Cor do tema
onPressed: onDelete,
),
],
),
),
);
}
}

228
lib/zone_map_dialog.dart Normal file
View File

@@ -0,0 +1,228 @@
import 'package:flutter/material.dart';
import 'dart:math' as math;
class ZoneMapDialog extends StatelessWidget {
final String playerName;
final bool isMake;
final bool is3PointAction; // 👇 AGORA O POP-UP SABE O QUE ARRASTASTE!
final Function(String zone, int points, double relativeX, double relativeY) onZoneSelected;
const ZoneMapDialog({
super.key,
required this.playerName,
required this.isMake,
required this.is3PointAction,
required this.onZoneSelected,
});
@override
Widget build(BuildContext context) {
final Color headerColor = const Color(0xFFE88F15);
final Color yellowBackground = const Color(0xFFDFAB00);
final double screenHeight = MediaQuery.of(context).size.height;
final double dialogHeight = screenHeight * 0.95;
final double dialogWidth = dialogHeight * 1.0;
return Dialog(
backgroundColor: yellowBackground,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
clipBehavior: Clip.antiAlias,
insetPadding: const EdgeInsets.all(10),
child: SizedBox(
height: dialogHeight,
width: dialogWidth,
child: Column(
children: [
Container(
height: 40,
color: headerColor,
width: double.infinity,
child: Stack(
alignment: Alignment.center,
children: [
Text(
isMake ? "Lançamento de $playerName (Marcou)" : "Lançamento de $playerName (Falhou)",
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold),
),
Positioned(
right: 8,
child: InkWell(
onTap: () => Navigator.pop(context),
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle),
child: Icon(Icons.close, color: headerColor, size: 16),
),
),
)
],
),
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return GestureDetector(
onTapUp: (details) => _calculateAndReturnZone(context, details.localPosition, constraints.biggest),
child: CustomPaint(
size: Size(constraints.maxWidth, constraints.maxHeight),
painter: DebugPainter(is3PointAction: is3PointAction), // 👇 Passa a info para o desenhador
),
);
},
),
),
],
),
),
);
}
void _calculateAndReturnZone(BuildContext context, Offset tap, Size size) {
final double w = size.width;
final double h = size.height;
final double x = tap.dx;
final double y = tap.dy;
final double basketX = w / 2;
final double margin = w * 0.10;
final double length = h * 0.35;
final double larguraDoArco = (w / 2) - margin;
final double alturaDoArco = larguraDoArco * 0.30;
final double totalArcoHeight = alturaDoArco * 4;
String zone = "";
int pts = 2;
// 1. SABER SE CLICOU NA ZONA DE 3 OU DE 2
bool is3 = false;
if (y < length) {
if (x < margin || x > w - margin) is3 = true;
} else {
double dx = x - basketX;
double dy = y - length;
double ellipse = (dx * dx) / (larguraDoArco * larguraDoArco) + (dy * dy) / (math.pow(totalArcoHeight / 2, 2));
if (ellipse > 1.0) is3 = true;
}
// 👇 MAGIA AQUI: BLOQUEIA O CLIQUE NA ZONA ESCURA! 👇
if (is3PointAction && !is3) return; // Arrastou 3pts mas clicou na de 2pts -> IGNORA
if (!is3PointAction && is3) return; // Arrastou 2pts mas clicou na de 3pts -> IGNORA
double angle = math.atan2(y - length, x - basketX);
if (is3) {
pts = 3;
if (y < length) {
zone = (x < w / 2) ? "Canto Esquerdo (3pt)" : "Canto Direito (3pt)";
} else if (angle > 2.35) {
zone = "Ala Esquerda (3pt)";
} else if (angle < 0.78) {
zone = "Ala Direita (3pt)";
} else {
zone = "Topo (3pt)";
}
} else {
pts = 2;
final double pW = w * 0.28;
final double pH = h * 0.38;
if (x > basketX - pW / 2 && x < basketX + pW / 2 && y < pH) {
zone = "Garrafão";
} else {
if (y < length) {
zone = (x < w / 2) ? "Meia Distância (Canto Esq)" : "Meia Distância (Canto Dir)";
} else if (angle > 2.35) {
zone = "Meia Distância (Esq)";
} else if (angle < 0.78) {
zone = "Meia Distância (Dir)";
} else {
zone = "Meia Distância (Centro)";
}
}
}
// 👇 A MUDANÇA ESTÁ AQUI! Passamos os dados e deixamos quem chamou decidir como fechar!
onZoneSelected(zone, pts, x / w, y / h);
}
}
class DebugPainter extends CustomPainter {
final bool is3PointAction;
DebugPainter({required this.is3PointAction});
@override
void paint(Canvas canvas, Size size) {
final double w = size.width;
final double h = size.height;
final double basketX = w / 2;
final Paint whiteStroke = Paint()..color = Colors.white..style = PaintingStyle.stroke..strokeWidth = 2.0;
final Paint blackStroke = Paint()..color = Colors.black87..style = PaintingStyle.stroke..strokeWidth = 2.0;
final double margin = w * 0.10;
final double length = h * 0.35;
final double larguraDoArco = (w / 2) - margin;
final double alturaDoArco = larguraDoArco * 0.30;
final double totalArcoHeight = alturaDoArco * 4;
// DESENHA O CAMPO
canvas.drawLine(Offset(margin, 0), Offset(margin, length), whiteStroke);
canvas.drawLine(Offset(w - margin, 0), Offset(w - margin, length), whiteStroke);
canvas.drawLine(Offset(0, length), Offset(margin, length), whiteStroke);
canvas.drawLine(Offset(w - margin, length), Offset(w, length), whiteStroke);
canvas.drawArc(Rect.fromCenter(center: Offset(basketX, length), width: larguraDoArco * 2, height: totalArcoHeight), 0, math.pi, false, whiteStroke);
double sXL = basketX + (larguraDoArco * math.cos(math.pi * 0.75));
double sYL = length + ((totalArcoHeight / 2) * math.sin(math.pi * 0.75));
double sXR = basketX + (larguraDoArco * math.cos(math.pi * 0.25));
double sYR = length + ((totalArcoHeight / 2) * math.sin(math.pi * 0.25));
canvas.drawLine(Offset(sXL, sYL), Offset(0, h * 0.85), whiteStroke);
canvas.drawLine(Offset(sXR, sYR), Offset(w, h * 0.85), whiteStroke);
final double pW = w * 0.28;
final double pH = h * 0.38;
canvas.drawRect(Rect.fromLTWH(basketX - pW / 2, 0, pW, pH), blackStroke);
final double ftR = pW / 2;
canvas.drawArc(Rect.fromCircle(center: Offset(basketX, pH), radius: ftR), 0, math.pi, false, blackStroke);
for (int i = 0; i < 10; i++) {
canvas.drawArc(Rect.fromCircle(center: Offset(basketX, pH), radius: ftR), math.pi + (i * 2 * (math.pi / 20)), math.pi / 20, false, blackStroke);
}
canvas.drawLine(Offset(basketX - pW / 2, pH), Offset(sXL, sYL), blackStroke);
canvas.drawLine(Offset(basketX + pW / 2, pH), Offset(sXR, sYR), blackStroke);
canvas.drawArc(Rect.fromCircle(center: Offset(basketX, h), radius: w * 0.12), math.pi, math.pi, false, blackStroke);
canvas.drawCircle(Offset(basketX, h * 0.12), w * 0.02, blackStroke);
canvas.drawLine(Offset(basketX - w * 0.08, h * 0.12 - 5), Offset(basketX + w * 0.08, h * 0.12 - 5), blackStroke);
// ==========================================
// 👇 EFEITO DE ESCURECIMENTO (SHADOW) 👇
// ==========================================
final Paint shadowPaint = Paint()..color = Colors.black.withOpacity(0.75); // 75% escuro!
// Cria o molde da área de 2 pontos
Path path2pt = Path();
path2pt.moveTo(margin, 0);
path2pt.lineTo(margin, length);
// Faz o arco curvo da linha de 3 pontos
path2pt.arcTo(Rect.fromCenter(center: Offset(basketX, length), width: larguraDoArco * 2, height: totalArcoHeight), math.pi, -math.pi, false);
path2pt.lineTo(w - margin, 0);
path2pt.close();
if (is3PointAction) {
// Arrastou 3 Pontos -> Escurece a Zona de 2!
canvas.drawPath(path2pt, shadowPaint);
} else {
// Arrastou 2 Pontos -> Escurece a Zona de 3!
Path fullScreen = Path()..addRect(Rect.fromLTWH(0, 0, w, h));
Path path3pt = Path.combine(PathOperation.difference, fullScreen, path2pt);
canvas.drawPath(path3pt, shadowPaint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

View File

@@ -6,13 +6,21 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <gtk/gtk_plugin.h>
#include <printing/printing_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);
g_autoptr(FlPluginRegistrar) printing_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin");
printing_plugin_register_with_registrar(printing_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View File

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

View File

@@ -6,13 +6,19 @@ import FlutterMacOS
import Foundation
import app_links
import file_selector_macos
import path_provider_foundation
import printing
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"))
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}

View File

@@ -41,6 +41,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.4"
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
async:
dependency: transitive
description:
@@ -49,6 +57,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.0"
barcode:
dependency: transitive
description:
name: barcode
sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4"
url: "https://pub.dev"
source: hosted
version: "2.2.9"
bidi:
dependency: transitive
description:
name: bidi
sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d"
url: "https://pub.dev"
source: hosted
version: "2.0.13"
boolean_selector:
dependency: transitive
description:
@@ -57,6 +81,30 @@ 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:
@@ -89,6 +137,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 +201,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 +246,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 +262,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 +320,102 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
image_cropper:
dependency: "direct main"
description:
name: image_cropper
sha256: "46c8f9aae51c8350b2a2982462f85a129e77b04675d35b09db5499437d7a996b"
url: "https://pub.dev"
source: hosted
version: "11.0.0"
image_cropper_for_web:
dependency: transitive
description:
name: image_cropper_for_web
sha256: e09749714bc24c4e3b31fbafa2e5b7229b0ff23e8b14d4ba44bd723b77611a0f
url: "https://pub.dev"
source: hosted
version: "7.0.0"
image_cropper_platform_interface:
dependency: transitive
description:
name: image_cropper_platform_interface
sha256: "886a30ec199362cdcc2fbb053b8e53347fbfb9dbbdaa94f9ff85622609f5e7ff"
url: "https://pub.dev"
source: hosted
version: "8.0.0"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156
url: "https://pub.dev"
source: hosted
version: "0.8.13+14"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
url: "https://pub.dev"
source: hosted
version: "0.8.13+6"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
url: "https://pub.dev"
source: hosted
version: "0.2.2+1"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev"
source: hosted
version: "0.2.2"
jwt_decode:
dependency: transitive
description:
@@ -304,6 +504,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:
@@ -312,6 +520,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider:
dependency: transitive
description:
@@ -360,6 +576,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
pdf:
dependency: "direct main"
description:
name: pdf
sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b
url: "https://pub.dev"
source: hosted
version: "3.12.0"
pdf_widget_wrapper:
dependency: transitive
description:
name: pdf_widget_wrapper
sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5
url: "https://pub.dev"
source: hosted
version: "1.0.4"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
@@ -384,6 +624,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.0"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
postgrest:
dependency: transitive
description:
@@ -392,6 +640,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.6.0"
printing:
dependency: "direct main"
description:
name: printing
sha256: "689170c9ddb1bda85826466ba80378aa8993486d3c959a71cd7d2d80cb606692"
url: "https://pub.dev"
source: hosted
version: "5.14.3"
provider:
dependency: "direct main"
description:
@@ -400,6 +656,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
realtime_client:
dependency: transitive
description:
@@ -425,7 +689,7 @@ packages:
source: hosted
version: "0.28.0"
shared_preferences:
dependency: transitive
dependency: "direct main"
description:
name: shared_preferences
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
@@ -480,6 +744,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 +765,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 +853,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:
@@ -629,6 +949,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:
@@ -677,6 +1005,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yet_another_json_isolate:
dependency: transitive
description:

View File

@@ -36,6 +36,13 @@ 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
printing: ^5.14.3
pdf: ^3.12.0
dev_dependencies:
flutter_test:
@@ -58,6 +65,7 @@ flutter:
assets:
- assets/playmaker-logo.png
- assets/campo.png
- assets/playmaker-logos.png
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images

View File

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

View File

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