Compare commits

...

6 Commits

Author SHA1 Message Date
144a6c71a5 pronta para a pap? 2026-06-15 15:47:24 +01:00
29e887cb14 espero que nao presisse mudar. 2026-06-11 09:52:06 +01:00
947e119dba tentar aresolver a home 2026-06-08 14:54:04 +01:00
7d2f3c4679 bora para o relatorio 2026-05-21 10:51:35 +01:00
332361c296 nao sei 2026-05-15 12:43:30 +01:00
1e38c4ad57 JOGO 2026-05-11 17:22:04 +01:00
20 changed files with 3785 additions and 982 deletions

51
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,51 @@
## PlayMaker — guidance for AI coding assistants
This file gives focused, actionable knowledge to quickly make safe edits in the PlayMaker Flutter app.
Key facts (big picture)
- App: Flutter (mobile + desktop) using Supabase for backend and SharedPreferences for local persistence.
- Data ownership: teams are per-user (teams.user_id). Team records include fields like `id`, `name`, `image_url`, `wins`, `losses`, `draws`, `is_favorite`.
- Global selection: the app exposes a single global active team via `lib/controllers/active_team.dart` — a `ValueNotifier<ActiveTeam?>` named `globalActiveTeam`. Use `loadGlobalTeam()` and `saveGlobalTeam()` to read/update both local (SharedPreferences) and Supabase (`profiles.selected_team_id`).
Where to look (important files)
- `lib/controllers/active_team.dart` — central logic: load/save active team, prefers favorite team after our recent change.
- `lib/controllers/team_controller.dart` — creates teams, toggles favorites and exposes `teamsStream` for realtime listing.
- `lib/pages/home.dart` — main UI that shows the selected team, reads/writes last_team_* prefs and uses `TeamController` streams.
- `lib/pages/status_page.dart` — shows detailed stats and accepts initialTeam* props; will use the same persistence keys as `home.dart` as a fallback.
- `lib/widgets/*.dart` — UI building blocks (game, placar, team widgets) that assume team names/logos are strings and use `globalActiveTeam`/selectedTeam state.
Project-specific conventions & patterns
- Single active team: stored locally under prefs keys `last_team_id`, `last_team_name`, `last_team_logo`, `last_team_wins`, `last_team_losses`, `last_team_draws`.
- Realtime lists: controllers expose Streams from Supabase (e.g., `teamsStream`) and UI uses `StreamBuilder`/`snapshot.data` expecting List<Map<String,dynamic>>.
- Favoriting: `teams.is_favorite` is a boolean; code expects at most one favorite per user. When marking favorite, clear other favorites for that user and set the favorite as the global active team (we updated `team_controller.toggleFavorite` to do this).
- Global state: prefer using `globalActiveTeam` instead of ad-hoc SharedPreferences reads when changing the current team — it notifies Home and Status pages automatically.
- Naming: Portuguese UI strings are used throughout (e.g., "Selecionar Equipa"), so preserve that when editing visible text.
Examples of common edits
- Changing how the active team is chosen: edit `loadGlobalTeam()` in `lib/controllers/active_team.dart`. Example behaviour implemented: prefer `teams.is_favorite == true`, then fallback to `profiles.selected_team_id`, then local prefs.
- Adding a field to teams: update Supabase schema, then adapt `TeamController.getTeamsWithStats()` and UI widgets under `lib/widgets/` to read the new field.
- Making UI reactive to team changes: call `saveGlobalTeam(ActiveTeam(...))` to update memory, Supabase profile and notify UI.
Developer workflows (how to build/test/debug)
- Install dependencies: `flutter pub get`.
- Run on iOS simulator: `flutter run -d ios` (macOS only). Android: `flutter run -d android`.
- Quick analyzer: `flutter analyze` or rely on the project's editor diagnostics.
- Running tests: There is a `test/widget_test.dart` — run `flutter test` to execute.
- When debugging Supabase flows locally, ensure `google-services.json` (Android) and iOS config files are present under `android/app/` and `ios/` as appropriate.
Safe-edit checklist for AI agents
- Prefer small, focused patches. Keep existing structure and naming conventions (Portuguese strings, `prefs` keys and Supabase table names).
- When touching team selection: update both local prefs and Supabase `profiles.selected_team_id` (use `saveGlobalTeam`).
- When changing persistence keys, update both `home.dart` and `status_page.dart` and `active_team.dart`.
- Avoid altering app-wide themes or widget contracts unless necessary; many widgets rely on team names/logos being non-null strings.
- If adding Supabase queries, filter by `user_id` unless deliberately global.
If you can't find something
- Search for the pref keys `last_team_id` / `last_team_name` / `last_team_logo` to locate all usages.
- Look for `globalActiveTeam` to see places that react to the active team.
Contact / next steps
- After applying changes that affect team selection flow, run `flutter analyze` and (if possible) `flutter test`.
- Ask for confirmation before changing user-visible Portuguese copy or database schema.
— end of guidance —

157
SYNC_CHANGES_SUMMARY.md Normal file
View File

@@ -0,0 +1,157 @@
# Resumo das Mudanças - Sincronização de Jogo em Tempo Real
## 1. lib/controllers/placar_controller.dart
### Adicionado ao constructor:
```dart
final void Function(String actionType, Map<String, dynamic> actionData)? onSyncAction;
PlacarController({
required this.gameId,
required this.myTeam,
required this.opponentTeam,
this.onSyncAction, // ← NOVO
});
```
### Adicionado método _dispatchSyncAction:
```dart
void _dispatchSyncAction(String actionType, Map<String, dynamic> actionData) {
if (onSyncAction != null) {
final enrichedActionData = Map<String, dynamic>.from(actionData)
..['remaining_seconds'] = durationNotifier.value.inSeconds
..['is_running'] = isRunning;
onSyncAction!(actionType, enrichedActionData);
}
}
```
### Adicionado em 5 métodos (chamada _dispatchSyncAction):
- `useTimeout()` → dispatch `'use_timeout'`
- `handleSubbing()` → dispatch `'subbing'`
- `swapCourtPlayers()` → dispatch `'swap_players'`
- `registerFoul()` → dispatch `'register_foul'`
- `commitStat()` → dispatch `'commit_stat'`
**Exemplo em commitStat:**
```dart
_dispatchSyncAction('commit_stat', {
'action': action,
'player_data': playerData,
});
```
---
## 2. lib/pages/PlacarPage.dart
### Adicionado ao state:
```dart
String? _lastAppliedSyncEventId; // ← NOVO - deduplicação de eventos
```
### Constructor do controller:
```dart
_controller = PlacarController(
gameId: widget.gameId,
myTeam: widget.myTeam,
opponentTeam: widget.opponentTeam,
onSyncAction: _onLocalControllerSync, // ← CONECTADO
);
```
### Adicionado novo método _onLocalControllerSync:
```dart
void _onLocalControllerSync(String actionType, Map<String, dynamic> actionData) {
if (_sessionId == null || _isApplyingRemoteSync) return;
print("📤 Enviando sync action local: $actionType -> $actionData");
_sharingController.sendSyncEvent(_sessionId!, actionType, actionData);
}
```
### Atualizado _setupSyncListener (deduplicação):
```dart
_syncSubscription = _sharingController.listenToGameSyncOthers(_sessionId!).listen(
(dynamic event) {
Map<String, dynamic>? record;
if (event is List && event.isNotEmpty) {
for (final item in event) {
final row = item as Map<String, dynamic>?;
if (row == null) continue;
final rowId = row['id']?.toString();
if (rowId != null && rowId != _lastAppliedSyncEventId) {
record = row;
break; // ← para no primeiro evento novo
}
}
} else if (event is Map<String, dynamic>) {
record = Map<String, dynamic>.from(event);
}
if (record != null) {
final recordId = record['id']?.toString();
if (recordId != null && recordId == _lastAppliedSyncEventId) return;
if (recordId != null) _lastAppliedSyncEventId = recordId;
_applyRemoteSyncEvent(record);
}
},
);
```
### Atualizado _applyRemoteSyncEvent (aplicar estado remoto):
```dart
void _applyRemoteSyncEvent(Map<String, dynamic> record) {
final actionType = record['action_type']?.toString();
final actionData = Map<String, dynamic>.from(record['action_data'] ?? {});
// ← NOVO: aplicar timer remotamente em TODAS as ações
final remoteSeconds = int.tryParse(actionData['remaining_seconds']?.toString() ?? '');
final remoteIsRunning = actionData['is_running'] == true;
if (remoteSeconds != null) {
_controller.durationNotifier.value = Duration(seconds: remoteSeconds);
}
if (remoteIsRunning != _controller.isRunning) {
_isApplyingRemoteSync = true;
_controller.toggleTimer(context);
_isApplyingRemoteSync = false;
}
// Aplicar ações específicas
if (actionType == 'toggle_timer') {
setState(() {});
} else if (actionType == 'commit_stat') {
// aplicar pontos/faltas
} else if (actionType == 'register_foul') {
// aplicar falta
} else if (actionType == 'subbing') {
// aplicar substituição
} else if (actionType == 'swap_players') {
// trocar posição
} else if (actionType == 'use_timeout') {
// usar timeout
}
}
```
---
## Fluxo Completo
1. **Ação Local**`commitStat()` no controller
2. **Controller emite**`_dispatchSyncAction('commit_stat', {action, player_data, remaining_seconds, is_running})`
3. **PlacarPage escuta**`_onLocalControllerSync()` recebe o evento
4. **Envia ao Supabase**`sendSyncEvent()` armazena em `game_sync_events`
5. **Parceiro recebe**`listenToGameSyncOthers()` retorna o evento
6. **Aplica remotamente**`_applyRemoteSyncEvent()` executa a ação no parceiro
7. **Estado sincronizado** → Ambos têm timer, pontos, faltas idênticos
---
## Resultado
✅ Timer não reseta ao marcar ponto
✅ Pontos sincronizam entre os dois lados
✅ Faltas sincronizam
✅ Timeouts sincronizam
✅ Substituições sincronizam
✅ Posições de jogadores sincronizam

BIN
assets/campone.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 MiB

108
create_tables.sql Normal file
View File

@@ -0,0 +1,108 @@
-- =====================================================
-- GARANTIR QUE A TABELA PROFILES TEM TODAS AS COLUNAS
-- =====================================================
-- Verifica e adiciona colunas que podem estar em falta
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='profiles' AND column_name='id'
) THEN
CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
username VARCHAR(255),
full_name VARCHAR(255),
avatar_url TEXT,
selected_team_id UUID,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
END IF;
-- Adiciona colunas em falta se necessário
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='profiles' AND column_name='username'
) THEN
ALTER TABLE profiles ADD COLUMN username VARCHAR(255);
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='profiles' AND column_name='full_name'
) THEN
ALTER TABLE profiles ADD COLUMN full_name VARCHAR(255);
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='profiles' AND column_name='avatar_url'
) THEN
ALTER TABLE profiles ADD COLUMN avatar_url TEXT;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='profiles' AND column_name='selected_team_id'
) THEN
ALTER TABLE profiles ADD COLUMN selected_team_id UUID;
END IF;
END
$$;
-- =====================================================
-- CRIAR TABELAS DE COMPARTILHAMENTO DE JOGO
-- =====================================================
DROP TABLE IF EXISTS game_sync_events;
DROP TABLE IF EXISTS game_sessions;
CREATE TABLE game_sessions (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
game_id UUID NOT NULL REFERENCES games(id) ON DELETE CASCADE,
created_by UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
shared_with_user_id UUID REFERENCES profiles(id) ON DELETE SET NULL,
share_code VARCHAR(10) UNIQUE NOT NULL,
status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE game_sync_events (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
session_id UUID NOT NULL REFERENCES game_sessions(id) ON DELETE CASCADE,
action_type VARCHAR(50) NOT NULL,
action_data JSONB,
triggered_by UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- =====================================================
-- CRIAR ÍNDICES PARA PERFORMANCE
-- =====================================================
CREATE INDEX IF NOT EXISTS idx_game_sessions_game_id ON game_sessions(game_id);
CREATE INDEX IF NOT EXISTS idx_game_sessions_share_code ON game_sessions(share_code);
CREATE INDEX IF NOT EXISTS idx_game_sessions_status ON game_sessions(status);
CREATE INDEX IF NOT EXISTS idx_game_sync_events_session_id ON game_sync_events(session_id);
-- =====================================================
-- CRIAR TRIGGER AUTOMÁTICO PARA NOVOS UTILIZADORES
-- =====================================================
CREATE OR REPLACE FUNCTION public.create_profile_for_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id, username)
VALUES (NEW.id, COALESCE(NEW.raw_user_meta_data->>'full_name', NEW.email))
ON CONFLICT (id) DO NOTHING;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Drop the trigger if it exists and create it
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.create_profile_for_new_user();

View File

@@ -46,11 +46,28 @@ Future<void> loadGlobalTeam() async {
if (userId == null) return; if (userId == null) return;
try { try {
// 1) Prefer an explicit team selection stored on the user's profile (if any)
Map<String, dynamic>? teamData;
final profile = await supabase.from('profiles').select('selected_team_id').eq('id', userId).maybeSingle(); final profile = await supabase.from('profiles').select('selected_team_id').eq('id', userId).maybeSingle();
if (profile != null && profile['selected_team_id'] != null) { if (profile != null && profile['selected_team_id'] != null) {
final dbTeamId = profile['selected_team_id'].toString(); final dbTeamId = profile['selected_team_id'].toString();
final teamData = await supabase.from('teams').select().eq('id', dbTeamId).maybeSingle(); final dbTeam = await supabase.from('teams').select().eq('id', dbTeamId).maybeSingle();
if (dbTeam != null) teamData = Map<String, dynamic>.from(dbTeam);
}
// 2) If the user has no explicit profile selection, fall back to any team
// marked as favorite for that user (acts as a default)
if (teamData == null) {
final favTeam = await supabase
.from('teams')
.select()
.eq('user_id', userId)
.eq('is_favorite', true)
.maybeSingle();
if (favTeam != null) teamData = Map<String, dynamic>.from(favTeam);
}
// If we found a team (favorite or profile selection), set it as active and persist locally
if (teamData != null) { if (teamData != null) {
final newTeam = ActiveTeam( final newTeam = ActiveTeam(
id: teamData['id'].toString(), id: teamData['id'].toString(),
@@ -72,7 +89,6 @@ Future<void> loadGlobalTeam() async {
await prefs.setInt('last_team_losses', newTeam.losses); await prefs.setInt('last_team_losses', newTeam.losses);
await prefs.setInt('last_team_draws', newTeam.draws); await prefs.setInt('last_team_draws', newTeam.draws);
} }
}
} catch (e) { } catch (e) {
debugPrint("Erro ao carregar equipa do Supabase: $e"); debugPrint("Erro ao carregar equipa do Supabase: $e");
} }

View File

@@ -1,4 +1,5 @@
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import '../utils/session_manager.dart';
import '../models/game_model.dart'; import '../models/game_model.dart';
class GameController { class GameController {
@@ -53,6 +54,9 @@ class GameController {
// CRIAR JOGO // CRIAR JOGO
Future<String?> createGame(String myTeam, String opponent, String season) async { Future<String?> createGame(String myTeam, String opponent, String season) async {
try { try {
// Marca que existe uma sessão/jogo em progresso localmente
// (será limpa quando o jogo terminar ou em falha)
await SessionManager.setInProgress(true);
final response = await _supabase.from('games').insert({ final response = await _supabase.from('games').insert({
'user_id': myUserId, 'user_id': myUserId,
'my_team': myTeam, 'my_team': myTeam,
@@ -77,6 +81,10 @@ class GameController {
return response['id']?.toString(); return response['id']?.toString();
} catch (e) { } catch (e) {
print("Erro ao criar jogo: $e"); print("Erro ao criar jogo: $e");
// Se houve erro, limpa o flag para não exigir logout indevido
try {
await SessionManager.clear();
} catch (_) {}
return null; return null;
} }

View File

@@ -0,0 +1,281 @@
// ...existing code...
import 'package:supabase_flutter/supabase_flutter.dart';
import 'dart:math';
class GameSharingController {
final _supabase = Supabase.instance.client;
String get myUserId => _supabase.auth.currentUser?.id ?? '';
String get myUserEmail => _supabase.auth.currentUser?.email ?? '';
// ====================================
// 1⃣ GERAR CÓDIGO E CRIAR SESSÃO
// ====================================
Future<String?> createShareSession(String gameId) async {
try {
final shareCode = _generateShareCode();
final response = await _supabase.from('game_sessions').insert({
'game_id': gameId,
'created_by': myUserId,
'share_code': shareCode,
'status': 'active',
}).select().single();
return shareCode;
} catch (e) {
print("❌ Erro ao criar sessão de compartilhamento: $e");
return null;
}
}
// ====================================
// 2⃣ ENTRAR EM JOGO COMPARTILHADO
// ====================================
Future<Map<String, dynamic>?> joinGameByCode(String shareCode) async {
try {
print("🔍 Procurando sessão com código: $shareCode");
// Procura a sessão pelo código
final sessions = await _supabase
.from('game_sessions')
.select()
.eq('share_code', shareCode.toUpperCase())
.eq('status', 'active');
print("📋 Sessões encontradas: ${sessions.length}");
if (sessions.isEmpty) {
print("❌ Código inválido ou expirado");
return null;
}
final session = sessions.first;
final gameId = session['game_id'] as String;
final createdBy = session['created_by'] as String;
print("🎮 Game ID: $gameId, Criado por: $createdBy");
// Garante que o utilizador atual tem perfil
print("👤 Verificando perfil do utilizador: $myUserId");
await _ensureUserProfile();
// Atualiza a sessão para adicionar o utilizador que está a entrar
await _supabase.from('game_sessions').update({
'shared_with_user_id': myUserId,
'updated_at': DateTime.now().toIso8601String(),
}).eq('id', session['id']);
print("✅ Sessão atualizada com novo utilizador");
// Busca informações do jogo
final gameData = await _supabase
.from('games')
.select()
.eq('id', gameId)
.single();
print("📊 Dados do jogo: $gameData");
// Busca o nome do utilizador que criou
final creatorData = await _supabase
.from('profiles')
.select('username, full_name')
.eq('id', createdBy)
.maybeSingle();
final creatorName = creatorData != null
? (creatorData['full_name'] ?? creatorData['username'] ?? 'Utilizador')
: 'Utilizador';
print("👤 Criador: $creatorName");
return {
'session_id': session['id'],
'game_id': gameId,
'creator_name': creatorName,
'game': gameData,
};
} catch (e) {
print("❌ Erro ao entrar no jogo: $e");
return null;
}
}
// ====================================
// GARANTIR QUE UTILIZADOR TEM PERFIL
// ====================================
Future<void> _ensureUserProfile() async {
try {
final user = _supabase.auth.currentUser;
if (user == null) return;
// Verifica se o perfil existe
final existing = await _supabase
.from('profiles')
.select()
.eq('id', user.id)
.maybeSingle();
if (existing == null) {
// Cria o perfil se não existir - usa apenas colunas básicas
print("📝 Criando perfil para novo utilizador");
await _supabase.from('profiles').upsert({
'id': user.id,
'username': user.email?.split('@').first ?? 'user',
}, onConflict: 'id');
print("✅ Perfil criado com sucesso");
} else {
print("✅ Perfil já existe");
}
} catch (e) {
print("⚠️ Aviso ao verificar/criar perfil: $e");
// Não falha o join se o perfil já existe
}
}
// ====================================
// 3⃣ OBTER INFORMAÇÕES DA SESSÃO
// ====================================
Future<Map<String, dynamic>?> getSessionInfo(String sessionId) async {
try {
final response = await _supabase
.from('game_sessions')
.select()
.eq('id', sessionId)
.single();
return response;
} catch (e) {
print("❌ Erro ao buscar informações da sessão: $e");
return null;
}
}
// ====================================
// 4⃣ ENVIAR EVENTO DE SINCRONIZAÇÃO
// ====================================
Future<bool> sendSyncEvent(
String sessionId,
String actionType,
Map<String, dynamic> actionData, {
String? playerId, // opcional: identifica jogador/entidade alvo
}) async {
try {
await _supabase.from('game_sync_events').insert({
'session_id': sessionId,
'action_type': actionType,
'action_data': actionData,
'triggered_by': myUserId,
if (playerId != null) 'player_id': playerId,
});
print("✅ Evento sincronizado: $actionType");
return true;
} catch (e) {
print("❌ Erro ao enviar evento de sincronização: $e");
return false;
}
}
// ====================================
// 5⃣ OUVIR EVENTOS EM TEMPO REAL
// ====================================
Stream<dynamic> listenToGameSync(String sessionId) {
return _supabase
.from('game_sync_events')
.stream(primaryKey: ['id'])
.eq('session_id', sessionId)
.order('created_at', ascending: false);
}
/// Retorna apenas os eventos que NÃO foram disparados pelo utilizador atual.
/// Emite uma lista de eventos (List<Map<String, dynamic>>) por cada atualização.
Stream<List<Map<String, dynamic>>> listenToGameSyncOthers(String sessionId) {
return listenToGameSync(sessionId).map((data) {
List<Map<String, dynamic>> rows = [];
try {
if (data is List) {
rows = List<Map<String, dynamic>>.from(data);
} else if (data is Map) {
rows = [Map<String, dynamic>.from(data)];
}
} catch (_) {
return <Map<String, dynamic>>[];
}
return rows.where((r) => (r['triggered_by'] as String?) != myUserId).toList();
});
}
// ====================================
// 6⃣ OBTER ÚLTIMOS EVENTOS
// ====================================
Future<List<Map<String, dynamic>>> getRecentSyncEvents(String sessionId, {int limit = 10}) async {
try {
final response = await _supabase
.from('game_sync_events')
.select()
.eq('session_id', sessionId)
.order('created_at', ascending: false)
.limit(limit);
return List<Map<String, dynamic>>.from(response);
} catch (e) {
print("❌ Erro ao buscar eventos: $e");
return [];
}
}
// ====================================
// 7⃣ TERMINAR SESSÃO COMPARTILHADA
// ====================================
Future<bool> endShareSession(String sessionId) async {
try {
await _supabase
.from('game_sessions')
.update({'status': 'ended'})
.eq('id', sessionId);
print("✅ Sessão terminada");
return true;
} catch (e) {
print("❌ Erro ao terminar sessão: $e");
return false;
}
}
// ====================================
// 8⃣ OBTER SESSÃO ATIVA DO JOGO
// ====================================
Future<Map<String, dynamic>?> getActiveSessionForGame(String gameId) async {
try {
final response = await _supabase
.from('game_sessions')
.select()
.eq('game_id', gameId)
.eq('status', 'active')
.single();
return response;
} catch (e) {
return null; // Sem sessão ativa
}
}
// ====================================
// FUNÇÕES PRIVADAS
// ====================================
String _generateShareCode() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
final random = Random();
return List.generate(6, (index) => chars[random.nextInt(chars.length)]).join();
}
}

View File

@@ -25,13 +25,23 @@ class ShotRecord {
}); });
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'relativeX': relativeX, 'relativeY': relativeY, 'isMake': isMake, 'relativeX': relativeX,
'playerId': playerId, 'playerName': playerName, 'zone': zone, 'points': points, 'relativeY': relativeY,
'isMake': isMake,
'playerId': playerId,
'playerName': playerName,
'zone': zone,
'points': points,
}; };
factory ShotRecord.fromJson(Map<String, dynamic> json) => ShotRecord( factory ShotRecord.fromJson(Map<String, dynamic> json) => ShotRecord(
relativeX: json['relativeX'], relativeY: json['relativeY'], isMake: json['isMake'], relativeX: json['relativeX'],
playerId: json['playerId'], playerName: json['playerName'], zone: json['zone'], points: json['points'], relativeY: json['relativeY'],
isMake: json['isMake'],
playerId: json['playerId'],
playerName: json['playerName'],
zone: json['zone'],
points: json['points'],
); );
} }
@@ -39,13 +49,126 @@ class PlacarController extends ChangeNotifier {
final String gameId; final String gameId;
final String myTeam; final String myTeam;
final String opponentTeam; final String opponentTeam;
final void Function(String actionType, Map<String, dynamic> actionData)?
onSyncAction;
PlacarController({ PlacarController({
required this.gameId, required this.gameId,
required this.myTeam, required this.myTeam,
required this.opponentTeam, required this.opponentTeam,
this.onSyncAction,
}); });
void _dispatchSyncAction(String actionType, Map<String, dynamic> actionData) {
if (onSyncAction != null) {
final enrichedActionData = Map<String, dynamic>.from(actionData)
..['remaining_seconds'] = durationNotifier.value.inSeconds
..['is_running'] = isRunning
..['current_quarter'] = currentQuarter
..['my_fouls'] = myFouls
..['opponent_fouls'] = opponentFouls
..['my_timeouts_used'] = myTimeoutsUsed
..['opponent_timeouts_used'] = opponentTimeoutsUsed;
onSyncAction!(actionType, enrichedActionData);
}
}
void _startTimer() {
timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!isRunning) return;
if (durationNotifier.value.inSeconds > 0) {
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);
durationNotifier.value -= const Duration(seconds: 1);
} else {
timer.cancel();
isRunning = false;
if (currentQuarter < 4) {
currentQuarter++;
durationNotifier.value = const Duration(minutes: 10);
myFouls = 0;
opponentFouls = 0;
myTimeoutsUsed = 0;
opponentTimeoutsUsed = 0;
_scheduleAutoSave();
}
notifyListeners();
_dispatchSyncAction('period_ended', {});
}
});
}
void _setTimerRunning(bool shouldRun, {bool emitSync = true}) {
print("🔧 _setTimerRunning: shouldRun=$shouldRun, isRunning=$isRunning");
if (shouldRun == isRunning) {
print("🔧 Guardado: shouldRun == isRunning");
return;
}
isRunning = shouldRun;
if (!shouldRun) {
print("🛑 Cancelando timer");
timer?.cancel();
_scheduleAutoSave();
} else {
print("▶️ Iniciando timer");
_startTimer();
}
notifyListeners();
print("✅ notifyListeners chamado");
if (emitSync) {
print("📡 Despachando sync action");
_dispatchSyncAction('toggle_timer', {'is_running': isRunning});
}
}
void applyRemoteTimerState(bool shouldRun) {
_setTimerRunning(shouldRun, emitSync: false);
}
void applyRemoteAddShot(Map<String, dynamic> shotJson) {
try {
matchShots.add(ShotRecord.fromJson(shotJson));
notifyListeners();
} catch (e) {
debugPrint('Erro ao aplicar shot remoto: $e');
}
}
Future<void> _persistShotRemote(Map<String, dynamic> shotJson) async {
try {
final supabase = Supabase.instance.client;
final row = {
'game_id': gameId,
'member_id': shotJson['playerId'] ?? shotJson['player_id'],
'player_name': shotJson['playerName'] ?? shotJson['player_name'],
'relative_x': shotJson['relativeX'] ?? shotJson['relative_x'],
'relative_y': shotJson['relativeY'] ?? shotJson['relative_y'],
'is_make': shotJson['isMake'] ?? shotJson['is_make'],
'zone': shotJson['zone'],
'points': shotJson['points'],
};
await supabase.from('shot_locations').insert(row);
debugPrint('✅ Shot persisted remotely');
} catch (e) {
debugPrint('❌ Erro ao persistir shot remoto: $e');
}
}
bool isLoading = true; bool isLoading = true;
bool isSaving = false; bool isSaving = false;
bool gameWasAlreadyFinished = false; bool gameWasAlreadyFinished = false;
@@ -80,7 +203,9 @@ class PlacarController extends ChangeNotifier {
List<String> playByPlay = []; List<String> playByPlay = [];
ValueNotifier<Duration> durationNotifier = ValueNotifier(const Duration(minutes: 10)); ValueNotifier<Duration> durationNotifier = ValueNotifier(
const Duration(minutes: 10),
);
Timer? timer; Timer? timer;
bool isRunning = false; bool isRunning = false;
@@ -96,21 +221,41 @@ class PlacarController extends ChangeNotifier {
try { try {
await Future.delayed(const Duration(milliseconds: 1500)); await Future.delayed(const Duration(milliseconds: 1500));
myCourt.clear(); myBench.clear(); oppCourt.clear(); oppBench.clear(); myCourt.clear();
playerNames.clear(); playerStats.clear(); playerNumbers.clear(); myBench.clear();
matchShots.clear(); playByPlay.clear(); myFouls = 0; opponentFouls = 0; oppCourt.clear();
oppBench.clear();
playerNames.clear();
playerStats.clear();
playerNumbers.clear();
matchShots.clear();
playByPlay.clear();
myFouls = 0;
opponentFouls = 0;
final gameResponse = await supabase.from('games').select().eq('id', gameId).single(); final gameResponse = await supabase
.from('games')
.select()
.eq('id', gameId)
.single();
myScore = int.tryParse(gameResponse['my_score']?.toString() ?? '0') ?? 0; myScore = int.tryParse(gameResponse['my_score']?.toString() ?? '0') ?? 0;
opponentScore = int.tryParse(gameResponse['opponent_score']?.toString() ?? '0') ?? 0; opponentScore =
int.tryParse(gameResponse['opponent_score']?.toString() ?? '0') ?? 0;
int totalSeconds = int.tryParse(gameResponse['remaining_seconds']?.toString() ?? '600') ?? 600; int totalSeconds =
int.tryParse(
gameResponse['remaining_seconds']?.toString() ?? '600',
) ??
600;
durationNotifier.value = Duration(seconds: totalSeconds); durationNotifier.value = Duration(seconds: totalSeconds);
myTimeoutsUsed = int.tryParse(gameResponse['my_timeouts']?.toString() ?? '0') ?? 0; myTimeoutsUsed =
opponentTimeoutsUsed = int.tryParse(gameResponse['opp_timeouts']?.toString() ?? '0') ?? 0; int.tryParse(gameResponse['my_timeouts']?.toString() ?? '0') ?? 0;
currentQuarter = int.tryParse(gameResponse['current_quarter']?.toString() ?? '1') ?? 1; opponentTimeoutsUsed =
int.tryParse(gameResponse['opp_timeouts']?.toString() ?? '0') ?? 0;
currentQuarter =
int.tryParse(gameResponse['current_quarter']?.toString() ?? '1') ?? 1;
gameWasAlreadyFinished = gameResponse['status'] == 'Terminado'; gameWasAlreadyFinished = gameResponse['status'] == 'Terminado';
@@ -120,25 +265,49 @@ class PlacarController extends ChangeNotifier {
playByPlay = []; playByPlay = [];
} }
final teamsResponse = await supabase.from('teams').select('id, name').inFilter('name', [myTeam, opponentTeam]); final teamsResponse = await supabase
.from('teams')
.select('id, name')
.inFilter('name', [myTeam, opponentTeam]);
for (var t in teamsResponse) { for (var t in teamsResponse) {
if (t['name'] == myTeam) myTeamDbId = t['id']; if (t['name'] == myTeam) myTeamDbId = t['id'];
if (t['name'] == opponentTeam) oppTeamDbId = t['id']; if (t['name'] == opponentTeam) oppTeamDbId = t['id'];
} }
List<dynamic> myPlayers = myTeamDbId != null ? await supabase.from('members').select().eq('team_id', myTeamDbId!).eq('type', 'Jogador') : []; List<dynamic> myPlayers = myTeamDbId != null
List<dynamic> oppPlayers = oppTeamDbId != null ? await supabase.from('members').select().eq('team_id', oppTeamDbId!).eq('type', 'Jogador') : []; ? 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')
: [];
final statsResponse = await supabase.from('player_stats').select().eq('game_id', gameId); final statsResponse = await supabase
.from('player_stats')
.select()
.eq('game_id', gameId);
final Map<String, dynamic> savedStats = { final Map<String, dynamic> savedStats = {
for (var item in statsResponse) item['member_id'].toString(): item for (var item in statsResponse) item['member_id'].toString(): item,
}; };
for (int i = 0; i < myPlayers.length; i++) { for (int i = 0; i < myPlayers.length; i++) {
String dbId = myPlayers[i]['id'].toString(); String dbId = myPlayers[i]['id'].toString();
String name = myPlayers[i]['name'].toString(); String name = myPlayers[i]['name'].toString();
_registerPlayer(name: name, number: myPlayers[i]['number']?.toString() ?? "0", dbId: dbId, isMyTeam: true, isCourt: i < 5); _registerPlayer(
name: name,
number: myPlayers[i]['number']?.toString() ?? "0",
dbId: dbId,
isMyTeam: true,
isCourt: i < 5,
);
if (savedStats.containsKey(dbId)) { if (savedStats.containsKey(dbId)) {
var s = savedStats[dbId]; var s = savedStats[dbId];
@@ -152,7 +321,13 @@ class PlacarController extends ChangeNotifier {
String dbId = oppPlayers[i]['id'].toString(); String dbId = oppPlayers[i]['id'].toString();
String name = oppPlayers[i]['name'].toString(); String name = oppPlayers[i]['name'].toString();
_registerPlayer(name: name, number: oppPlayers[i]['number']?.toString() ?? "0", dbId: dbId, isMyTeam: false, isCourt: i < 5); _registerPlayer(
name: name,
number: oppPlayers[i]['number']?.toString() ?? "0",
dbId: dbId,
isMyTeam: false,
isCourt: i < 5,
);
if (savedStats.containsKey(dbId)) { if (savedStats.containsKey(dbId)) {
var s = savedStats[dbId]; var s = savedStats[dbId];
@@ -162,17 +337,24 @@ class PlacarController extends ChangeNotifier {
} }
_padTeam(oppCourt, oppBench, "Adversário", isMyTeam: false); _padTeam(oppCourt, oppBench, "Adversário", isMyTeam: false);
final shotsResponse = await supabase.from('shot_locations').select().eq('game_id', gameId); final shotsResponse = await supabase
.from('shot_locations')
.select()
.eq('game_id', gameId);
for (var shotData in shotsResponse) { for (var shotData in shotsResponse) {
matchShots.add(ShotRecord( matchShots.add(
ShotRecord(
relativeX: double.parse(shotData['relative_x'].toString()), relativeX: double.parse(shotData['relative_x'].toString()),
relativeY: double.parse(shotData['relative_y'].toString()), relativeY: double.parse(shotData['relative_y'].toString()),
isMake: shotData['is_make'] == true, isMake: shotData['is_make'] == true,
playerId: shotData['member_id'].toString(), playerId: shotData['member_id'].toString(),
playerName: shotData['player_name'].toString(), playerName: shotData['player_name'].toString(),
zone: shotData['zone']?.toString(), zone: shotData['zone']?.toString(),
points: shotData['points'] != null ? int.parse(shotData['points'].toString()) : null, points: shotData['points'] != null
)); ? int.parse(shotData['points'].toString())
: null,
),
);
} }
await _loadLocalBackup(); await _loadLocalBackup();
@@ -188,42 +370,103 @@ class PlacarController extends ChangeNotifier {
void _loadSavedPlayerStats(String dbId, Map<String, dynamic> s) { void _loadSavedPlayerStats(String dbId, Map<String, dynamic> s) {
playerStats[dbId] = { playerStats[dbId] = {
"pts": s['pts'] ?? 0, "rbs": s['rbs'] ?? 0, "ast": s['ast'] ?? 0, "pts": s['pts'] ?? 0,
"stl": s['stl'] ?? 0, "tov": s['tov'] ?? 0, "blk": s['blk'] ?? 0, "rbs": s['rbs'] ?? 0,
"fls": s['fls'] ?? 0, "fgm": s['fgm'] ?? 0, "fga": s['fga'] ?? 0, "ast": s['ast'] ?? 0,
"ftm": s['ftm'] ?? 0, "fta": s['fta'] ?? 0, "orb": s['orb'] ?? 0, "drb": s['drb'] ?? 0, "stl": s['stl'] ?? 0,
"p2m": s['p2m'] ?? 0, "p2a": s['p2a'] ?? 0, "p3m": s['p3m'] ?? 0, "p3a": s['p3a'] ?? 0, "tov": s['tov'] ?? 0,
"so": s['so'] ?? 0, "il": s['il'] ?? 0, "li": s['li'] ?? 0, "blk": s['blk'] ?? 0,
"pa": s['pa'] ?? 0, "tres_seg": s['tres_seg'] ?? 0, "dr": s['dr'] ?? 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_seg": s['tres_seg'] ?? 0,
"dr": s['dr'] ?? 0,
"min": (s['minutos_jogados'] ?? 0) ~/ 60, "min": (s['minutos_jogados'] ?? 0) ~/ 60,
"sec": s['minutos_jogados'] ?? 0, "sec": s['minutos_jogados'] ?? 0,
}; };
} }
void _registerPlayer({required String name, required String number, String? dbId, required bool isMyTeam, required bool isCourt}) { void _registerPlayer({
String id = dbId ?? "fake_${DateTime.now().millisecondsSinceEpoch}_${math.Random().nextInt(9999)}"; required String name,
required String number,
String? dbId,
required bool isMyTeam,
required bool isCourt,
}) {
String id =
dbId ??
"fake_${DateTime.now().millisecondsSinceEpoch}_${math.Random().nextInt(9999)}";
playerNames[id] = name; playerNames[id] = name;
playerNumbers[id] = number; playerNumbers[id] = number;
playerStats[id] = { playerStats[id] = {
"pts": 0, "rbs": 0, "ast": 0, "stl": 0, "tov": 0, "blk": 0, "pts": 0,
"fls": 0, "fgm": 0, "fga": 0, "ftm": 0, "fta": 0, "orb": 0, "drb": 0, "rbs": 0,
"p2m": 0, "p2a": 0, "p3m": 0, "p3a": 0, "ast": 0,
"so": 0, "il": 0, "li": 0, "pa": 0, "tres_seg": 0, "dr": 0, "stl": 0,
"min": 0, "sec": 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_seg": 0,
"dr": 0,
"min": 0,
"sec": 0,
}; };
if (isMyTeam) { if (isMyTeam) {
if (isCourt) myCourt.add(id); else myBench.add(id); if (isCourt)
myCourt.add(id);
else
myBench.add(id);
} else { } else {
if (isCourt) oppCourt.add(id); else oppBench.add(id); if (isCourt)
oppCourt.add(id);
else
oppBench.add(id);
} }
} }
void _padTeam(List<String> court, List<String> bench, String prefix, {required bool isMyTeam}) { void _padTeam(
List<String> court,
List<String> bench,
String prefix, {
required bool isMyTeam,
}) {
while (court.length < 5) { while (court.length < 5) {
_registerPlayer(name: "Sem $prefix ${court.length + 1}", number: "0", dbId: null, isMyTeam: isMyTeam, isCourt: true); _registerPlayer(
name: "Sem $prefix ${court.length + 1}",
number: "0",
dbId: null,
isMyTeam: isMyTeam,
isCourt: true,
);
} }
} }
@@ -238,12 +481,19 @@ class PlacarController extends ChangeNotifier {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final backupData = { final backupData = {
'myScore': myScore, 'opponentScore': opponentScore, 'myScore': myScore,
'myFouls': myFouls, 'opponentFouls': opponentFouls, 'opponentScore': opponentScore,
'currentQuarter': currentQuarter, 'duration': durationNotifier.value.inSeconds, 'myFouls': myFouls,
'myTimeoutsUsed': myTimeoutsUsed, 'opponentTimeoutsUsed': opponentTimeoutsUsed, 'opponentFouls': opponentFouls,
'currentQuarter': currentQuarter,
'duration': durationNotifier.value.inSeconds,
'myTimeoutsUsed': myTimeoutsUsed,
'opponentTimeoutsUsed': opponentTimeoutsUsed,
'playerStats': playerStats, 'playerStats': playerStats,
'myCourt': myCourt, 'myBench': myBench, 'oppCourt': oppCourt, 'oppBench': oppBench, 'myCourt': myCourt,
'myBench': myBench,
'oppCourt': oppCourt,
'oppBench': oppBench,
'matchShots': matchShots.map((s) => s.toJson()).toList(), 'matchShots': matchShots.map((s) => s.toJson()).toList(),
'playByPlay': playByPlay, 'playByPlay': playByPlay,
}; };
@@ -261,16 +511,24 @@ class PlacarController extends ChangeNotifier {
if (backupString != null) { if (backupString != null) {
final data = jsonDecode(backupString); final data = jsonDecode(backupString);
myScore = data['myScore']; opponentScore = data['opponentScore']; myScore = data['myScore'];
myFouls = data['myFouls']; opponentFouls = data['opponentFouls']; opponentScore = data['opponentScore'];
currentQuarter = data['currentQuarter']; durationNotifier.value = Duration(seconds: data['duration']); myFouls = data['myFouls'];
myTimeoutsUsed = data['myTimeoutsUsed']; opponentTimeoutsUsed = data['opponentTimeoutsUsed']; 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']); myCourt = List<String>.from(data['myCourt']);
oppCourt = List<String>.from(data['oppCourt']); oppBench = List<String>.from(data['oppBench']); myBench = List<String>.from(data['myBench']);
oppCourt = List<String>.from(data['oppCourt']);
oppBench = List<String>.from(data['oppBench']);
Map<String, dynamic> decodedStats = data['playerStats']; Map<String, dynamic> decodedStats = data['playerStats'];
playerStats = decodedStats.map((k, v) => MapEntry(k, Map<String, int>.from(v))); playerStats = decodedStats.map(
(k, v) => MapEntry(k, Map<String, int>.from(v)),
);
List<dynamic> decodedShots = data['matchShots']; List<dynamic> decodedShots = data['matchShots'];
matchShots = decodedShots.map((s) => ShotRecord.fromJson(s)).toList(); matchShots = decodedShots.map((s) => ShotRecord.fromJson(s)).toList();
@@ -283,43 +541,8 @@ class PlacarController extends ChangeNotifier {
} }
void toggleTimer(BuildContext context) { void toggleTimer(BuildContext context) {
if (isRunning) { print("⏱️ toggleTimer chamado: isRunning=$isRunning");
timer?.cancel(); _setTimerRunning(!isRunning);
_scheduleAutoSave();
} else {
timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (durationNotifier.value.inSeconds > 0) {
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);
durationNotifier.value -= const Duration(seconds: 1);
} else {
timer.cancel();
isRunning = false;
if (currentQuarter < 4) {
currentQuarter++;
durationNotifier.value = const Duration(minutes: 10);
myFouls = 0; opponentFouls = 0;
myTimeoutsUsed = 0; opponentTimeoutsUsed = 0;
_scheduleAutoSave();
}
notifyListeners();
}
});
}
isRunning = !isRunning;
notifyListeners();
} }
void useTimeout(bool isOpponent) { void useTimeout(bool isOpponent) {
@@ -332,19 +555,34 @@ class PlacarController extends ChangeNotifier {
timer?.cancel(); timer?.cancel();
_scheduleAutoSave(); _scheduleAutoSave();
notifyListeners(); notifyListeners();
_dispatchSyncAction('use_timeout', {'is_opponent': isOpponent});
} }
void handleActionDrag(BuildContext context, String action, String playerData) { void handleActionDrag(
String playerId = playerData.replaceAll("player_my_", "").replaceAll("player_opp_", ""); BuildContext context,
String action,
String playerData,
) {
String playerId = playerData
.replaceAll("player_my_", "")
.replaceAll("player_opp_", "");
final stats = playerStats[playerId]!; final stats = playerStats[playerId]!;
final name = playerNames[playerId]!; final name = playerNames[playerId]!;
if (stats["fls"]! >= 5 && action != "sub_foul") { if (stats["fls"]! >= 5 && action != "sub_foul") {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('🛑 $name atingiu 5 faltas e está expulso!'), backgroundColor: Colors.red)); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('🛑 $name atingiu 5 faltas e está expulso!'),
backgroundColor: Colors.red,
),
);
return; return;
} }
if (action == "add_pts_2" || action == "add_pts_3" || action == "miss_2" || action == "miss_3") { if (action == "add_pts_2" ||
action == "add_pts_3" ||
action == "miss_2" ||
action == "miss_3") {
pendingAction = action; pendingAction = action;
pendingPlayerId = playerData; pendingPlayerId = playerData;
isSelectingShotLocation = true; isSelectingShotLocation = true;
@@ -354,7 +592,12 @@ class PlacarController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void handleSubbing(BuildContext context, String action, String courtPlayerId, bool isOpponent) { void handleSubbing(
BuildContext context,
String action,
String courtPlayerId,
bool isOpponent,
) {
if (action.startsWith("bench_my_") && !isOpponent) { if (action.startsWith("bench_my_") && !isOpponent) {
String benchPlayerId = action.replaceAll("bench_my_", ""); String benchPlayerId = action.replaceAll("bench_my_", "");
if (playerStats[benchPlayerId]!["fls"]! >= 5) return; if (playerStats[benchPlayerId]!["fls"]! >= 5) return;
@@ -375,12 +618,18 @@ class PlacarController extends ChangeNotifier {
} }
_scheduleAutoSave(); _scheduleAutoSave();
notifyListeners(); notifyListeners();
_dispatchSyncAction('subbing', {
'action': action,
'court_player': courtPlayerId,
'is_opponent': isOpponent,
});
} }
// ── TROCAR JOGADORES NO CAMPO ────────────────────────────────────────────── // ── TROCAR JOGADORES NO CAMPO ──────────────────────────────────────────────
void swapCourtPlayers(String draggedPlayerData, String targetPlayerData) { void swapCourtPlayers(String draggedPlayerData, String targetPlayerData) {
// Verifica se são da mesma equipa (Minha Equipa) // Verifica se são da mesma equipa (Minha Equipa)
if (draggedPlayerData.startsWith("player_my_") && targetPlayerData.startsWith("player_my_")) { if (draggedPlayerData.startsWith("player_my_") &&
targetPlayerData.startsWith("player_my_")) {
String id1 = draggedPlayerData.replaceAll("player_my_", ""); String id1 = draggedPlayerData.replaceAll("player_my_", "");
String id2 = targetPlayerData.replaceAll("player_my_", ""); String id2 = targetPlayerData.replaceAll("player_my_", "");
@@ -393,7 +642,8 @@ class PlacarController extends ChangeNotifier {
} }
} }
// Verifica se são da mesma equipa (Adversário) // Verifica se são da mesma equipa (Adversário)
else if (draggedPlayerData.startsWith("player_opp_") && targetPlayerData.startsWith("player_opp_")) { else if (draggedPlayerData.startsWith("player_opp_") &&
targetPlayerData.startsWith("player_opp_")) {
String id1 = draggedPlayerData.replaceAll("player_opp_", ""); String id1 = draggedPlayerData.replaceAll("player_opp_", "");
String id2 = targetPlayerData.replaceAll("player_opp_", ""); String id2 = targetPlayerData.replaceAll("player_opp_", "");
@@ -411,20 +661,49 @@ class PlacarController extends ChangeNotifier {
_scheduleAutoSave(); _scheduleAutoSave();
notifyListeners(); notifyListeners();
_dispatchSyncAction('swap_players', {
'dragged': draggedPlayerData,
'target': targetPlayerData,
});
} }
void registerShotFromPopup(BuildContext context, String action, String targetPlayer, String zone, int points, double relativeX, double relativeY) { void registerShotFromPopup(
String playerId = targetPlayer.replaceAll("player_my_", "").replaceAll("player_opp_", ""); 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_"); bool isMake = action.startsWith("add_");
String name = playerNames[playerId] ?? "Jogador"; String name = playerNames[playerId] ?? "Jogador";
matchShots.add(ShotRecord( matchShots.add(
relativeX: relativeX, relativeY: relativeY, isMake: isMake, ShotRecord(
playerId: playerId, playerName: name, zone: zone, points: points relativeX: relativeX,
)); relativeY: relativeY,
isMake: isMake,
playerId: playerId,
playerName: name,
zone: zone,
points: points,
),
);
String finalAction = isMake ? "add_pts_$points" : "miss_$points"; String finalAction = isMake ? "add_pts_$points" : "miss_$points";
commitStat(finalAction, targetPlayer); commitStat(finalAction, targetPlayer);
// Emitir evento de shot para parceiros remotos
try {
final shotJson = matchShots.last.toJson();
_dispatchSyncAction('add_shot', {'shot': shotJson});
// Persist shot immediately on server (fire-and-forget)
_persistShotRemote(shotJson);
} catch (_) {}
notifyListeners(); notifyListeners();
} }
@@ -442,13 +721,33 @@ class PlacarController extends ChangeNotifier {
bool isMake = pendingAction!.startsWith("add_pts_"); bool isMake = pendingAction!.startsWith("add_pts_");
double relX = position.dx / size.width; double relX = position.dx / size.width;
double relY = position.dy / size.height; double relY = position.dy / size.height;
String pId = pendingPlayerId!.replaceAll("player_my_", "").replaceAll("player_opp_", ""); String pId = pendingPlayerId!
.replaceAll("player_my_", "")
.replaceAll("player_opp_", "");
matchShots.add(ShotRecord(relativeX: relX, relativeY: relY, isMake: isMake, playerId: pId, playerName: playerNames[pId]!)); matchShots.add(
ShotRecord(
relativeX: relX,
relativeY: relY,
isMake: isMake,
playerId: pId,
playerName: playerNames[pId]!,
),
);
// Emitir evento de shot para parceiros remotos
try {
final shotJson = matchShots.last.toJson();
_dispatchSyncAction('add_shot', {'shot': shotJson});
// Persist shot immediately on server (fire-and-forget)
_persistShotRemote(shotJson);
} catch (_) {}
commitStat(pendingAction!, pendingPlayerId!); commitStat(pendingAction!, pendingPlayerId!);
isSelectingShotLocation = false; pendingAction = null; pendingPlayerId = null; isSelectingShotLocation = false;
pendingAction = null;
pendingPlayerId = null;
_scheduleAutoSave(); _scheduleAutoSave();
notifyListeners(); notifyListeners();
} }
@@ -481,17 +780,25 @@ class PlacarController extends ChangeNotifier {
} }
void cancelShotLocation() { void cancelShotLocation() {
isSelectingShotLocation = false; pendingAction = null; pendingPlayerId = null; notifyListeners(); isSelectingShotLocation = false;
pendingAction = null;
pendingPlayerId = null;
notifyListeners();
} }
void registerFoul(String committerData, String foulType, String victimData) { void registerFoul(String committerData, String foulType, String victimData) {
bool isOpponent = committerData.startsWith("player_opp_"); bool isOpponent = committerData.startsWith("player_opp_");
String committerId = committerData.replaceAll("player_my_", "").replaceAll("player_opp_", ""); String committerId = committerData
.replaceAll("player_my_", "")
.replaceAll("player_opp_", "");
final committerStats = playerStats[committerId]!; final committerStats = playerStats[committerId]!;
final committerName = playerNames[committerId] ?? "Jogador"; final committerName = playerNames[committerId] ?? "Jogador";
committerStats["fls"] = committerStats["fls"]! + 1; committerStats["fls"] = committerStats["fls"]! + 1;
if (isOpponent) opponentFouls++; else myFouls++; if (isOpponent)
opponentFouls++;
else
myFouls++;
if (foulType == "Desqualificante") { if (foulType == "Desqualificante") {
committerStats["fls"] = 5; committerStats["fls"] = 5;
@@ -500,7 +807,9 @@ class PlacarController extends ChangeNotifier {
String logText = "cometeu Falta $foulType"; String logText = "cometeu Falta $foulType";
if (victimData.isNotEmpty) { if (victimData.isNotEmpty) {
String victimId = victimData.replaceAll("player_my_", "").replaceAll("player_opp_", ""); String victimId = victimData
.replaceAll("player_my_", "")
.replaceAll("player_opp_", "");
final victimStats = playerStats[victimId]!; final victimStats = playerStats[victimId]!;
final victimName = playerNames[victimId] ?? "Jogador"; final victimName = playerNames[victimId] ?? "Jogador";
@@ -510,11 +819,17 @@ class PlacarController extends ChangeNotifier {
logText += " (Equipa/Banco) ⚠️"; logText += " (Equipa/Banco) ⚠️";
} }
String time = "${durationNotifier.value.inMinutes.toString().padLeft(2, '0')}:${durationNotifier.value.inSeconds.remainder(60).toString().padLeft(2, '0')}"; 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"); playByPlay.insert(0, "P$currentQuarter - $time: $committerName $logText");
_scheduleAutoSave(); _scheduleAutoSave();
notifyListeners(); notifyListeners();
_dispatchSyncAction('register_foul', {
'committer': committerData,
'foulType': foulType,
'victim': victimData,
});
} }
void commitStat(String action, String playerData) { void commitStat(String action, String playerData) {
@@ -553,13 +868,14 @@ class PlacarController extends ChangeNotifier {
} }
logText = "marcou $pts pontos 🏀"; logText = "marcou $pts pontos 🏀";
} }
// ── ANULAR PONTOS ──────────────────────────────────────────────────────── // ── ANULAR PONTOS ────────────────────────────────────────────────────────
else if (action.startsWith("sub_pts_")) { else if (action.startsWith("sub_pts_")) {
int ptsToAnul = int.parse(action.split("_").last); int ptsToAnul = int.parse(action.split("_").last);
int lastShotIndex = matchShots.lastIndexWhere((s) => int lastShotIndex = matchShots.lastIndexWhere(
s.playerId == playerId && s.isMake == true && s.points == ptsToAnul); (s) =>
s.playerId == playerId && s.isMake == true && s.points == ptsToAnul,
);
if (lastShotIndex != -1) { if (lastShotIndex != -1) {
matchShots.removeAt(lastShotIndex); matchShots.removeAt(lastShotIndex);
@@ -588,7 +904,6 @@ class PlacarController extends ChangeNotifier {
return; return;
} }
} }
// ── FALHAS ─────────────────────────────────────────────────────────────── // ── FALHAS ───────────────────────────────────────────────────────────────
else if (action == "miss_1") { else if (action == "miss_1") {
stats["fta"] = stats["fta"]! + 1; stats["fta"] = stats["fta"]! + 1;
@@ -602,7 +917,6 @@ class PlacarController extends ChangeNotifier {
stats["p3a"] = stats["p3a"]! + 1; stats["p3a"] = stats["p3a"]! + 1;
logText = "falhou lançamento de 3 ❌"; logText = "falhou lançamento de 3 ❌";
} }
// ── RESSALTOS ───────────────────────────────────────────────────────────── // ── RESSALTOS ─────────────────────────────────────────────────────────────
else if (action == "add_orb") { else if (action == "add_orb") {
stats["orb"] = stats["orb"]! + 1; stats["orb"] = stats["orb"]! + 1;
@@ -613,19 +927,16 @@ class PlacarController extends ChangeNotifier {
stats["rbs"] = stats["rbs"]! + 1; stats["rbs"] = stats["rbs"]! + 1;
logText = "ganhou ressalto defensivo 🛡️"; logText = "ganhou ressalto defensivo 🛡️";
} }
// ── ASSISTÊNCIA ─────────────────────────────────────────────────────────── // ── ASSISTÊNCIA ───────────────────────────────────────────────────────────
else if (action == "add_ast") { else if (action == "add_ast") {
stats["ast"] = stats["ast"]! + 1; stats["ast"] = stats["ast"]! + 1;
logText = "fez uma assistência 🤝"; logText = "fez uma assistência 🤝";
} }
// ── SOFRIDAS ────────────────────────────────────────────────────────────── // ── SOFRIDAS ──────────────────────────────────────────────────────────────
else if (action == "add_so") { else if (action == "add_so") {
stats["so"] = stats["so"]! + 1; stats["so"] = stats["so"]! + 1;
logText = "sofreu uma falta 🤕"; logText = "sofreu uma falta 🤕";
} }
// ══════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════
// STEAL — ROUBO DE BOLA // STEAL — ROUBO DE BOLA
// ══════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════
@@ -637,7 +948,6 @@ class PlacarController extends ChangeNotifier {
stats["il"] = stats["il"]! + 1; stats["il"] = stats["il"]! + 1;
logText = "intercetou um lançamento 🛑"; logText = "intercetou um lançamento 🛑";
} }
// ══════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════
// BLOCK — DESARME // BLOCK — DESARME
// ══════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════
@@ -648,7 +958,6 @@ class PlacarController extends ChangeNotifier {
stats["li"] = stats["li"]! + 1; stats["li"] = stats["li"]! + 1;
logText = "sofreu um desarme 🚫"; logText = "sofreu um desarme 🚫";
} }
// Ações independentes legadas // Ações independentes legadas
else if (action == "add_il") { else if (action == "add_il") {
stats["il"] = stats["il"]! + 1; stats["il"] = stats["il"]! + 1;
@@ -657,7 +966,6 @@ class PlacarController extends ChangeNotifier {
stats["li"] = stats["li"]! + 1; stats["li"] = stats["li"]! + 1;
logText = "teve o lançamento intercetado 🚫"; logText = "teve o lançamento intercetado 🚫";
} }
// ══════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════
// TURNOVER — PERDE DE BOLA E INFRAÇÕES // TURNOVER — PERDE DE BOLA E INFRAÇÕES
// ══════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════
@@ -680,7 +988,6 @@ class PlacarController extends ChangeNotifier {
stats["tov"] = stats["tov"]! + 1; // SOMA AO TURNOVER GERAL stats["tov"] = stats["tov"]! + 1; // SOMA AO TURNOVER GERAL
logText = "fez drible duplo 🏀"; logText = "fez drible duplo 🏀";
} }
// ── ANULAR FALTA ────────────────────────────────────────────────────────── // ── ANULAR FALTA ──────────────────────────────────────────────────────────
else if (action == "sub_foul") { else if (action == "sub_foul") {
if (stats["fls"]! > 0) stats["fls"] = stats["fls"]! - 1; if (stats["fls"]! > 0) stats["fls"] = stats["fls"]! - 1;
@@ -700,6 +1007,10 @@ class PlacarController extends ChangeNotifier {
_scheduleAutoSave(); _scheduleAutoSave();
notifyListeners(); notifyListeners();
_dispatchSyncAction('commit_stat', {
'action': action,
'player_data': playerData,
});
} }
@override @override
@@ -768,7 +1079,9 @@ class PlacarController extends ChangeNotifier {
}); });
// 1. Atualizar o Jogo // 1. Atualizar o Jogo
await supabase.from('games').update({ await supabase
.from('games')
.update({
'my_score': myScore, 'my_score': myScore,
'opponent_score': opponentScore, 'opponent_score': opponentScore,
'remaining_seconds': durationNotifier.value.inSeconds, 'remaining_seconds': durationNotifier.value.inSeconds,
@@ -781,7 +1094,8 @@ class PlacarController extends ChangeNotifier {
'top_rbs_name': topRbsName, 'top_rbs_name': topRbsName,
'mvp_name': mvpName, 'mvp_name': mvpName,
'play_by_play': playByPlay, 'play_by_play': playByPlay,
}).eq('id', gameId); })
.eq('id', gameId);
// 2. Preparar as Estatísticas dos Jogadores // 2. Preparar as Estatísticas dos Jogadores
List<Map<String, dynamic>> batchStats = []; List<Map<String, dynamic>> batchStats = [];
@@ -855,16 +1169,22 @@ class PlacarController extends ChangeNotifier {
await prefs.remove('backup_$gameId'); await prefs.remove('backup_$gameId');
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Guardado com Sucesso!'), content: Text('Guardado com Sucesso!'),
backgroundColor: Colors.green)); backgroundColor: Colors.green,
),
);
} }
} catch (e) { } catch (e) {
debugPrint("Erro ao gravar estatísticas: $e"); debugPrint("Erro ao gravar estatísticas: $e");
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erro ao guardar: $e'), content: Text('Erro ao guardar: $e'),
backgroundColor: Colors.red)); backgroundColor: Colors.red,
),
);
} }
} finally { } finally {
isSaving = false; isSaving = false;

View File

@@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:playmaker/controllers/active_team.dart';
class TeamController { class TeamController {
final _supabase = Supabase.instance.client; final _supabase = Supabase.instance.client;
@@ -65,10 +66,34 @@ class TeamController {
// 4. FAVORITAR // 4. FAVORITAR
Future<void> toggleFavorite(String teamId, bool currentStatus) async { Future<void> toggleFavorite(String teamId, bool currentStatus) async {
try { try {
await _supabase final userId = _supabase.auth.currentUser?.id;
.from('teams') if (userId == null) return;
.update({'is_favorite': !currentStatus})
.eq('id', teamId); // If we're marking this team as favorite, clear other favorites for this user
if (!currentStatus) {
await _supabase.from('teams').update({'is_favorite': false}).eq('user_id', userId);
}
// Toggle the chosen team's favorite flag
await _supabase.from('teams').update({'is_favorite': !currentStatus}).eq('id', teamId);
// If it became favorite, load its data and set global active team
if (!currentStatus) {
final teamData = await _supabase.from('teams').select().eq('id', teamId).maybeSingle();
if (teamData != null) {
final newTeam = ActiveTeam(
id: teamData['id'].toString(),
name: teamData['name'] ?? 'Desconhecido',
logo: teamData['image_url'],
wins: int.tryParse(teamData['wins']?.toString() ?? '0') ?? 0,
losses: int.tryParse(teamData['losses']?.toString() ?? '0') ?? 0,
draws: int.tryParse(teamData['draws']?.toString() ?? '0') ?? 0,
);
// Update global active team so UI reflects the favorite immediately
await saveGlobalTeam(newTeam);
}
}
} catch (e) { } catch (e) {
print("❌ Erro ao favoritar: $e"); print("❌ Erro ao favoritar: $e");
} }

View File

@@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; // Para as orientações
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:playmaker/classe/theme.dart'; import 'package:playmaker/classe/theme.dart';
import 'pages/login.dart'; import 'pages/login.dart';
import 'utils/session_manager.dart';
// Variável global para controlar o Tema // Variável global para controlar o Tema
final ValueNotifier<ThemeMode> themeNotifier = ValueNotifier(ThemeMode.system); final ValueNotifier<ThemeMode> themeNotifier = ValueNotifier(ThemeMode.system);
@@ -25,9 +26,41 @@ void main() async {
runApp(const MyApp()); runApp(const MyApp());
} }
class MyApp extends StatelessWidget { class MyApp extends StatefulWidget {
const MyApp({super.key}); const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) async {
super.didChangeAppLifecycleState(state);
// Quando a app for para background/terminar, se houver sessão em progresso, desliga a sessão
if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) {
final inProgress = await SessionManager.isInProgress();
if (inProgress) {
try {
await Supabase.instance.client.auth.signOut();
await SessionManager.clear();
} catch (_) {}
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ValueListenableBuilder<ThemeMode>( return ValueListenableBuilder<ThemeMode>(

View File

@@ -1,11 +1,16 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:playmaker/icons.dart/resaltosicon.dart'; import 'package:playmaker/icons.dart/resaltosicon.dart';
import 'package:playmaker/widgets/placar_widgets.dart'; // Mantém este import import 'package:playmaker/widgets/placar_widgets.dart'; // Mantém este import
import 'dart:math' as math; import 'package:playmaker/widgets/share_game_dialog.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../classe/theme.dart'; import '../classe/theme.dart';
import '../controllers/game_sharing_controller.dart';
import '../controllers/placar_controller.dart'; import '../controllers/placar_controller.dart';
import 'package:playmaker/zone_map_dialog.dart';
class PlacarPage extends StatefulWidget { class PlacarPage extends StatefulWidget {
final String gameId, myTeam, opponentTeam; final String gameId, myTeam, opponentTeam;
@@ -14,7 +19,7 @@ class PlacarPage extends StatefulWidget {
super.key, super.key,
required this.gameId, required this.gameId,
required this.myTeam, required this.myTeam,
required this.opponentTeam required this.opponentTeam,
}); });
@override @override
@@ -23,6 +28,13 @@ class PlacarPage extends StatefulWidget {
class _PlacarPageState extends State<PlacarPage> { class _PlacarPageState extends State<PlacarPage> {
late PlacarController _controller; late PlacarController _controller;
final GameSharingController _sharingController = GameSharingController();
String? _sessionId;
String? _shareCode;
StreamSubscription? _syncSubscription;
bool _isApplyingRemoteSync = false;
final Set<String> _appliedSyncEventIds = {};
final Map<String, DateTime> _lastAppliedActionAt = {};
@override @override
void initState() { void initState() {
@@ -36,18 +48,29 @@ class _PlacarPageState extends State<PlacarPage> {
gameId: widget.gameId, gameId: widget.gameId,
myTeam: widget.myTeam, myTeam: widget.myTeam,
opponentTeam: widget.opponentTeam, opponentTeam: widget.opponentTeam,
onSyncAction: _onLocalControllerSync,
); );
_controller.loadPlayers(); _controller.loadPlayers().then((_) => _initializeShareForGame());
} }
@override @override
void dispose() { void dispose() {
_syncSubscription?.cancel();
_controller.dispose(); _controller.dispose();
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
super.dispose(); super.dispose();
} }
Widget _buildFloatingFoulBtn(String label, Color color, String action, IconData icon, double left, double right, double top, double sf) { Widget _buildFloatingFoulBtn(
String label,
Color color,
String action,
IconData icon,
double left,
double right,
double top,
double sf,
) {
return Positioned( return Positioned(
top: top, top: top,
left: left > 0 ? left : null, left: left > 0 ? left : null,
@@ -59,7 +82,7 @@ class _PlacarPageState extends State<PlacarPage> {
child: CircleAvatar( child: CircleAvatar(
radius: 30 * sf, radius: 30 * sf,
backgroundColor: color.withOpacity(0.8), backgroundColor: color.withOpacity(0.8),
child: Icon(icon, color: Colors.white, size: 30 * sf) child: Icon(icon, color: Colors.white, size: 30 * sf),
), ),
), ),
child: Column( child: Column(
@@ -70,25 +93,48 @@ class _PlacarPageState extends State<PlacarPage> {
child: Icon(icon, color: Colors.white, size: 28 * sf), child: Icon(icon, color: Colors.white, size: 28 * sf),
), ),
SizedBox(height: 5 * sf), SizedBox(height: 5 * sf),
Text(label, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12 * sf)), Text(
label,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12 * sf,
),
),
], ],
), ),
), ),
); );
} }
Widget _buildCornerBtn({required String heroTag, required IconData icon, required Color color, required VoidCallback? onTap, required double size, bool isLoading = false}) { Widget _buildCornerBtn({
required String heroTag,
required IconData icon,
required Color color,
required VoidCallback? onTap,
required double size,
bool isLoading = false,
}) {
return SizedBox( return SizedBox(
width: size, width: size,
height: size, height: size,
child: FloatingActionButton( child: FloatingActionButton(
heroTag: heroTag, heroTag: heroTag,
backgroundColor: onTap == null ? Colors.grey : color, backgroundColor: onTap == null ? Colors.grey : color,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14 * (size / 50))), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14 * (size / 50)),
),
elevation: 5, elevation: 5,
onPressed: isLoading ? null : onTap, onPressed: isLoading ? null : onTap,
child: isLoading child: isLoading
? SizedBox(width: size * 0.45, height: size * 0.45, child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2.5)) ? SizedBox(
width: size * 0.45,
height: size * 0.45,
child: const CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2.5,
),
)
: Icon(icon, color: Colors.white, size: size * 0.55), : Icon(icon, color: Colors.white, size: size * 0.55),
), ),
); );
@@ -109,6 +155,305 @@ class _PlacarPageState extends State<PlacarPage> {
); );
} }
Future<void> _initializeShareForGame() async {
final activeSession = await _sharingController.getActiveSessionForGame(
widget.gameId,
);
if (activeSession == null) return;
_sessionId = activeSession['id']?.toString();
_shareCode = activeSession['share_code']?.toString();
// Partner info intentionally ignored because share label is hidden.
await _setupSyncListener();
setState(() {});
}
Future<String> _resolveUserName(String userId) async {
try {
final profile = await Supabase.instance.client
.from('profiles')
.select('username, full_name')
.eq('id', userId)
.single();
return profile['full_name']?.toString() ??
profile['username']?.toString() ??
'Parceiro';
} catch (_) {
return 'Parceiro';
}
}
Future<void> _setupSyncListener() async {
if (_sessionId == null) return;
_syncSubscription?.cancel();
_appliedSyncEventIds.clear();
await _seedAppliedSyncEventIds();
_syncSubscription = _sharingController
.listenToGameSyncOthers(_sessionId!)
.listen(
(dynamic event) {
final rows = <Map<String, dynamic>>[];
if (event is List && event.isNotEmpty) {
for (final item in event) {
final row = item as Map<String, dynamic>?;
if (row == null) continue;
final rowId = row['id']?.toString();
if (rowId == null || _appliedSyncEventIds.contains(rowId)) {
continue;
}
rows.add(Map<String, dynamic>.from(row));
}
} else if (event is Map<String, dynamic>) {
final row = Map<String, dynamic>.from(event);
final rowId = row['id']?.toString();
if (rowId != null && !_appliedSyncEventIds.contains(rowId)) {
rows.add(row);
}
}
if (rows.isEmpty) return;
rows.sort((a, b) {
final aTime = a['created_at']?.toString();
final bTime = b['created_at']?.toString();
if (aTime == null || bTime == null) return 0;
try {
return DateTime.parse(aTime).compareTo(DateTime.parse(bTime));
} catch (_) {
return 0;
}
});
for (final record in rows) {
final recordId = record['id']?.toString();
if (recordId == null || _appliedSyncEventIds.contains(recordId)) {
continue;
}
// Skip if this event is older or equal to the last applied event of the same type
final actionType = record['action_type']?.toString();
DateTime? recordTime;
try {
final created = record['created_at']?.toString();
if (created != null) recordTime = DateTime.parse(created);
} catch (_) {
recordTime = null;
}
if (actionType != null && recordTime != null) {
final last = _lastAppliedActionAt[actionType];
if (last != null && !recordTime.isAfter(last)) {
_appliedSyncEventIds.add(recordId);
continue;
}
}
_appliedSyncEventIds.add(recordId);
if (actionType != null && recordTime != null) {
_lastAppliedActionAt[actionType] = recordTime;
}
print(
"🔄 Evento remoto recebido: ${record['action_type']} - ${record['action_data']}",
);
_applyRemoteSyncEvent(record);
}
},
onError: (error) {
print("⚠️ Erro no stream de sync: $error");
},
);
}
Future<void> _seedAppliedSyncEventIds() async {
if (_sessionId == null) return;
try {
final response = await Supabase.instance.client
.from('game_sync_events')
.select('id')
.eq('session_id', _sessionId!)
.order('created_at', ascending: true);
for (final item in response as List) {
final id = item['id']?.toString();
if (id != null) {
_appliedSyncEventIds.add(id);
}
}
} catch (e) {
print('⚠️ Erro ao semear eventos históricos de sync: $e');
}
}
void _handleSyncRecords(Map<String, dynamic> record) {
// Mantido apenas como fallback, mas a escuta principal usa listenToGameSyncOthers.
_applyRemoteSyncEvent(record);
}
void _onLocalControllerSync(
String actionType,
Map<String, dynamic> actionData,
) {
if (_sessionId == null || _isApplyingRemoteSync) return;
print("📤 Enviando sync action local: $actionType -> $actionData (is_running: ${actionData['is_running']})");
_sharingController.sendSyncEvent(_sessionId!, actionType, actionData);
}
void _applyRemoteSyncEvent(Map<String, dynamic> record) {
final actionType = record['action_type']?.toString();
final actionData = Map<String, dynamic>.from(record['action_data'] ?? {});
// Aplicar estado remoto do timer em TODAS as ações
final remoteSeconds = int.tryParse(
actionData['remaining_seconds']?.toString() ?? '',
);
final remoteIsRunning = actionData['is_running'] == true;
if (remoteSeconds != null) {
_controller.durationNotifier.value = Duration(seconds: remoteSeconds);
}
final remoteQuarter = int.tryParse(
actionData['current_quarter']?.toString() ?? '',
);
if (remoteQuarter != null) {
_controller.currentQuarter = remoteQuarter;
}
final remoteMyFouls = int.tryParse(
actionData['my_fouls']?.toString() ?? '',
);
if (remoteMyFouls != null) {
_controller.myFouls = remoteMyFouls;
}
final remoteOpponentFouls = int.tryParse(
actionData['opponent_fouls']?.toString() ?? '',
);
if (remoteOpponentFouls != null) {
_controller.opponentFouls = remoteOpponentFouls;
}
final remoteMyTimeoutsUsed = int.tryParse(
actionData['my_timeouts_used']?.toString() ?? '',
);
if (remoteMyTimeoutsUsed != null) {
_controller.myTimeoutsUsed = remoteMyTimeoutsUsed;
}
final remoteOpponentTimeoutsUsed = int.tryParse(
actionData['opponent_timeouts_used']?.toString() ?? '',
);
if (remoteOpponentTimeoutsUsed != null) {
_controller.opponentTimeoutsUsed = remoteOpponentTimeoutsUsed;
}
if (remoteIsRunning != _controller.isRunning) {
_isApplyingRemoteSync = true;
_controller.applyRemoteTimerState(remoteIsRunning);
_isApplyingRemoteSync = false;
}
if (actionType == 'toggle_timer') {
setState(() {});
} else if (actionType == 'commit_stat') {
final action = actionData['action']?.toString() ?? '';
final playerData = actionData['player_data']?.toString() ?? '';
if (action.isNotEmpty && playerData.isNotEmpty) {
_isApplyingRemoteSync = true;
_controller.commitStat(action, playerData);
_isApplyingRemoteSync = false;
}
} else if (actionType == 'register_foul') {
final committer = actionData['committer']?.toString() ?? '';
final foulType = actionData['foulType']?.toString() ?? '';
final victim = actionData['victim']?.toString() ?? '';
if (committer.isNotEmpty && foulType.isNotEmpty) {
_isApplyingRemoteSync = true;
_controller.registerFoul(committer, foulType, victim);
_isApplyingRemoteSync = false;
}
} else if (actionType == 'subbing') {
final action = actionData['action']?.toString() ?? '';
final courtPlayer = actionData['court_player']?.toString() ?? '';
final isOpponent = actionData['is_opponent'] == true;
if (action.isNotEmpty && courtPlayer.isNotEmpty) {
_isApplyingRemoteSync = true;
_controller.handleSubbing(context, action, courtPlayer, isOpponent);
_isApplyingRemoteSync = false;
}
} else if (actionType == 'swap_players') {
final dragged = actionData['dragged']?.toString() ?? '';
final target = actionData['target']?.toString() ?? '';
if (dragged.isNotEmpty && target.isNotEmpty) {
_isApplyingRemoteSync = true;
_controller.swapCourtPlayers(dragged, target);
_isApplyingRemoteSync = false;
}
} else if (actionType == 'use_timeout') {
final isOpponent = actionData['is_opponent'] == true;
_isApplyingRemoteSync = true;
_controller.useTimeout(isOpponent);
_isApplyingRemoteSync = false;
} else if (actionType == 'add_shot') {
final shot = actionData['shot'] as Map<String, dynamic>?;
if (shot != null) {
_isApplyingRemoteSync = true;
_controller.applyRemoteAddShot(Map<String, dynamic>.from(shot));
_isApplyingRemoteSync = false;
}
}
}
void _handleTimerButton(BuildContext context) {
_controller.toggleTimer(context);
}
Future<void> _openShareDialog(BuildContext context) async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (ctx) => ShareGameDialog(
gameId: widget.gameId,
controller: _sharingController,
activeSessionId: _sessionId,
activeShareCode: _shareCode,
),
);
if (result != null) {
_sessionId = result['session_id']?.toString();
_shareCode = result['share_code']?.toString();
await _setupSyncListener();
setState(() {});
}
}
Future<void> _openJoinDialog(BuildContext context) async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (ctx) => JoinGameDialog(controller: _sharingController),
);
if (result != null) {
_sessionId = result['session_id']?.toString();
_shareCode = result['share_code']?.toString();
await _setupSyncListener();
setState(() {});
}
}
Widget _buildShareStatus(double sf) {
// Não mostrar qualquer indicação de partilha (nome ou código).
return const SizedBox.shrink();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final double wScreen = MediaQuery.of(context).size.width; final double wScreen = MediaQuery.of(context).size.width;
@@ -126,7 +471,15 @@ class _PlacarPageState extends State<PlacarPage> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text("PREPARANDO O PAVILHÃO", style: TextStyle(color: Colors.white24, fontSize: 45 * sf, fontWeight: FontWeight.bold, letterSpacing: 2)), Text(
"PREPARANDO O PAVILHÃO",
style: TextStyle(
color: Colors.white24,
fontSize: 45 * sf,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
SizedBox(height: 35 * sf), SizedBox(height: 35 * sf),
const CircularProgressIndicator(color: Colors.orangeAccent), const CircularProgressIndicator(color: Colors.orangeAccent),
], ],
@@ -138,14 +491,21 @@ class _PlacarPageState extends State<PlacarPage> {
return Scaffold( return Scaffold(
backgroundColor: AppTheme.placarBackground, backgroundColor: AppTheme.placarBackground,
body: SafeArea( body: SafeArea(
top: false, bottom: false, top: false,
bottom: false,
child: IgnorePointer( child: IgnorePointer(
ignoring: _controller.isSaving, ignoring: _controller.isSaving,
child: Stack( child: Stack(
children: [ children: [
Container( Container(
margin: EdgeInsets.only(left: 65 * sf, right: 65 * sf, bottom: 55 * sf), margin: EdgeInsets.only(
decoration: BoxDecoration(border: Border.all(color: Colors.white, width: 2.5)), left: 65 * sf,
right: 65 * sf,
bottom: 55 * sf,
),
decoration: BoxDecoration(
border: Border.all(color: Colors.white, width: 2.5),
),
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final w = constraints.maxWidth; final w = constraints.maxWidth;
@@ -155,70 +515,254 @@ class _PlacarPageState extends State<PlacarPage> {
GestureDetector( GestureDetector(
onTapDown: (details) { onTapDown: (details) {
if (_controller.isSelectingShotLocation) { if (_controller.isSelectingShotLocation) {
bool isMake = _controller.pendingAction?.startsWith("add_pts_") ?? false; bool isMake =
_controller.pendingAction?.startsWith(
"add_pts_",
) ??
false;
String? pData = _controller.pendingPlayerId; String? pData = _controller.pendingPlayerId;
_controller.registerShotLocation(context, details.localPosition, Size(w, h)); _controller.registerShotLocation(
context,
details.localPosition,
Size(w, h),
);
if (isMake && pData != null) { if (isMake && pData != null) {
bool isOpp = pData.startsWith("player_opp_"); bool isOpp = pData.startsWith(
String pId = pData.replaceAll("player_my_", "").replaceAll("player_opp_", ""); "player_opp_",
showAssistDialog(context, _controller, isOpp, pId, sf); );
String pId = pData
.replaceAll("player_my_", "")
.replaceAll("player_opp_", "");
showAssistDialog(
context,
_controller,
isOpp,
pId,
sf,
);
} }
} }
}, },
child: Container( child: Container(
decoration: const BoxDecoration( decoration: const BoxDecoration(
image: DecorationImage( image: DecorationImage(
image: AssetImage('assets/campo.png'), image: AssetImage('assets/campone.png'),
fit: BoxFit.fill, fit: BoxFit.fill,
), ),
), ),
), ),
), ),
if (!_controller.isSelectingShotLocation && _controller.myCourt.length >= 5 && _controller.oppCourt.length >= 5) ...[ if (!_controller.isSelectingShotLocation &&
Positioned(top: h * 0.25, left: w * 0.02, child: PlayerCourtCard(controller: _controller, playerId: _controller.myCourt[0], isOpponent: false, sf: sf)), _controller.myCourt.length >= 5 &&
Positioned(top: h * 0.68, left: w * 0.02, child: PlayerCourtCard(controller: _controller, playerId: _controller.myCourt[1], isOpponent: false, sf: sf)), _controller.oppCourt.length >= 5) ...[
Positioned(top: h * 0.45, left: w * 0.25, child: PlayerCourtCard(controller: _controller, playerId: _controller.myCourt[2], isOpponent: false, sf: sf)), Positioned(
Positioned(top: h * 0.15, left: w * 0.20, child: PlayerCourtCard(controller: _controller, playerId: _controller.myCourt[3], isOpponent: false, sf: sf)), top: h * 0.25,
Positioned(top: h * 0.80, left: w * 0.20, child: PlayerCourtCard(controller: _controller, playerId: _controller.myCourt[4], isOpponent: false, sf: sf)), left: w * 0.02,
child: PlayerCourtCard(
controller: _controller,
playerId: _controller.myCourt[0],
isOpponent: false,
sf: sf,
),
),
Positioned(
top: h * 0.68,
left: w * 0.02,
child: PlayerCourtCard(
controller: _controller,
playerId: _controller.myCourt[1],
isOpponent: false,
sf: sf,
),
),
Positioned(
top: h * 0.45,
left: w * 0.25,
child: PlayerCourtCard(
controller: _controller,
playerId: _controller.myCourt[2],
isOpponent: false,
sf: sf,
),
),
Positioned(
top: h * 0.15,
left: w * 0.20,
child: PlayerCourtCard(
controller: _controller,
playerId: _controller.myCourt[3],
isOpponent: false,
sf: sf,
),
),
Positioned(
top: h * 0.80,
left: w * 0.20,
child: PlayerCourtCard(
controller: _controller,
playerId: _controller.myCourt[4],
isOpponent: false,
sf: sf,
),
),
Positioned(top: h * 0.25, right: w * 0.02, child: PlayerCourtCard(controller: _controller, playerId: _controller.oppCourt[0], isOpponent: true, sf: sf)), Positioned(
Positioned(top: h * 0.68, right: w * 0.02, child: PlayerCourtCard(controller: _controller, playerId: _controller.oppCourt[1], isOpponent: true, sf: sf)), top: h * 0.25,
Positioned(top: h * 0.45, right: w * 0.25, child: PlayerCourtCard(controller: _controller, playerId: _controller.oppCourt[2], isOpponent: true, sf: sf)), right: w * 0.02,
Positioned(top: h * 0.15, right: w * 0.20, child: PlayerCourtCard(controller: _controller, playerId: _controller.oppCourt[3], isOpponent: true, sf: sf)), child: PlayerCourtCard(
Positioned(top: h * 0.80, right: w * 0.20, child: PlayerCourtCard(controller: _controller, playerId: _controller.oppCourt[4], isOpponent: true, sf: sf)), controller: _controller,
playerId: _controller.oppCourt[0],
isOpponent: true,
sf: sf,
),
),
Positioned(
top: h * 0.68,
right: w * 0.02,
child: PlayerCourtCard(
controller: _controller,
playerId: _controller.oppCourt[1],
isOpponent: true,
sf: sf,
),
),
Positioned(
top: h * 0.45,
right: w * 0.25,
child: PlayerCourtCard(
controller: _controller,
playerId: _controller.oppCourt[2],
isOpponent: true,
sf: sf,
),
),
Positioned(
top: h * 0.15,
right: w * 0.20,
child: PlayerCourtCard(
controller: _controller,
playerId: _controller.oppCourt[3],
isOpponent: true,
sf: sf,
),
),
Positioned(
top: h * 0.80,
right: w * 0.20,
child: PlayerCourtCard(
controller: _controller,
playerId: _controller.oppCourt[4],
isOpponent: true,
sf: sf,
),
),
], ],
if (!_controller.isSelectingShotLocation) ...[ if (!_controller.isSelectingShotLocation) ...[
_buildFloatingFoulBtn("FALTA +", AppTheme.actionPoints, "add_foul", Icons.sports, w * 0.39, 0.0, h * 0.31, sf), _buildFloatingFoulBtn(
_buildFloatingFoulBtn("FALTA -", AppTheme.actionMiss, "sub_foul", Icons.block, 0.0, w * 0.39, h * 0.31, sf), "FALTA +",
AppTheme.actionPoints,
"add_foul",
Icons.sports,
w * 0.39,
0.0,
h * 0.31,
sf,
),
_buildFloatingFoulBtn(
"FALTA -",
AppTheme.actionMiss,
"sub_foul",
Icons.block,
0.0,
w * 0.39,
h * 0.31,
sf,
),
], ],
if (!_controller.isSelectingShotLocation) if (!_controller.isSelectingShotLocation)
Positioned( Positioned(
top: (h * 0.32) + (40 * sf), top: (h * 0.32) + (40 * sf),
left: 0, right: 0, left: 0,
right: 0,
child: Center( child: Center(
child: GestureDetector( child: GestureDetector(
onTap: () => _controller.toggleTimer(context), onTap: () => _handleTimerButton(context),
child: CircleAvatar( child: CircleAvatar(
radius: 68 * sf, radius: 68 * sf,
backgroundColor: Colors.grey.withOpacity(0.5), backgroundColor: Colors.grey.withOpacity(
child: Icon(_controller.isRunning ? Icons.pause : Icons.play_arrow, color: Colors.white, size: 58 * sf) 0.5,
),
child: Icon(
_controller.isRunning
? Icons.pause
: Icons.play_arrow,
color: Colors.white,
size: 58 * sf,
), ),
), ),
), ),
), ),
Positioned(top: 0, left: 0, right: 0, child: Center(child: TopScoreboard(controller: _controller, sf: sf))), ),
Positioned(
top: 0,
left: 0,
right: 0,
child: Center(
child: TopScoreboard(
controller: _controller,
sf: sf,
),
),
),
if (_sessionId != null)
Positioned(
top: 90 * sf,
left: 0,
right: 0,
child: Center(child: _buildShareStatus(sf)),
),
if (!_controller.isSelectingShotLocation) Positioned(bottom: -10 * sf, left: 0, right: 0, child: ActionButtonsPanel(controller: _controller, sf: sf)), if (!_controller.isSelectingShotLocation)
Positioned(
bottom: -10 * sf,
left: 0,
right: 0,
child: ActionButtonsPanel(
controller: _controller,
sf: sf,
),
),
if (_controller.isSelectingShotLocation) if (_controller.isSelectingShotLocation)
Positioned( Positioned(
top: h * 0.4, left: 0, right: 0, top: h * 0.4,
left: 0,
right: 0,
child: Center( child: Center(
child: Container( child: Container(
padding: EdgeInsets.symmetric(horizontal: 35 * sf, vertical: 18 * sf), padding: EdgeInsets.symmetric(
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(11 * sf), border: Border.all(color: Colors.white, width: 1.5 * sf)), horizontal: 35 * sf,
child: Text("TOQUE NO CAMPO PARA MARCAR O LOCAL", style: TextStyle(color: Colors.white, fontSize: 22 * sf, fontWeight: FontWeight.bold)), vertical: 18 * sf,
),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(
11 * sf,
),
border: Border.all(
color: Colors.white,
width: 1.5 * sf,
),
),
child: Text(
"TOQUE NO CAMPO PARA MARCAR O LOCAL",
style: TextStyle(
color: Colors.white,
fontSize: 22 * sf,
fontWeight: FontWeight.bold,
),
),
), ),
), ),
), ),
@@ -229,30 +773,77 @@ class _PlacarPageState extends State<PlacarPage> {
), ),
Positioned( Positioned(
top: 50 * sf, left: 12 * sf, top: 50 * sf,
left: 12 * sf,
child: Column( child: Column(
children: [ children: [
_buildCornerBtn(heroTag: 'btn_save_exit', icon: Icons.save_alt, color: AppTheme.oppTeamRed, size: cornerBtnSize, isLoading: _controller.isSaving, onTap: () async { await _controller.saveGameStats(context); if (context.mounted) Navigator.pop(context); }), _buildCornerBtn(
heroTag: 'btn_save_exit',
icon: Icons.save_alt,
color: AppTheme.oppTeamRed,
size: cornerBtnSize,
isLoading: _controller.isSaving,
onTap: () async {
await _controller.saveGameStats(context);
if (context.mounted) Navigator.pop(context);
},
),
SizedBox(height: 10 * sf), SizedBox(height: 10 * sf),
_buildCornerBtn(heroTag: 'btn_history', icon: Icons.history, color: Colors.blueGrey, size: cornerBtnSize, onTap: () => showDialog(context: context, builder: (ctx) => PlayByPlayDialog(controller: _controller))), _buildCornerBtn(
heroTag: 'btn_history',
icon: Icons.history,
color: Colors.blueGrey,
size: cornerBtnSize,
onTap: () => showDialog(
context: context,
builder: (ctx) =>
PlayByPlayDialog(controller: _controller),
),
),
], ],
), ),
), ),
Positioned( Positioned(
top: 50 * sf, right: 12 * sf, top: 50 * sf,
right: 12 * sf,
child: Column( child: Column(
children: [ children: [
_buildCornerBtn(heroTag: 'btn_heatmap', icon: Icons.local_fire_department, color: Colors.orange.shade800, size: cornerBtnSize, onTap: () => _showHeatmap(context)), _buildCornerBtn(
heroTag: 'btn_heatmap',
icon: Icons.local_fire_department,
color: Colors.orange.shade800,
size: cornerBtnSize,
onTap: () => _showHeatmap(context),
),
SizedBox(height: 10 * sf), SizedBox(height: 10 * sf),
_buildCornerBtn(heroTag: 'btn_boxscore', icon: Icons.table_chart, color: Colors.indigo, size: cornerBtnSize, onTap: () => showDialog(context: context, builder: (ctx) => BoxScoreDialog(controller: _controller, sf: sf))), _buildCornerBtn(
heroTag: 'btn_boxscore',
icon: Icons.table_chart,
color: Colors.indigo,
size: cornerBtnSize,
onTap: () => showDialog(
context: context,
builder: (ctx) =>
BoxScoreDialog(controller: _controller, sf: sf),
),
),
SizedBox(height: 10 * sf),
_buildCornerBtn(
heroTag: 'btn_share',
icon: Icons.share,
color: Colors.green,
size: cornerBtnSize,
onTap: () => _openShareDialog(context),
),
], ],
), ),
), ),
// BOTÕES INFERIORES: SUBSTITUIÇÕES E TIMEOUTS // BOTÕES INFERIORES: SUBSTITUIÇÕES E TIMEOUTS
Positioned( Positioned(
bottom: 55 * sf, left: 12 * sf, bottom: 55 * sf,
left: 12 * sf,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -271,13 +862,22 @@ class _PlacarPageState extends State<PlacarPage> {
), ),
), ),
SizedBox(height: 12 * sf), SizedBox(height: 12 * sf),
_buildCornerBtn(heroTag: 'btn_to_home', icon: Icons.timer, color: AppTheme.myTeamBlue, size: cornerBtnSize, onTap: _controller.myTimeoutsUsed >= 3 ? null : () => _controller.useTimeout(false)), _buildCornerBtn(
heroTag: 'btn_to_home',
icon: Icons.timer,
color: AppTheme.myTeamBlue,
size: cornerBtnSize,
onTap: _controller.myTimeoutsUsed >= 3
? null
: () => _controller.useTimeout(false),
),
], ],
), ),
), ),
Positioned( Positioned(
bottom: 55 * sf, right: 12 * sf, bottom: 55 * sf,
right: 12 * sf,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -296,13 +896,28 @@ class _PlacarPageState extends State<PlacarPage> {
), ),
), ),
SizedBox(height: 12 * sf), SizedBox(height: 12 * sf),
_buildCornerBtn(heroTag: 'btn_to_away', icon: Icons.timer, color: AppTheme.oppTeamRed, size: cornerBtnSize, onTap: _controller.opponentTimeoutsUsed >= 3 ? null : () => _controller.useTimeout(true)), _buildCornerBtn(
heroTag: 'btn_to_away',
icon: Icons.timer,
color: AppTheme.oppTeamRed,
size: cornerBtnSize,
onTap: _controller.opponentTimeoutsUsed >= 3
? null
: () => _controller.useTimeout(true),
),
], ],
), ),
), ),
if (_controller.isSaving) if (_controller.isSaving)
Positioned.fill(child: Container(color: Colors.black.withOpacity(0.4), child: const Center(child: CircularProgressIndicator(color: Colors.white)))), Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.4),
child: const Center(
child: CircularProgressIndicator(color: Colors.white),
),
),
),
], ],
), ),
), ),

View File

@@ -1,11 +1,12 @@
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/game_model.dart'; import '../models/game_model.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:playmaker/pages/PlacarPage.dart'; import 'package:playmaker/pages/PlacarPage.dart';
import 'package:playmaker/widgets/share_game_dialog.dart';
import 'package:playmaker/classe/theme.dart'; import 'package:playmaker/classe/theme.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../controllers/team_controller.dart'; import '../controllers/team_controller.dart';
import '../controllers/game_controller.dart'; import '../controllers/game_controller.dart';
import '../controllers/game_sharing_controller.dart';
import '../utils/size_extension.dart'; import '../utils/size_extension.dart';
import 'pdf_export_service.dart'; import 'pdf_export_service.dart';
@@ -281,6 +282,7 @@ class _CreateGameDialogManualState extends State<CreateGameDialogManual> {
late TextEditingController _seasonController; late TextEditingController _seasonController;
final TextEditingController _myTeamController = TextEditingController(); final TextEditingController _myTeamController = TextEditingController();
final TextEditingController _opponentController = TextEditingController(); final TextEditingController _opponentController = TextEditingController();
final GameSharingController _sharingController = GameSharingController();
bool _isLoading = false; bool _isLoading = false;
@override @override
@@ -318,6 +320,7 @@ class _CreateGameDialogManualState extends State<CreateGameDialogManual> {
), ),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text('CANCELAR', style: TextStyle(fontSize: 14 * widget.sf, color: Colors.grey))), TextButton(onPressed: () => Navigator.pop(context), child: Text('CANCELAR', style: TextStyle(fontSize: 14 * widget.sf, color: Colors.grey))),
TextButton(onPressed: _isLoading ? null : () async => await _joinRoom(), child: Text('ENTRAR NA SALA', style: TextStyle(fontSize: 14 * widget.sf))),
ElevatedButton( 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)), 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 { onPressed: _isLoading ? null : () async {
@@ -337,6 +340,40 @@ class _CreateGameDialogManualState extends State<CreateGameDialogManual> {
); );
} }
Future<void> _joinRoom() async {
print("🚪 Abrindo diálogo para entrar na sala");
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (ctx) => JoinGameDialog(controller: _sharingController),
);
print("📦 Resultado do diálogo: $result");
if (result == null) {
print("❌ Resultado nulo, cancelado");
return;
}
final gameData = result['game'] as Map<String, dynamic>?;
print("🎮 Game data: $gameData");
if (gameData == null) {
print("❌ Game data nula");
return;
}
final String gameId = gameData['id']?.toString() ?? '';
final String myTeam = gameData['my_team']?.toString() ?? _myTeamController.text;
final String opponentTeam = gameData['opponent_team']?.toString() ?? _opponentController.text;
print("🆔 Game ID: $gameId, My Team: $myTeam, Opponent: $opponentTeam");
if (gameId.isNotEmpty && context.mounted) {
print("➡️ Navegando para PlacarPage");
Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(builder: (context) => PlacarPage(gameId: gameId, myTeam: myTeam, opponentTeam: opponentTeam)));
} else {
print("❌ Game ID vazio ou contexto não montado");
}
}
Widget _buildSearch(BuildContext context, String label, TextEditingController controller) { Widget _buildSearch(BuildContext context, String label, TextEditingController controller) {
return StreamBuilder<List<Map<String, dynamic>>>( return StreamBuilder<List<Map<String, dynamic>>>(
stream: widget.teamController.teamsStream, stream: widget.teamController.teamsStream,

File diff suppressed because it is too large Load Diff

View File

@@ -33,13 +33,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadUserAvatar(); _loadUserAvatar();
} }
String _prefsKey(String key) {
final userId = supabase.auth.currentUser?.id ?? 'guest';
return '${key}_$userId';
}
Future<void> _loadUserAvatar() async { Future<void> _loadUserAvatar() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final savedUrl = prefs.getString('meu_avatar_guardado'); final savedUrl = prefs.getString(_prefsKey('meu_avatar_guardado'));
if (mounted) { if (mounted) {
setState(() { setState(() {
if (savedUrl != null) _uploadedImageUrl = savedUrl; if (savedUrl != null) {
_uploadedImageUrl = savedUrl;
} else {
_uploadedImageUrl = null;
}
_isMemoryLoaded = true; _isMemoryLoaded = true;
}); });
} }
@@ -58,7 +67,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final urlDoSupabase = data['avatar_url']; final urlDoSupabase = data['avatar_url'];
if (urlDoSupabase != savedUrl) { if (urlDoSupabase != savedUrl) {
await prefs.setString('meu_avatar_guardado', urlDoSupabase); await prefs.setString(_prefsKey('meu_avatar_guardado'), urlDoSupabase);
setState(() { setState(() {
_uploadedImageUrl = urlDoSupabase; _uploadedImageUrl = urlDoSupabase;
}); });
@@ -104,7 +113,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
}); });
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString('meu_avatar_guardado', publicUrl); await prefs.setString(_prefsKey('meu_avatar_guardado'), publicUrl);
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -354,13 +363,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
onPressed: () async { onPressed: () async {
// 👇 AGORA LIMPA A EQUIPA E TUDO DA MEMÓRIA AO SAIR! // 👇 AGORA LIMPA A EQUIPA E TUDO DA MEMÓRIA AO SAIR!
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove('meu_avatar_guardado'); final userId = supabase.auth.currentUser?.id;
await prefs.remove('last_team_id'); if (userId != null) {
await prefs.remove('last_team_name'); await prefs.remove(_prefsKey('meu_avatar_guardado'));
await prefs.remove('last_team_logo'); await prefs.remove(_prefsKey('last_team_id'));
await prefs.remove('last_team_wins'); await prefs.remove(_prefsKey('last_team_name'));
await prefs.remove('last_team_losses'); await prefs.remove(_prefsKey('last_team_logo'));
await prefs.remove('last_team_draws'); await prefs.remove(_prefsKey('last_team_wins'));
await prefs.remove(_prefsKey('last_team_losses'));
await prefs.remove(_prefsKey('last_team_draws'));
}
await Supabase.instance.client.auth.signOut(); await Supabase.instance.client.auth.signOut();
if (ctx.mounted) { if (ctx.mounted) {

View File

@@ -4,10 +4,20 @@ import 'package:playmaker/classe/theme.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../controllers/team_controller.dart'; import '../controllers/team_controller.dart';
import '../controllers/active_team.dart';
import '../utils/size_extension.dart'; import '../utils/size_extension.dart';
class StatusPage extends StatefulWidget { class StatusPage extends StatefulWidget {
const StatusPage({super.key}); final String? initialTeamId;
final String initialTeamName;
final String? initialTeamLogo;
const StatusPage({
super.key,
this.initialTeamId,
this.initialTeamName = "Selecionar Equipa",
this.initialTeamLogo,
});
@override @override
State<StatusPage> createState() => _StatusPageState(); State<StatusPage> createState() => _StatusPageState();
@@ -17,9 +27,9 @@ class _StatusPageState extends State<StatusPage> {
final TeamController _teamController = TeamController(); final TeamController _teamController = TeamController();
final _supabase = Supabase.instance.client; final _supabase = Supabase.instance.client;
String? _selectedTeamId; late String? _selectedTeamId;
String _selectedTeamName = "Selecionar Equipa"; late String _selectedTeamName;
String? _selectedTeamLogo; late String? _selectedTeamLogo;
String _sortColumn = 'pts'; String _sortColumn = 'pts';
bool _isAscending = false; bool _isAscending = false;
@@ -27,34 +37,87 @@ class _StatusPageState extends State<StatusPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadSelectedTeam(); _selectedTeamId = widget.initialTeamId;
_selectedTeamName = widget.initialTeamName;
_selectedTeamLogo = widget.initialTeamLogo;
// Se não vieram parâmetros da HomeScreen, tenta carregar do SharedPreferences
if (_selectedTeamId == null) {
_loadSelectedTeamFallback();
} }
Future<void> _loadSelectedTeam() async { // Listen to global active team changes (e.g., when user marks favorite)
final prefs = await SharedPreferences.getInstance(); globalActiveTeam.addListener(_onGlobalActiveTeamChanged);
final savedId = prefs.getString('last_team_id');
if (savedId != null && mounted) { // Se já existe um globalActiveTeam no momento da abertura da página, aplica-o
final atNow = globalActiveTeam.value;
if (atNow != null) {
_selectedTeamId = atNow.id;
_selectedTeamName = atNow.name;
_selectedTeamLogo = atNow.logo;
}
}
void _onGlobalActiveTeamChanged() {
final at = globalActiveTeam.value;
if (!mounted) return;
// Atualiza sempre para a equipa ativa global (favorita). Isto força a Status
// a mostrar a equipa marcada como favorita assim que o utilizador a define.
if (at != null) {
setState(() { setState(() {
_selectedTeamId = savedId; _selectedTeamId = at.id;
_selectedTeamName = prefs.getString('last_team_name') ?? "Selecionar Equipa"; _selectedTeamName = at.name;
_selectedTeamLogo = prefs.getString('last_team_logo'); _selectedTeamLogo = at.logo;
}); });
} }
} }
Future<void> _saveSelectedTeam() async { String _prefsKey(String key) {
final prefs = await SharedPreferences.getInstance(); final userId = _supabase.auth.currentUser?.id ?? 'guest';
if (_selectedTeamId != null) { return '${key}_$userId';
await prefs.setString('last_team_id', _selectedTeamId!); }
await prefs.setString('last_team_name', _selectedTeamName);
if (_selectedTeamLogo != null && _selectedTeamLogo!.isNotEmpty) { @override
await prefs.setString('last_team_logo', _selectedTeamLogo!); void didUpdateWidget(StatusPage oldWidget) {
} else { super.didUpdateWidget(oldWidget);
await prefs.remove('last_team_logo'); // Quando a HomeScreen muda a equipa, a StatusPage atualiza automaticamente
if (widget.initialTeamId != oldWidget.initialTeamId) {
setState(() {
_selectedTeamId = widget.initialTeamId;
_selectedTeamName = widget.initialTeamName;
_selectedTeamLogo = widget.initialTeamLogo;
});
} }
} }
/// Fallback: só usado se a HomeScreen não passou nenhuma equipa ainda
Future<void> _loadSelectedTeamFallback() async {
final prefs = await SharedPreferences.getInstance();
final savedId = prefs.getString(_prefsKey('last_team_id'));
if (savedId != null && mounted) {
setState(() {
_selectedTeamId = savedId;
_selectedTeamName = prefs.getString(_prefsKey('last_team_name')) ?? "Selecionar Equipa";
_selectedTeamLogo = prefs.getString(_prefsKey('last_team_logo'));
});
}
}
/// Guarda a equipa selecionada localmente (quando muda dentro da StatusPage)
Future<void> _saveSelectedTeamLocally() async {
final prefs = await SharedPreferences.getInstance();
if (_selectedTeamId != null) {
await prefs.setString(_prefsKey('last_team_id'), _selectedTeamId!);
await prefs.setString(_prefsKey('last_team_name'), _selectedTeamName);
if (_selectedTeamLogo != null && _selectedTeamLogo!.isNotEmpty) {
await prefs.setString(_prefsKey('last_team_logo'), _selectedTeamLogo!);
} else {
await prefs.remove(_prefsKey('last_team_logo'));
}
}
// Também guarda no Supabase
final userId = _supabase.auth.currentUser?.id; final userId = _supabase.auth.currentUser?.id;
if (userId != null && _selectedTeamId != null) { if (userId != null && _selectedTeamId != null) {
try { try {
@@ -85,7 +148,9 @@ class _StatusPageState extends State<StatusPage> {
color: bgColor, color: bgColor,
borderRadius: BorderRadius.circular(15 * context.sf), borderRadius: BorderRadius.circular(15 * context.sf),
border: Border.all(color: Colors.grey.withOpacity(0.2)), border: Border.all(color: Colors.grey.withOpacity(0.2)),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 5)] boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 5)
],
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -98,14 +163,23 @@ class _StatusPageState extends State<StatusPage> {
width: 24 * context.sf, width: 24 * context.sf,
height: 24 * context.sf, height: 24 * context.sf,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (context, url) => Icon(Icons.shield, color: AppTheme.primaryRed, size: 24 * context.sf), placeholder: (context, url) => Icon(Icons.shield,
errorWidget: (context, url, error) => Icon(Icons.shield, color: AppTheme.primaryRed, size: 24 * context.sf), color: AppTheme.primaryRed,
size: 24 * context.sf),
errorWidget: (context, url, error) => Icon(
Icons.shield,
color: AppTheme.primaryRed,
size: 24 * context.sf),
), ),
) )
: Icon(Icons.shield, color: AppTheme.primaryRed, size: 24 * context.sf), : Icon(Icons.shield,
color: AppTheme.primaryRed, size: 24 * context.sf),
SizedBox(width: 10 * context.sf), SizedBox(width: 10 * context.sf),
Text(_selectedTeamName, style: TextStyle(fontSize: 16 * context.sf, fontWeight: FontWeight.bold, color: textColor)) Text(_selectedTeamName,
style: TextStyle(
fontSize: 16 * context.sf,
fontWeight: FontWeight.bold,
color: textColor)),
]), ]),
Icon(Icons.arrow_drop_down, color: textColor), Icon(Icons.arrow_drop_down, color: textColor),
], ],
@@ -116,59 +190,124 @@ class _StatusPageState extends State<StatusPage> {
Expanded( Expanded(
child: _selectedTeamId == null child: _selectedTeamId == null
? Center(child: Text("Seleciona uma equipa acima.", style: TextStyle(color: Colors.grey, fontSize: 14 * context.sf))) ? Center(
child: Text(
"Seleciona uma equipa acima.",
style: TextStyle(
color: Colors.grey, fontSize: 14 * context.sf),
),
)
: StreamBuilder<List<Map<String, dynamic>>>( : StreamBuilder<List<Map<String, dynamic>>>(
stream: _supabase.from('player_stats_with_names').stream(primaryKey: ['id']).eq('team_id', _selectedTeamId!), stream: _supabase
.from('player_stats_with_names')
.stream(primaryKey: ['id']).eq('team_id', _selectedTeamId!),
builder: (context, statsSnapshot) { builder: (context, statsSnapshot) {
return StreamBuilder<List<Map<String, dynamic>>>( return StreamBuilder<List<Map<String, dynamic>>>(
stream: _supabase.from('games').stream(primaryKey: ['id']).eq('my_team', _selectedTeamName), stream: _supabase
.from('games')
.stream(primaryKey: ['id']).eq('my_team', _selectedTeamName),
builder: (context, gamesSnapshot) { builder: (context, gamesSnapshot) {
return StreamBuilder<List<Map<String, dynamic>>>( return StreamBuilder<List<Map<String, dynamic>>>(
stream: _supabase.from('members').stream(primaryKey: ['id']).eq('team_id', _selectedTeamId!), stream: _supabase
.from('members')
.stream(primaryKey: ['id']).eq('team_id', _selectedTeamId!),
builder: (context, membersSnapshot) { builder: (context, membersSnapshot) {
if (statsSnapshot.connectionState == ConnectionState.waiting || gamesSnapshot.connectionState == ConnectionState.waiting || membersSnapshot.connectionState == ConnectionState.waiting) { if (statsSnapshot.connectionState ==
return const Center(child: CircularProgressIndicator(color: AppTheme.primaryRed)); ConnectionState.waiting ||
gamesSnapshot.connectionState ==
ConnectionState.waiting ||
membersSnapshot.connectionState ==
ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(
color: AppTheme.primaryRed));
} }
final membersData = membersSnapshot.data ?? []; 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))); 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 statsData = statsSnapshot.data ?? [];
final gamesData = gamesSnapshot.data ?? []; final gamesData = gamesSnapshot.data ?? [];
final totalGamesPlayedByTeam = gamesData.where((g) => g['status'] == 'Terminado').length; final totalGamesPlayedByTeam = gamesData
.where((g) => g['status'] == 'Terminado')
.length;
final List<Map<String, dynamic>> playerTotals = _aggregateStats(statsData, gamesData, membersData); final List<Map<String, dynamic>> playerTotals =
final teamTotals = _calculateTeamTotals(playerTotals, totalGamesPlayedByTeam); _aggregateStats(statsData, gamesData, membersData);
final teamTotals = _calculateTeamTotals(
playerTotals, totalGamesPlayedByTeam);
playerTotals.sort((a, b) { playerTotals.sort((a, b) {
var valA = a[_sortColumn] ?? 0; var valA = a[_sortColumn] ?? 0;
var valB = b[_sortColumn] ?? 0; var valB = b[_sortColumn] ?? 0;
return _isAscending ? valA.compareTo(valB) : valB.compareTo(valA); return _isAscending
? valA.compareTo(valB)
: valB.compareTo(valA);
}); });
return _buildStatsGrid(context, playerTotals, teamTotals, bgColor, textColor); return _buildStatsGrid(
} context, playerTotals, teamTotals, bgColor, textColor);
},
); );
} },
); );
} },
), ),
), ),
], ],
); );
} }
List<Map<String, dynamic>> _aggregateStats(List<dynamic> stats, List<dynamic> games, List<dynamic> members) { @override
void dispose() {
globalActiveTeam.removeListener(_onGlobalActiveTeamChanged);
super.dispose();
}
List<Map<String, dynamic>> _aggregateStats(
List<dynamic> stats, List<dynamic> games, List<dynamic> members) {
Map<String, Map<String, dynamic>> aggregated = {}; Map<String, Map<String, dynamic>> aggregated = {};
for (var member in members) { for (var member in members) {
String name = member['name']?.toString() ?? "Desconhecido"; String name = member['name']?.toString() ?? "Desconhecido";
String? imageUrl = member['image_url']?.toString(); String? imageUrl = member['image_url']?.toString();
aggregated[name] = {'name': name, 'image_url': imageUrl, 'j': 0, 'pts': 0, 'ast': 0, 'rbs': 0, 'stl': 0, 'blk': 0, 'mvp': 0, 'def': 0}; aggregated[name] = {
'name': name,
'image_url': imageUrl,
'j': 0,
'pts': 0,
'ast': 0,
'rbs': 0,
'stl': 0,
'blk': 0,
'mvp': 0,
'def': 0,
};
} }
for (var row in stats) { for (var row in stats) {
String name = row['player_name']?.toString() ?? "Desconhecido"; String name = row['player_name']?.toString() ?? "Desconhecido";
if (!aggregated.containsKey(name)) aggregated[name] = {'name': name, 'image_url': null, 'j': 0, 'pts': 0, 'ast': 0, 'rbs': 0, 'stl': 0, 'blk': 0, 'mvp': 0, 'def': 0}; if (!aggregated.containsKey(name)) {
aggregated[name] = {
'name': name,
'image_url': null,
'j': 0,
'pts': 0,
'ast': 0,
'rbs': 0,
'stl': 0,
'blk': 0,
'mvp': 0,
'def': 0,
};
}
aggregated[name]!['j'] += 1; aggregated[name]!['j'] += 1;
aggregated[name]!['pts'] += (row['pts'] ?? 0); aggregated[name]!['pts'] += (row['pts'] ?? 0);
aggregated[name]!['ast'] += (row['ast'] ?? 0); aggregated[name]!['ast'] += (row['ast'] ?? 0);
@@ -176,46 +315,85 @@ class _StatusPageState extends State<StatusPage> {
aggregated[name]!['stl'] += (row['stl'] ?? 0); aggregated[name]!['stl'] += (row['stl'] ?? 0);
aggregated[name]!['blk'] += (row['blk'] ?? 0); aggregated[name]!['blk'] += (row['blk'] ?? 0);
} }
for (var game in games) { for (var game in games) {
String? mvp = game['mvp_name']; String? mvp = game['mvp_name'];
String? defRaw = game['top_def_name']; String? defRaw = game['top_def_name'];
if (mvp != null && aggregated.containsKey(mvp)) aggregated[mvp]!['mvp'] += 1; if (mvp != null && aggregated.containsKey(mvp)) {
aggregated[mvp]!['mvp'] += 1;
}
if (defRaw != null) { if (defRaw != null) {
String defName = defRaw.split(' (')[0].trim(); String defName = defRaw.split(' (')[0].trim();
if (aggregated.containsKey(defName)) aggregated[defName]!['def'] += 1; if (aggregated.containsKey(defName)) {
aggregated[defName]!['def'] += 1;
} }
} }
}
return aggregated.values.toList(); return aggregated.values.toList();
} }
Map<String, dynamic> _calculateTeamTotals(List<Map<String, dynamic>> players, int teamGames) { Map<String, dynamic> _calculateTeamTotals(
int tPts = 0, tAst = 0, tRbs = 0, tStl = 0, tBlk = 0, tMvp = 0, tDef = 0; 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) { for (var p in players) {
tPts += (p['pts'] as int); tAst += (p['ast'] as int); tRbs += (p['rbs'] as int); tStl += (p['stl'] as int); tBlk += (p['blk'] as int); tMvp += (p['mvp'] as int); tDef += (p['def'] as int); tPts += (p['pts'] as int);
tAst += (p['ast'] as int);
tRbs += (p['rbs'] as int);
tStl += (p['stl'] as int);
tBlk += (p['blk'] as int);
tMvp += (p['mvp'] as int);
tDef += (p['def'] as int);
} }
return {'name': 'TOTAL EQUIPA', 'image_url': null, 'j': teamGames, 'pts': tPts, 'ast': tAst, 'rbs': tRbs, 'stl': tStl, 'blk': tBlk, 'mvp': tMvp, 'def': tDef}; return {
'name': 'TOTAL EQUIPA',
'image_url': null,
'j': teamGames,
'pts': tPts,
'ast': tAst,
'rbs': tRbs,
'stl': tStl,
'blk': tBlk,
'mvp': tMvp,
'def': tDef,
};
} }
Widget _buildStatsGrid(BuildContext context, List<Map<String, dynamic>> players, Map<String, dynamic> teamTotals, Color bgColor, Color textColor) { Widget _buildStatsGrid(
BuildContext context,
List<Map<String, dynamic>> players,
Map<String, dynamic> teamTotals,
Color bgColor,
Color textColor) {
return Container( return Container(
color: Colors.transparent, // 👇 VOLTOU A ESTAR TRANSPARENTE COMO TINHAS ANTES! color: Colors.transparent,
width: double.infinity, width: double.infinity,
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.vertical, scrollDirection: Axis.vertical,
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
physics: const ClampingScrollPhysics(), // Mantém-se o Clamping para não puxar mais do que o ecrã physics: const ClampingScrollPhysics(),
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints(minWidth: MediaQuery.of(context).size.width), constraints:
BoxConstraints(minWidth: MediaQuery.of(context).size.width),
child: DataTable( child: DataTable(
columnSpacing: 20 * context.sf, columnSpacing: 20 * context.sf,
horizontalMargin: 16 * context.sf, horizontalMargin: 16 * context.sf,
headingRowColor: WidgetStateProperty.all(Theme.of(context).colorScheme.surface), headingRowColor: WidgetStateProperty.all(
Theme.of(context).colorScheme.surface),
dataRowMaxHeight: 60 * context.sf, dataRowMaxHeight: 60 * context.sf,
dataRowMinHeight: 60 * context.sf, dataRowMinHeight: 60 * context.sf,
columns: [ columns: [
DataColumn(label: Text('JOGADOR', style: TextStyle(color: textColor))), DataColumn(
label: Text('JOGADOR',
style: TextStyle(color: textColor))),
_buildSortableColumn(context, 'J', 'j', textColor), _buildSortableColumn(context, 'J', 'j', textColor),
_buildSortableColumn(context, 'PTS', 'pts', textColor), _buildSortableColumn(context, 'PTS', 'pts', textColor),
_buildSortableColumn(context, 'AST', 'ast', textColor), _buildSortableColumn(context, 'AST', 'ast', textColor),
@@ -228,52 +406,82 @@ class _StatusPageState extends State<StatusPage> {
rows: [ rows: [
...players.map((player) => DataRow(cells: [ ...players.map((player) => DataRow(cells: [
DataCell( DataCell(
Row( Row(children: [
children: [
ClipOval( ClipOval(
child: Container( child: Container(
width: 30 * context.sf, width: 30 * context.sf,
height: 30 * context.sf, height: 30 * context.sf,
color: Colors.grey.withOpacity(0.2), color: Colors.grey.withOpacity(0.2),
child: (player['image_url'] != null && player['image_url'].toString().isNotEmpty) child: (player['image_url'] != null &&
player['image_url']
.toString()
.isNotEmpty)
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: player['image_url'], imageUrl: player['image_url'],
fit: BoxFit.cover, fit: BoxFit.cover,
fadeInDuration: Duration.zero, fadeInDuration: Duration.zero,
placeholder: (context, url) => Icon(Icons.person, size: 18 * context.sf, color: Colors.grey), placeholder: (context, url) => Icon(
errorWidget: (context, url, error) => Icon(Icons.person, size: 18 * context.sf, color: Colors.grey), 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), : Icon(Icons.person,
size: 18 * context.sf,
color: Colors.grey),
), ),
), ),
SizedBox(width: 10 * context.sf), SizedBox(width: 10 * context.sf),
Text(player['name'], style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13 * context.sf, color: textColor)) Text(player['name'],
] style: TextStyle(
) fontWeight: FontWeight.bold,
fontSize: 13 * context.sf,
color: textColor)),
]),
), ),
DataCell(Center(child: Text(player['j'].toString(), style: TextStyle(color: textColor)))), DataCell(Center(
_buildStatCell(context, player['pts'], textColor, isHighlight: true), child: Text(player['j'].toString(),
style: TextStyle(color: textColor)))),
_buildStatCell(context, player['pts'], textColor,
isHighlight: true),
_buildStatCell(context, player['ast'], textColor), _buildStatCell(context, player['ast'], textColor),
_buildStatCell(context, player['rbs'], textColor), _buildStatCell(context, player['rbs'], textColor),
_buildStatCell(context, player['stl'], textColor), _buildStatCell(context, player['stl'], textColor),
_buildStatCell(context, player['blk'], textColor), _buildStatCell(context, player['blk'], textColor),
_buildStatCell(context, player['def'], textColor, isBlue: true), _buildStatCell(context, player['def'], textColor,
_buildStatCell(context, player['mvp'], textColor, isGold: true), isBlue: true),
_buildStatCell(context, player['mvp'], textColor,
isGold: true),
])), ])),
DataRow( DataRow(
color: WidgetStateProperty.all(Theme.of(context).colorScheme.surface.withOpacity(0.5)), color: WidgetStateProperty.all(
Theme.of(context).colorScheme.surface.withOpacity(0.5)),
cells: [ cells: [
DataCell(Text('TOTAL EQUIPA', style: TextStyle(fontWeight: FontWeight.w900, color: textColor, fontSize: 12 * context.sf))), DataCell(Text('TOTAL EQUIPA',
DataCell(Center(child: Text(teamTotals['j'].toString(), style: TextStyle(fontWeight: FontWeight.bold, color: textColor)))), style: TextStyle(
_buildStatCell(context, teamTotals['pts'], textColor, isHighlight: true), 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['ast'], textColor),
_buildStatCell(context, teamTotals['rbs'], textColor), _buildStatCell(context, teamTotals['rbs'], textColor),
_buildStatCell(context, teamTotals['stl'], textColor), _buildStatCell(context, teamTotals['stl'], textColor),
_buildStatCell(context, teamTotals['blk'], textColor), _buildStatCell(context, teamTotals['blk'], textColor),
_buildStatCell(context, teamTotals['def'], textColor, isBlue: true), _buildStatCell(context, teamTotals['def'], textColor,
_buildStatCell(context, teamTotals['mvp'], textColor, isGold: true), isBlue: true),
] _buildStatCell(context, teamTotals['mvp'], textColor,
) isGold: true),
],
),
], ],
), ),
), ),
@@ -282,36 +490,79 @@ class _StatusPageState extends State<StatusPage> {
); );
} }
DataColumn _buildSortableColumn(BuildContext context, String title, String sortKey, Color textColor) { DataColumn _buildSortableColumn(
return DataColumn(label: InkWell( BuildContext context, String title, String sortKey, Color textColor) {
return DataColumn(
label: InkWell(
onTap: () => setState(() { onTap: () => setState(() {
if (_sortColumn == sortKey) _isAscending = !_isAscending; if (_sortColumn == sortKey) {
else { _sortColumn = sortKey; _isAscending = false; } _isAscending = !_isAscending;
} else {
_sortColumn = sortKey;
_isAscending = false;
}
}), }),
child: Row(children: [ child: Row(children: [
Text(title, style: TextStyle(fontSize: 12 * context.sf, fontWeight: FontWeight.bold, color: textColor)), Text(title,
if (_sortColumn == sortKey) Icon(_isAscending ? Icons.arrow_drop_up : Icons.arrow_drop_down, size: 18 * context.sf, color: AppTheme.primaryRed), 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)),
),
),
),
)); ));
} }
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) { void _showTeamSelector(BuildContext context) {
showModalBottomSheet(context: context, backgroundColor: Theme.of(context).colorScheme.surface, builder: (context) => StreamBuilder<List<Map<String, dynamic>>>( showModalBottomSheet(
context: context,
backgroundColor: Theme.of(context).colorScheme.surface,
builder: (context) => StreamBuilder<List<Map<String, dynamic>>>(
stream: _teamController.teamsStream, stream: _teamController.teamsStream,
builder: (context, snapshot) { builder: (context, snapshot) {
final teams = snapshot.data ?? []; final teams = snapshot.data ?? [];
return ListView.builder(itemCount: teams.length, itemBuilder: (context, i) { return ListView.builder(
itemCount: teams.length,
itemBuilder: (context, i) {
final team = teams[i]; final team = teams[i];
final logoUrl = team['image_url']; final logoUrl = team['image_url'];
@@ -325,13 +576,21 @@ class _StatusPageState extends State<StatusPage> {
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: logoUrl, imageUrl: logoUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (context, url) => Icon(Icons.shield, color: AppTheme.primaryRed, size: 20 * context.sf), placeholder: (context, url) => Icon(Icons.shield,
errorWidget: (context, url, error) => Icon(Icons.shield, color: AppTheme.primaryRed, size: 20 * context.sf), color: AppTheme.primaryRed,
size: 20 * context.sf),
errorWidget: (context, url, error) => Icon(
Icons.shield,
color: AppTheme.primaryRed,
size: 20 * context.sf),
) )
: Icon(Icons.shield, color: AppTheme.primaryRed, size: 20 * context.sf), : Icon(Icons.shield,
color: AppTheme.primaryRed, size: 20 * context.sf),
), ),
), ),
title: Text(team['name'], style: TextStyle(color: Theme.of(context).colorScheme.onSurface)), title: Text(team['name'],
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface)),
onTap: () async { onTap: () async {
setState(() { setState(() {
_selectedTeamId = team['id'].toString(); _selectedTeamId = team['id'].toString();
@@ -339,13 +598,15 @@ class _StatusPageState extends State<StatusPage> {
_selectedTeamLogo = logoUrl; _selectedTeamLogo = logoUrl;
}); });
await _saveSelectedTeam(); await _saveSelectedTeamLocally();
if (context.mounted) Navigator.pop(context); if (context.mounted) Navigator.pop(context);
}, },
); );
});
}, },
)); );
},
),
);
} }
} }

View File

@@ -0,0 +1,20 @@
import 'package:shared_preferences/shared_preferences.dart';
class SessionManager {
static const _key = 'session_in_progress';
static Future<void> setInProgress(bool v) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_key, v);
}
static Future<bool> isInProgress() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_key) ?? false;
}
static Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_key);
}
}

View File

@@ -0,0 +1,239 @@
import 'package:flutter/material.dart';
import 'package:playmaker/classe/theme.dart';
import 'package:playmaker/controllers/game_sharing_controller.dart';
class ShareGameDialog extends StatefulWidget {
final String gameId;
final GameSharingController controller;
final String? activeSessionId;
final String? activeShareCode;
const ShareGameDialog({
super.key,
required this.gameId,
required this.controller,
this.activeSessionId,
this.activeShareCode,
});
@override
State<ShareGameDialog> createState() => _ShareGameDialogState();
}
class _ShareGameDialogState extends State<ShareGameDialog> {
String? _shareCode;
bool _isLoading = false;
String? _error;
@override
void initState() {
super.initState();
_shareCode = widget.activeShareCode;
}
Future<void> _createSession() async {
setState(() {
_error = null;
_isLoading = true;
});
final code = await widget.controller.createShareSession(widget.gameId);
if (!mounted) return;
setState(() {
_isLoading = false;
if (code == null) {
_error = 'Erro ao criar sessão. Tenta novamente.';
} else {
_shareCode = code;
}
});
if (_shareCode != null && widget.activeSessionId == null) {
final session = await widget.controller.getActiveSessionForGame(
widget.gameId,
);
if (session != null && mounted) {
Navigator.of(
context,
).pop({'session_id': session['id'], 'share_code': _shareCode});
}
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
backgroundColor: Theme.of(context).colorScheme.surface,
title: Text(
'Partilhar Jogo',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_shareCode != null) ...[
Text(
'Código de partilha:',
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
const SizedBox(height: 10),
SelectableText(
_shareCode!,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: AppTheme.primaryRed,
),
),
const SizedBox(height: 10),
Text(
'Envie este código ao seu parceiro e peça que ele entre no jogo.',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
),
),
] else ...[
Text(
'Crie um código e partilhe o jogo com outro utilizador.',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
),
),
],
if (_error != null) ...[
const SizedBox(height: 10),
Text(_error!, style: const TextStyle(color: Colors.red)),
],
],
),
actions: [
TextButton(
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
child: const Text('Fechar'),
),
if (_shareCode == null)
TextButton(
onPressed: _isLoading ? null : _createSession,
child: _isLoading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Criar código'),
)
else
TextButton(
onPressed: () {
Navigator.of(context).pop({
'session_id': widget.activeSessionId,
'share_code': _shareCode,
});
},
child: const Text('OK'),
),
],
);
}
}
class JoinGameDialog extends StatefulWidget {
final GameSharingController controller;
const JoinGameDialog({super.key, required this.controller});
@override
State<JoinGameDialog> createState() => _JoinGameDialogState();
}
class _JoinGameDialogState extends State<JoinGameDialog> {
final TextEditingController _codeController = TextEditingController();
bool _isLoading = false;
String? _error;
Future<void> _joinSession() async {
setState(() {
_isLoading = true;
_error = null;
});
final result = await widget.controller.joinGameByCode(
_codeController.text.trim(),
);
if (!mounted) return;
setState(() {
_isLoading = false;
});
if (result == null) {
setState(() {
_error = 'Código inválido ou sessão não disponível.';
});
return;
}
Navigator.of(context).pop(result);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
backgroundColor: Theme.of(context).colorScheme.surface,
title: Text(
'Entrar em Jogo Partilhado',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Digite o código enviado pelo outro utilizador.',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
),
),
const SizedBox(height: 16),
TextField(
controller: _codeController,
textCapitalization: TextCapitalization.characters,
decoration: InputDecoration(
labelText: 'Código',
border: const OutlineInputBorder(),
hintText: 'ABC123',
),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(_error!, style: const TextStyle(color: Colors.red)),
],
],
),
actions: [
TextButton(
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
child: const Text('Cancelar'),
),
TextButton(
onPressed: _isLoading ? null : _joinSession,
child: _isLoading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Entrar'),
),
],
);
}
}

View File

@@ -109,10 +109,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.1"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@@ -516,18 +516,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.17" version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.1" version: "0.13.0"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@@ -953,10 +953,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7" version: "0.7.10"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:

View File

@@ -71,6 +71,9 @@ flutter:
- assets/assit.png - assets/assit.png
- assets/tov.png - assets/tov.png
- assets/stl.png - assets/stl.png
- assets/campone.png
fonts: fonts:
- family: playmaker - family: playmaker
fonts: fonts:

View File

@@ -1,9 +1,3 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';