Compare commits
6 Commits
60656d77e8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 144a6c71a5 | |||
| 29e887cb14 | |||
| 947e119dba | |||
| 7d2f3c4679 | |||
| 332361c296 | |||
| 1e38c4ad57 |
51
.github/copilot-instructions.md
vendored
Normal file
51
.github/copilot-instructions.md
vendored
Normal 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
157
SYNC_CHANGES_SUMMARY.md
Normal 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
BIN
assets/campone.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 MiB |
108
create_tables.sql
Normal file
108
create_tables.sql
Normal 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();
|
||||
@@ -46,32 +46,48 @@ Future<void> loadGlobalTeam() async {
|
||||
if (userId == null) return;
|
||||
|
||||
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();
|
||||
if (profile != null && profile['selected_team_id'] != null) {
|
||||
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);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
globalActiveTeam.value = newTeam;
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Atualiza a memória do telemóvel para a próxima vez ser rápido
|
||||
await prefs.setString('last_team_id', newTeam.id);
|
||||
await prefs.setString('last_team_name', newTeam.name);
|
||||
if (newTeam.logo != null && newTeam.logo!.isNotEmpty) {
|
||||
await prefs.setString('last_team_logo', newTeam.logo!);
|
||||
}
|
||||
await prefs.setInt('last_team_wins', newTeam.wins);
|
||||
await prefs.setInt('last_team_losses', newTeam.losses);
|
||||
await prefs.setInt('last_team_draws', newTeam.draws);
|
||||
// If we found a team (favorite or profile selection), set it as active and persist locally
|
||||
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,
|
||||
);
|
||||
globalActiveTeam.value = newTeam;
|
||||
|
||||
// Atualiza a memória do telemóvel para a próxima vez ser rápido
|
||||
await prefs.setString('last_team_id', newTeam.id);
|
||||
await prefs.setString('last_team_name', newTeam.name);
|
||||
if (newTeam.logo != null && newTeam.logo!.isNotEmpty) {
|
||||
await prefs.setString('last_team_logo', newTeam.logo!);
|
||||
}
|
||||
await prefs.setInt('last_team_wins', newTeam.wins);
|
||||
await prefs.setInt('last_team_losses', newTeam.losses);
|
||||
await prefs.setInt('last_team_draws', newTeam.draws);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Erro ao carregar equipa do Supabase: $e");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import '../utils/session_manager.dart';
|
||||
import '../models/game_model.dart';
|
||||
|
||||
class GameController {
|
||||
@@ -53,6 +54,9 @@ class GameController {
|
||||
// CRIAR JOGO
|
||||
Future<String?> createGame(String myTeam, String opponent, String season) async {
|
||||
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({
|
||||
'user_id': myUserId,
|
||||
'my_team': myTeam,
|
||||
@@ -77,6 +81,10 @@ class GameController {
|
||||
return response['id']?.toString();
|
||||
} catch (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;
|
||||
}
|
||||
|
||||
|
||||
281
lib/controllers/game_sharing_controller.dart
Normal file
281
lib/controllers/game_sharing_controller.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -25,13 +25,23 @@ class ShotRecord {
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'relativeX': relativeX, 'relativeY': relativeY, 'isMake': isMake,
|
||||
'playerId': playerId, 'playerName': playerName, 'zone': zone, 'points': points,
|
||||
'relativeX': relativeX,
|
||||
'relativeY': relativeY,
|
||||
'isMake': isMake,
|
||||
'playerId': playerId,
|
||||
'playerName': playerName,
|
||||
'zone': zone,
|
||||
'points': points,
|
||||
};
|
||||
|
||||
factory ShotRecord.fromJson(Map<String, dynamic> json) => ShotRecord(
|
||||
relativeX: json['relativeX'], relativeY: json['relativeY'], isMake: json['isMake'],
|
||||
playerId: json['playerId'], playerName: json['playerName'], zone: json['zone'], points: json['points'],
|
||||
relativeX: json['relativeX'],
|
||||
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 myTeam;
|
||||
final String opponentTeam;
|
||||
final void Function(String actionType, Map<String, dynamic> actionData)?
|
||||
onSyncAction;
|
||||
|
||||
PlacarController({
|
||||
required this.gameId,
|
||||
required this.myTeam,
|
||||
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 isSaving = false;
|
||||
bool gameWasAlreadyFinished = false;
|
||||
@@ -80,7 +203,9 @@ class PlacarController extends ChangeNotifier {
|
||||
|
||||
List<String> playByPlay = [];
|
||||
|
||||
ValueNotifier<Duration> durationNotifier = ValueNotifier(const Duration(minutes: 10));
|
||||
ValueNotifier<Duration> durationNotifier = ValueNotifier(
|
||||
const Duration(minutes: 10),
|
||||
);
|
||||
Timer? timer;
|
||||
bool isRunning = false;
|
||||
|
||||
@@ -96,21 +221,41 @@ class PlacarController extends ChangeNotifier {
|
||||
try {
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
|
||||
myCourt.clear(); myBench.clear(); oppCourt.clear(); oppBench.clear();
|
||||
playerNames.clear(); playerStats.clear(); playerNumbers.clear();
|
||||
matchShots.clear(); playByPlay.clear(); myFouls = 0; opponentFouls = 0;
|
||||
myCourt.clear();
|
||||
myBench.clear();
|
||||
oppCourt.clear();
|
||||
oppBench.clear();
|
||||
playerNames.clear();
|
||||
playerStats.clear();
|
||||
playerNumbers.clear();
|
||||
matchShots.clear();
|
||||
playByPlay.clear();
|
||||
myFouls = 0;
|
||||
opponentFouls = 0;
|
||||
|
||||
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;
|
||||
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);
|
||||
|
||||
myTimeoutsUsed = int.tryParse(gameResponse['my_timeouts']?.toString() ?? '0') ?? 0;
|
||||
opponentTimeoutsUsed = int.tryParse(gameResponse['opp_timeouts']?.toString() ?? '0') ?? 0;
|
||||
currentQuarter = int.tryParse(gameResponse['current_quarter']?.toString() ?? '1') ?? 1;
|
||||
myTimeoutsUsed =
|
||||
int.tryParse(gameResponse['my_timeouts']?.toString() ?? '0') ?? 0;
|
||||
opponentTimeoutsUsed =
|
||||
int.tryParse(gameResponse['opp_timeouts']?.toString() ?? '0') ?? 0;
|
||||
currentQuarter =
|
||||
int.tryParse(gameResponse['current_quarter']?.toString() ?? '1') ?? 1;
|
||||
|
||||
gameWasAlreadyFinished = gameResponse['status'] == 'Terminado';
|
||||
|
||||
@@ -120,25 +265,49 @@ class PlacarController extends ChangeNotifier {
|
||||
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) {
|
||||
if (t['name'] == myTeam) myTeamDbId = 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> oppPlayers = oppTeamDbId != null ? await supabase.from('members').select().eq('team_id', oppTeamDbId!).eq('type', 'Jogador') : [];
|
||||
List<dynamic> myPlayers = myTeamDbId != null
|
||||
? await supabase
|
||||
.from('members')
|
||||
.select()
|
||||
.eq('team_id', myTeamDbId!)
|
||||
.eq('type', 'Jogador')
|
||||
: [];
|
||||
List<dynamic> oppPlayers = oppTeamDbId != null
|
||||
? await supabase
|
||||
.from('members')
|
||||
.select()
|
||||
.eq('team_id', oppTeamDbId!)
|
||||
.eq('type', 'Jogador')
|
||||
: [];
|
||||
|
||||
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 = {
|
||||
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++) {
|
||||
String dbId = myPlayers[i]['id'].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)) {
|
||||
var s = savedStats[dbId];
|
||||
@@ -152,7 +321,13 @@ class PlacarController extends ChangeNotifier {
|
||||
String dbId = oppPlayers[i]['id'].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)) {
|
||||
var s = savedStats[dbId];
|
||||
@@ -162,17 +337,24 @@ class PlacarController extends ChangeNotifier {
|
||||
}
|
||||
_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) {
|
||||
matchShots.add(ShotRecord(
|
||||
relativeX: double.parse(shotData['relative_x'].toString()),
|
||||
relativeY: double.parse(shotData['relative_y'].toString()),
|
||||
isMake: shotData['is_make'] == true,
|
||||
playerId: shotData['member_id'].toString(),
|
||||
playerName: shotData['player_name'].toString(),
|
||||
zone: shotData['zone']?.toString(),
|
||||
points: shotData['points'] != null ? int.parse(shotData['points'].toString()) : null,
|
||||
));
|
||||
matchShots.add(
|
||||
ShotRecord(
|
||||
relativeX: double.parse(shotData['relative_x'].toString()),
|
||||
relativeY: double.parse(shotData['relative_y'].toString()),
|
||||
isMake: shotData['is_make'] == true,
|
||||
playerId: shotData['member_id'].toString(),
|
||||
playerName: shotData['player_name'].toString(),
|
||||
zone: shotData['zone']?.toString(),
|
||||
points: shotData['points'] != null
|
||||
? int.parse(shotData['points'].toString())
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await _loadLocalBackup();
|
||||
@@ -188,42 +370,103 @@ class PlacarController extends ChangeNotifier {
|
||||
|
||||
void _loadSavedPlayerStats(String dbId, Map<String, dynamic> s) {
|
||||
playerStats[dbId] = {
|
||||
"pts": s['pts'] ?? 0, "rbs": s['rbs'] ?? 0, "ast": s['ast'] ?? 0,
|
||||
"stl": s['stl'] ?? 0, "tov": s['tov'] ?? 0, "blk": s['blk'] ?? 0,
|
||||
"fls": s['fls'] ?? 0, "fgm": s['fgm'] ?? 0, "fga": s['fga'] ?? 0,
|
||||
"ftm": s['ftm'] ?? 0, "fta": s['fta'] ?? 0, "orb": s['orb'] ?? 0, "drb": s['drb'] ?? 0,
|
||||
"p2m": s['p2m'] ?? 0, "p2a": s['p2a'] ?? 0, "p3m": s['p3m'] ?? 0, "p3a": s['p3a'] ?? 0,
|
||||
"so": s['so'] ?? 0, "il": s['il'] ?? 0, "li": s['li'] ?? 0,
|
||||
"pa": s['pa'] ?? 0, "tres_seg": s['tres_seg'] ?? 0, "dr": s['dr'] ?? 0,
|
||||
"pts": s['pts'] ?? 0,
|
||||
"rbs": s['rbs'] ?? 0,
|
||||
"ast": s['ast'] ?? 0,
|
||||
"stl": s['stl'] ?? 0,
|
||||
"tov": s['tov'] ?? 0,
|
||||
"blk": s['blk'] ?? 0,
|
||||
"fls": s['fls'] ?? 0,
|
||||
"fgm": s['fgm'] ?? 0,
|
||||
"fga": s['fga'] ?? 0,
|
||||
"ftm": s['ftm'] ?? 0,
|
||||
"fta": s['fta'] ?? 0,
|
||||
"orb": s['orb'] ?? 0,
|
||||
"drb": s['drb'] ?? 0,
|
||||
"p2m": s['p2m'] ?? 0,
|
||||
"p2a": s['p2a'] ?? 0,
|
||||
"p3m": s['p3m'] ?? 0,
|
||||
"p3a": s['p3a'] ?? 0,
|
||||
"so": s['so'] ?? 0,
|
||||
"il": s['il'] ?? 0,
|
||||
"li": s['li'] ?? 0,
|
||||
"pa": s['pa'] ?? 0,
|
||||
"tres_seg": s['tres_seg'] ?? 0,
|
||||
"dr": s['dr'] ?? 0,
|
||||
"min": (s['minutos_jogados'] ?? 0) ~/ 60,
|
||||
"sec": s['minutos_jogados'] ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
void _registerPlayer({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)}";
|
||||
void _registerPlayer({
|
||||
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;
|
||||
playerNumbers[id] = number;
|
||||
|
||||
playerStats[id] = {
|
||||
"pts": 0, "rbs": 0, "ast": 0, "stl": 0, "tov": 0, "blk": 0,
|
||||
"fls": 0, "fgm": 0, "fga": 0, "ftm": 0, "fta": 0, "orb": 0, "drb": 0,
|
||||
"p2m": 0, "p2a": 0, "p3m": 0, "p3a": 0,
|
||||
"so": 0, "il": 0, "li": 0, "pa": 0, "tres_seg": 0, "dr": 0,
|
||||
"min": 0, "sec": 0
|
||||
"pts": 0,
|
||||
"rbs": 0,
|
||||
"ast": 0,
|
||||
"stl": 0,
|
||||
"tov": 0,
|
||||
"blk": 0,
|
||||
"fls": 0,
|
||||
"fgm": 0,
|
||||
"fga": 0,
|
||||
"ftm": 0,
|
||||
"fta": 0,
|
||||
"orb": 0,
|
||||
"drb": 0,
|
||||
"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 (isCourt) myCourt.add(id); else myBench.add(id);
|
||||
if (isCourt)
|
||||
myCourt.add(id);
|
||||
else
|
||||
myBench.add(id);
|
||||
} 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) {
|
||||
_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 {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final backupData = {
|
||||
'myScore': myScore, 'opponentScore': opponentScore,
|
||||
'myFouls': myFouls, 'opponentFouls': opponentFouls,
|
||||
'currentQuarter': currentQuarter, 'duration': durationNotifier.value.inSeconds,
|
||||
'myTimeoutsUsed': myTimeoutsUsed, 'opponentTimeoutsUsed': opponentTimeoutsUsed,
|
||||
'myScore': myScore,
|
||||
'opponentScore': opponentScore,
|
||||
'myFouls': myFouls,
|
||||
'opponentFouls': opponentFouls,
|
||||
'currentQuarter': currentQuarter,
|
||||
'duration': durationNotifier.value.inSeconds,
|
||||
'myTimeoutsUsed': myTimeoutsUsed,
|
||||
'opponentTimeoutsUsed': opponentTimeoutsUsed,
|
||||
'playerStats': playerStats,
|
||||
'myCourt': myCourt, 'myBench': myBench, 'oppCourt': oppCourt, 'oppBench': oppBench,
|
||||
'myCourt': myCourt,
|
||||
'myBench': myBench,
|
||||
'oppCourt': oppCourt,
|
||||
'oppBench': oppBench,
|
||||
'matchShots': matchShots.map((s) => s.toJson()).toList(),
|
||||
'playByPlay': playByPlay,
|
||||
};
|
||||
@@ -261,16 +511,24 @@ class PlacarController extends ChangeNotifier {
|
||||
if (backupString != null) {
|
||||
final data = jsonDecode(backupString);
|
||||
|
||||
myScore = data['myScore']; opponentScore = data['opponentScore'];
|
||||
myFouls = data['myFouls']; opponentFouls = data['opponentFouls'];
|
||||
currentQuarter = data['currentQuarter']; durationNotifier.value = Duration(seconds: data['duration']);
|
||||
myTimeoutsUsed = data['myTimeoutsUsed']; opponentTimeoutsUsed = data['opponentTimeoutsUsed'];
|
||||
myScore = data['myScore'];
|
||||
opponentScore = data['opponentScore'];
|
||||
myFouls = data['myFouls'];
|
||||
opponentFouls = data['opponentFouls'];
|
||||
currentQuarter = data['currentQuarter'];
|
||||
durationNotifier.value = Duration(seconds: data['duration']);
|
||||
myTimeoutsUsed = data['myTimeoutsUsed'];
|
||||
opponentTimeoutsUsed = data['opponentTimeoutsUsed'];
|
||||
|
||||
myCourt = List<String>.from(data['myCourt']); myBench = List<String>.from(data['myBench']);
|
||||
oppCourt = List<String>.from(data['oppCourt']); oppBench = List<String>.from(data['oppBench']);
|
||||
myCourt = List<String>.from(data['myCourt']);
|
||||
myBench = List<String>.from(data['myBench']);
|
||||
oppCourt = List<String>.from(data['oppCourt']);
|
||||
oppBench = List<String>.from(data['oppBench']);
|
||||
|
||||
Map<String, dynamic> decodedStats = data['playerStats'];
|
||||
playerStats = decodedStats.map((k, v) => MapEntry(k, Map<String, int>.from(v)));
|
||||
playerStats = decodedStats.map(
|
||||
(k, v) => MapEntry(k, Map<String, int>.from(v)),
|
||||
);
|
||||
|
||||
List<dynamic> decodedShots = data['matchShots'];
|
||||
matchShots = decodedShots.map((s) => ShotRecord.fromJson(s)).toList();
|
||||
@@ -283,43 +541,8 @@ class PlacarController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void toggleTimer(BuildContext context) {
|
||||
if (isRunning) {
|
||||
timer?.cancel();
|
||||
_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();
|
||||
print("⏱️ toggleTimer chamado: isRunning=$isRunning");
|
||||
_setTimerRunning(!isRunning);
|
||||
}
|
||||
|
||||
void useTimeout(bool isOpponent) {
|
||||
@@ -332,19 +555,34 @@ class PlacarController extends ChangeNotifier {
|
||||
timer?.cancel();
|
||||
_scheduleAutoSave();
|
||||
notifyListeners();
|
||||
_dispatchSyncAction('use_timeout', {'is_opponent': isOpponent});
|
||||
}
|
||||
|
||||
void handleActionDrag(BuildContext context, String action, String playerData) {
|
||||
String playerId = playerData.replaceAll("player_my_", "").replaceAll("player_opp_", "");
|
||||
void handleActionDrag(
|
||||
BuildContext context,
|
||||
String action,
|
||||
String playerData,
|
||||
) {
|
||||
String playerId = playerData
|
||||
.replaceAll("player_my_", "")
|
||||
.replaceAll("player_opp_", "");
|
||||
final stats = playerStats[playerId]!;
|
||||
final name = playerNames[playerId]!;
|
||||
|
||||
if (stats["fls"]! >= 5 && action != "sub_foul") {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('🛑 $name atingiu 5 faltas e está expulso!'), backgroundColor: Colors.red));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('🛑 $name atingiu 5 faltas e está expulso!'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
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;
|
||||
pendingPlayerId = playerData;
|
||||
isSelectingShotLocation = true;
|
||||
@@ -354,7 +592,12 @@ class PlacarController extends ChangeNotifier {
|
||||
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) {
|
||||
String benchPlayerId = action.replaceAll("bench_my_", "");
|
||||
if (playerStats[benchPlayerId]!["fls"]! >= 5) return;
|
||||
@@ -375,12 +618,18 @@ class PlacarController extends ChangeNotifier {
|
||||
}
|
||||
_scheduleAutoSave();
|
||||
notifyListeners();
|
||||
_dispatchSyncAction('subbing', {
|
||||
'action': action,
|
||||
'court_player': courtPlayerId,
|
||||
'is_opponent': isOpponent,
|
||||
});
|
||||
}
|
||||
|
||||
// ── TROCAR JOGADORES NO CAMPO ──────────────────────────────────────────────
|
||||
void swapCourtPlayers(String draggedPlayerData, String targetPlayerData) {
|
||||
// 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 id2 = targetPlayerData.replaceAll("player_my_", "");
|
||||
|
||||
@@ -393,7 +642,8 @@ class PlacarController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
// 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 id2 = targetPlayerData.replaceAll("player_opp_", "");
|
||||
|
||||
@@ -411,20 +661,49 @@ class PlacarController extends ChangeNotifier {
|
||||
|
||||
_scheduleAutoSave();
|
||||
notifyListeners();
|
||||
_dispatchSyncAction('swap_players', {
|
||||
'dragged': draggedPlayerData,
|
||||
'target': targetPlayerData,
|
||||
});
|
||||
}
|
||||
|
||||
void registerShotFromPopup(BuildContext context, String action, String targetPlayer, String zone, int points, double relativeX, double relativeY) {
|
||||
String playerId = targetPlayer.replaceAll("player_my_", "").replaceAll("player_opp_", "");
|
||||
void registerShotFromPopup(
|
||||
BuildContext context,
|
||||
String action,
|
||||
String targetPlayer,
|
||||
String zone,
|
||||
int points,
|
||||
double relativeX,
|
||||
double relativeY,
|
||||
) {
|
||||
String playerId = targetPlayer
|
||||
.replaceAll("player_my_", "")
|
||||
.replaceAll("player_opp_", "");
|
||||
bool isMake = action.startsWith("add_");
|
||||
String name = playerNames[playerId] ?? "Jogador";
|
||||
|
||||
matchShots.add(ShotRecord(
|
||||
relativeX: relativeX, relativeY: relativeY, isMake: isMake,
|
||||
playerId: playerId, playerName: name, zone: zone, points: points
|
||||
));
|
||||
matchShots.add(
|
||||
ShotRecord(
|
||||
relativeX: relativeX,
|
||||
relativeY: relativeY,
|
||||
isMake: isMake,
|
||||
playerId: playerId,
|
||||
playerName: name,
|
||||
zone: zone,
|
||||
points: points,
|
||||
),
|
||||
);
|
||||
|
||||
String finalAction = isMake ? "add_pts_$points" : "miss_$points";
|
||||
commitStat(finalAction, targetPlayer);
|
||||
// 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();
|
||||
}
|
||||
|
||||
@@ -442,13 +721,33 @@ class PlacarController extends ChangeNotifier {
|
||||
bool isMake = pendingAction!.startsWith("add_pts_");
|
||||
double relX = position.dx / size.width;
|
||||
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!);
|
||||
|
||||
isSelectingShotLocation = false; pendingAction = null; pendingPlayerId = null;
|
||||
isSelectingShotLocation = false;
|
||||
pendingAction = null;
|
||||
pendingPlayerId = null;
|
||||
_scheduleAutoSave();
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -481,17 +780,25 @@ class PlacarController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void cancelShotLocation() {
|
||||
isSelectingShotLocation = false; pendingAction = null; pendingPlayerId = null; notifyListeners();
|
||||
isSelectingShotLocation = false;
|
||||
pendingAction = null;
|
||||
pendingPlayerId = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void registerFoul(String committerData, String foulType, String victimData) {
|
||||
bool isOpponent = committerData.startsWith("player_opp_");
|
||||
String committerId = committerData.replaceAll("player_my_", "").replaceAll("player_opp_", "");
|
||||
String committerId = committerData
|
||||
.replaceAll("player_my_", "")
|
||||
.replaceAll("player_opp_", "");
|
||||
final committerStats = playerStats[committerId]!;
|
||||
final committerName = playerNames[committerId] ?? "Jogador";
|
||||
|
||||
committerStats["fls"] = committerStats["fls"]! + 1;
|
||||
if (isOpponent) opponentFouls++; else myFouls++;
|
||||
if (isOpponent)
|
||||
opponentFouls++;
|
||||
else
|
||||
myFouls++;
|
||||
|
||||
if (foulType == "Desqualificante") {
|
||||
committerStats["fls"] = 5;
|
||||
@@ -500,7 +807,9 @@ class PlacarController extends ChangeNotifier {
|
||||
String logText = "cometeu Falta $foulType";
|
||||
|
||||
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 victimName = playerNames[victimId] ?? "Jogador";
|
||||
|
||||
@@ -510,11 +819,17 @@ class PlacarController extends ChangeNotifier {
|
||||
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");
|
||||
|
||||
_scheduleAutoSave();
|
||||
notifyListeners();
|
||||
_dispatchSyncAction('register_foul', {
|
||||
'committer': committerData,
|
||||
'foulType': foulType,
|
||||
'victim': victimData,
|
||||
});
|
||||
}
|
||||
|
||||
void commitStat(String action, String playerData) {
|
||||
@@ -553,13 +868,14 @@ class PlacarController extends ChangeNotifier {
|
||||
}
|
||||
logText = "marcou $pts pontos 🏀";
|
||||
}
|
||||
|
||||
// ── ANULAR PONTOS ────────────────────────────────────────────────────────
|
||||
else if (action.startsWith("sub_pts_")) {
|
||||
int ptsToAnul = int.parse(action.split("_").last);
|
||||
|
||||
int lastShotIndex = matchShots.lastIndexWhere((s) =>
|
||||
s.playerId == playerId && s.isMake == true && s.points == ptsToAnul);
|
||||
int lastShotIndex = matchShots.lastIndexWhere(
|
||||
(s) =>
|
||||
s.playerId == playerId && s.isMake == true && s.points == ptsToAnul,
|
||||
);
|
||||
|
||||
if (lastShotIndex != -1) {
|
||||
matchShots.removeAt(lastShotIndex);
|
||||
@@ -588,7 +904,6 @@ class PlacarController extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── FALHAS ───────────────────────────────────────────────────────────────
|
||||
else if (action == "miss_1") {
|
||||
stats["fta"] = stats["fta"]! + 1;
|
||||
@@ -602,7 +917,6 @@ class PlacarController extends ChangeNotifier {
|
||||
stats["p3a"] = stats["p3a"]! + 1;
|
||||
logText = "falhou lançamento de 3 ❌";
|
||||
}
|
||||
|
||||
// ── RESSALTOS ─────────────────────────────────────────────────────────────
|
||||
else if (action == "add_orb") {
|
||||
stats["orb"] = stats["orb"]! + 1;
|
||||
@@ -613,19 +927,16 @@ class PlacarController extends ChangeNotifier {
|
||||
stats["rbs"] = stats["rbs"]! + 1;
|
||||
logText = "ganhou ressalto defensivo 🛡️";
|
||||
}
|
||||
|
||||
// ── ASSISTÊNCIA ───────────────────────────────────────────────────────────
|
||||
else if (action == "add_ast") {
|
||||
stats["ast"] = stats["ast"]! + 1;
|
||||
logText = "fez uma assistência 🤝";
|
||||
}
|
||||
|
||||
// ── SOFRIDAS ──────────────────────────────────────────────────────────────
|
||||
else if (action == "add_so") {
|
||||
stats["so"] = stats["so"]! + 1;
|
||||
logText = "sofreu uma falta 🤕";
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// STEAL — ROUBO DE BOLA
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
@@ -637,7 +948,6 @@ class PlacarController extends ChangeNotifier {
|
||||
stats["il"] = stats["il"]! + 1;
|
||||
logText = "intercetou um lançamento 🛑";
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// BLOCK — DESARME
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
@@ -648,7 +958,6 @@ class PlacarController extends ChangeNotifier {
|
||||
stats["li"] = stats["li"]! + 1;
|
||||
logText = "sofreu um desarme 🚫";
|
||||
}
|
||||
|
||||
// Ações independentes legadas
|
||||
else if (action == "add_il") {
|
||||
stats["il"] = stats["il"]! + 1;
|
||||
@@ -657,7 +966,6 @@ class PlacarController extends ChangeNotifier {
|
||||
stats["li"] = stats["li"]! + 1;
|
||||
logText = "teve o lançamento intercetado 🚫";
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// TURNOVER — PERDE DE BOLA E INFRAÇÕES
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
@@ -666,21 +974,20 @@ class PlacarController extends ChangeNotifier {
|
||||
logText = "fez um passe ruim 🤦";
|
||||
} else if (action == "tov_3s") {
|
||||
stats["tres_seg"] = stats["tres_seg"]! + 1; // SOMA AOS 3 SEGUNDOS
|
||||
stats["tov"] = stats["tov"]! + 1; // SOMA AO TURNOVER GERAL
|
||||
stats["tov"] = stats["tov"]! + 1; // SOMA AO TURNOVER GERAL
|
||||
logText = "violação de 3 segundos ⏱️";
|
||||
} else if (action == "tov_clock") {
|
||||
stats["tov"] = stats["tov"]! + 1;
|
||||
logText = "violação de 24 segundos ⏱️";
|
||||
} else if (action == "tov_travel") {
|
||||
stats["pa"] = stats["pa"]! + 1; // SOMA AOS PASSOS
|
||||
stats["tov"] = stats["tov"]! + 1; // SOMA AO TURNOVER GERAL
|
||||
stats["pa"] = stats["pa"]! + 1; // SOMA AOS PASSOS
|
||||
stats["tov"] = stats["tov"]! + 1; // SOMA AO TURNOVER GERAL
|
||||
logText = "cometeu passos 🚶";
|
||||
} else if (action == "tov_double") {
|
||||
stats["dr"] = stats["dr"]! + 1; // SOMA AOS DRIBLES DUPLOS
|
||||
stats["tov"] = stats["tov"]! + 1; // SOMA AO TURNOVER GERAL
|
||||
stats["dr"] = stats["dr"]! + 1; // SOMA AOS DRIBLES DUPLOS
|
||||
stats["tov"] = stats["tov"]! + 1; // SOMA AO TURNOVER GERAL
|
||||
logText = "fez drible duplo 🏀";
|
||||
}
|
||||
|
||||
// ── ANULAR FALTA ──────────────────────────────────────────────────────────
|
||||
else if (action == "sub_foul") {
|
||||
if (stats["fls"]! > 0) stats["fls"] = stats["fls"]! - 1;
|
||||
@@ -700,6 +1007,10 @@ class PlacarController extends ChangeNotifier {
|
||||
|
||||
_scheduleAutoSave();
|
||||
notifyListeners();
|
||||
_dispatchSyncAction('commit_stat', {
|
||||
'action': action,
|
||||
'player_data': playerData,
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -744,7 +1055,7 @@ class PlacarController extends ChangeNotifier {
|
||||
|
||||
double mvpScore =
|
||||
((pts * 0.30) + (tr * 0.20) + (ast * 0.35) + (br * 0.15)) -
|
||||
((bp * 0.35) + (lFalhados * 0.30) + (llFalhados * 0.35));
|
||||
((bp * 0.35) + (lFalhados * 0.30) + (llFalhados * 0.35));
|
||||
mvpScore = mvpScore * (minJogados / 40.0);
|
||||
|
||||
String pName = playerNames[playerId] ?? '---';
|
||||
@@ -768,20 +1079,23 @@ class PlacarController extends ChangeNotifier {
|
||||
});
|
||||
|
||||
// 1. Atualizar o Jogo
|
||||
await supabase.from('games').update({
|
||||
'my_score': myScore,
|
||||
'opponent_score': opponentScore,
|
||||
'remaining_seconds': durationNotifier.value.inSeconds,
|
||||
'my_timeouts': myTimeoutsUsed,
|
||||
'opp_timeouts': opponentTimeoutsUsed,
|
||||
'current_quarter': currentQuarter,
|
||||
'status': newStatus,
|
||||
'top_pts_name': topPtsName,
|
||||
'top_ast_name': topAstName,
|
||||
'top_rbs_name': topRbsName,
|
||||
'mvp_name': mvpName,
|
||||
'play_by_play': playByPlay,
|
||||
}).eq('id', gameId);
|
||||
await supabase
|
||||
.from('games')
|
||||
.update({
|
||||
'my_score': myScore,
|
||||
'opponent_score': opponentScore,
|
||||
'remaining_seconds': durationNotifier.value.inSeconds,
|
||||
'my_timeouts': myTimeoutsUsed,
|
||||
'opp_timeouts': opponentTimeoutsUsed,
|
||||
'current_quarter': currentQuarter,
|
||||
'status': newStatus,
|
||||
'top_pts_name': topPtsName,
|
||||
'top_ast_name': topAstName,
|
||||
'top_rbs_name': topRbsName,
|
||||
'mvp_name': mvpName,
|
||||
'play_by_play': playByPlay,
|
||||
})
|
||||
.eq('id', gameId);
|
||||
|
||||
// 2. Preparar as Estatísticas dos Jogadores
|
||||
List<Map<String, dynamic>> batchStats = [];
|
||||
@@ -855,16 +1169,22 @@ class PlacarController extends ChangeNotifier {
|
||||
await prefs.remove('backup_$gameId');
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Guardado com Sucesso!'),
|
||||
backgroundColor: Colors.green));
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Erro ao gravar estatísticas: $e");
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erro ao guardar: $e'),
|
||||
backgroundColor: Colors.red));
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
isSaving = false;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:playmaker/controllers/active_team.dart';
|
||||
|
||||
class TeamController {
|
||||
final _supabase = Supabase.instance.client;
|
||||
@@ -65,10 +66,34 @@ class TeamController {
|
||||
// 4. FAVORITAR
|
||||
Future<void> toggleFavorite(String teamId, bool currentStatus) async {
|
||||
try {
|
||||
await _supabase
|
||||
.from('teams')
|
||||
.update({'is_favorite': !currentStatus})
|
||||
.eq('id', teamId);
|
||||
final userId = _supabase.auth.currentUser?.id;
|
||||
if (userId == null) return;
|
||||
|
||||
// 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) {
|
||||
print("❌ Erro ao favoritar: $e");
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; // Para as orientações
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:playmaker/classe/theme.dart';
|
||||
import 'pages/login.dart';
|
||||
import 'utils/session_manager.dart';
|
||||
|
||||
// Variável global para controlar o Tema
|
||||
final ValueNotifier<ThemeMode> themeNotifier = ValueNotifier(ThemeMode.system);
|
||||
@@ -25,9 +26,41 @@ void main() async {
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
class MyApp extends StatefulWidget {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<ThemeMode>(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,12 @@
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import '../models/game_model.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:playmaker/pages/PlacarPage.dart';
|
||||
import 'package:playmaker/widgets/share_game_dialog.dart';
|
||||
import 'package:playmaker/classe/theme.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../controllers/team_controller.dart';
|
||||
import '../controllers/game_controller.dart';
|
||||
import '../controllers/game_sharing_controller.dart';
|
||||
import '../utils/size_extension.dart';
|
||||
|
||||
import 'pdf_export_service.dart';
|
||||
@@ -281,6 +282,7 @@ class _CreateGameDialogManualState extends State<CreateGameDialogManual> {
|
||||
late TextEditingController _seasonController;
|
||||
final TextEditingController _myTeamController = TextEditingController();
|
||||
final TextEditingController _opponentController = TextEditingController();
|
||||
final GameSharingController _sharingController = GameSharingController();
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
@@ -318,6 +320,7 @@ class _CreateGameDialogManualState extends State<CreateGameDialogManual> {
|
||||
),
|
||||
actions: [
|
||||
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(
|
||||
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 {
|
||||
@@ -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) {
|
||||
return StreamBuilder<List<Map<String, dynamic>>>(
|
||||
stream: widget.teamController.teamsStream,
|
||||
|
||||
1039
lib/pages/home.dart
1039
lib/pages/home.dart
File diff suppressed because it is too large
Load Diff
@@ -33,13 +33,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_loadUserAvatar();
|
||||
}
|
||||
|
||||
String _prefsKey(String key) {
|
||||
final userId = supabase.auth.currentUser?.id ?? 'guest';
|
||||
return '${key}_$userId';
|
||||
}
|
||||
|
||||
Future<void> _loadUserAvatar() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedUrl = prefs.getString('meu_avatar_guardado');
|
||||
final savedUrl = prefs.getString(_prefsKey('meu_avatar_guardado'));
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (savedUrl != null) _uploadedImageUrl = savedUrl;
|
||||
if (savedUrl != null) {
|
||||
_uploadedImageUrl = savedUrl;
|
||||
} else {
|
||||
_uploadedImageUrl = null;
|
||||
}
|
||||
_isMemoryLoaded = true;
|
||||
});
|
||||
}
|
||||
@@ -58,7 +67,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final urlDoSupabase = data['avatar_url'];
|
||||
|
||||
if (urlDoSupabase != savedUrl) {
|
||||
await prefs.setString('meu_avatar_guardado', urlDoSupabase);
|
||||
await prefs.setString(_prefsKey('meu_avatar_guardado'), urlDoSupabase);
|
||||
setState(() {
|
||||
_uploadedImageUrl = urlDoSupabase;
|
||||
});
|
||||
@@ -104,7 +113,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
});
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('meu_avatar_guardado', publicUrl);
|
||||
await prefs.setString(_prefsKey('meu_avatar_guardado'), publicUrl);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -354,13 +363,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
onPressed: () async {
|
||||
// 👇 AGORA LIMPA A EQUIPA E TUDO DA MEMÓRIA AO SAIR!
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('meu_avatar_guardado');
|
||||
await prefs.remove('last_team_id');
|
||||
await prefs.remove('last_team_name');
|
||||
await prefs.remove('last_team_logo');
|
||||
await prefs.remove('last_team_wins');
|
||||
await prefs.remove('last_team_losses');
|
||||
await prefs.remove('last_team_draws');
|
||||
final userId = supabase.auth.currentUser?.id;
|
||||
if (userId != null) {
|
||||
await prefs.remove(_prefsKey('meu_avatar_guardado'));
|
||||
await prefs.remove(_prefsKey('last_team_id'));
|
||||
await prefs.remove(_prefsKey('last_team_name'));
|
||||
await prefs.remove(_prefsKey('last_team_logo'));
|
||||
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();
|
||||
if (ctx.mounted) {
|
||||
|
||||
@@ -4,10 +4,20 @@ import 'package:playmaker/classe/theme.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../controllers/team_controller.dart';
|
||||
import '../controllers/active_team.dart';
|
||||
import '../utils/size_extension.dart';
|
||||
|
||||
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
|
||||
State<StatusPage> createState() => _StatusPageState();
|
||||
@@ -17,9 +27,9 @@ class _StatusPageState extends State<StatusPage> {
|
||||
final TeamController _teamController = TeamController();
|
||||
final _supabase = Supabase.instance.client;
|
||||
|
||||
String? _selectedTeamId;
|
||||
String _selectedTeamName = "Selecionar Equipa";
|
||||
String? _selectedTeamLogo;
|
||||
late String? _selectedTeamId;
|
||||
late String _selectedTeamName;
|
||||
late String? _selectedTeamLogo;
|
||||
|
||||
String _sortColumn = 'pts';
|
||||
bool _isAscending = false;
|
||||
@@ -27,34 +37,87 @@ class _StatusPageState extends State<StatusPage> {
|
||||
@override
|
||||
void 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();
|
||||
}
|
||||
|
||||
// Listen to global active team changes (e.g., when user marks favorite)
|
||||
globalActiveTeam.addListener(_onGlobalActiveTeamChanged);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSelectedTeam() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedId = prefs.getString('last_team_id');
|
||||
void _onGlobalActiveTeamChanged() {
|
||||
final at = globalActiveTeam.value;
|
||||
if (!mounted) return;
|
||||
|
||||
if (savedId != null && mounted) {
|
||||
// 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(() {
|
||||
_selectedTeamId = savedId;
|
||||
_selectedTeamName = prefs.getString('last_team_name') ?? "Selecionar Equipa";
|
||||
_selectedTeamLogo = prefs.getString('last_team_logo');
|
||||
_selectedTeamId = at.id;
|
||||
_selectedTeamName = at.name;
|
||||
_selectedTeamLogo = at.logo;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveSelectedTeam() async {
|
||||
String _prefsKey(String key) {
|
||||
final userId = _supabase.auth.currentUser?.id ?? 'guest';
|
||||
return '${key}_$userId';
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(StatusPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// 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('last_team_id', _selectedTeamId!);
|
||||
await prefs.setString('last_team_name', _selectedTeamName);
|
||||
await prefs.setString(_prefsKey('last_team_id'), _selectedTeamId!);
|
||||
await prefs.setString(_prefsKey('last_team_name'), _selectedTeamName);
|
||||
if (_selectedTeamLogo != null && _selectedTeamLogo!.isNotEmpty) {
|
||||
await prefs.setString('last_team_logo', _selectedTeamLogo!);
|
||||
await prefs.setString(_prefsKey('last_team_logo'), _selectedTeamLogo!);
|
||||
} else {
|
||||
await prefs.remove('last_team_logo');
|
||||
await prefs.remove(_prefsKey('last_team_logo'));
|
||||
}
|
||||
}
|
||||
|
||||
// Também guarda no Supabase
|
||||
final userId = _supabase.auth.currentUser?.id;
|
||||
if (userId != null && _selectedTeamId != null) {
|
||||
try {
|
||||
@@ -85,7 +148,9 @@ class _StatusPageState extends State<StatusPage> {
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(15 * context.sf),
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.2)),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 5)]
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 5)
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
@@ -98,14 +163,23 @@ class _StatusPageState extends State<StatusPage> {
|
||||
width: 24 * context.sf,
|
||||
height: 24 * context.sf,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Icon(Icons.shield, color: AppTheme.primaryRed, size: 24 * context.sf),
|
||||
errorWidget: (context, url, error) => Icon(Icons.shield, color: AppTheme.primaryRed, size: 24 * context.sf),
|
||||
placeholder: (context, url) => Icon(Icons.shield,
|
||||
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),
|
||||
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),
|
||||
],
|
||||
@@ -116,59 +190,124 @@ class _StatusPageState extends State<StatusPage> {
|
||||
|
||||
Expanded(
|
||||
child: _selectedTeamId == null
|
||||
? Center(child: Text("Seleciona uma equipa acima.", style: TextStyle(color: Colors.grey, fontSize: 14 * context.sf)))
|
||||
: StreamBuilder<List<Map<String, dynamic>>>(
|
||||
stream: _supabase.from('player_stats_with_names').stream(primaryKey: ['id']).eq('team_id', _selectedTeamId!),
|
||||
builder: (context, statsSnapshot) {
|
||||
return StreamBuilder<List<Map<String, dynamic>>>(
|
||||
stream: _supabase.from('games').stream(primaryKey: ['id']).eq('my_team', _selectedTeamName),
|
||||
builder: (context, gamesSnapshot) {
|
||||
return StreamBuilder<List<Map<String, dynamic>>>(
|
||||
stream: _supabase.from('members').stream(primaryKey: ['id']).eq('team_id', _selectedTeamId!),
|
||||
builder: (context, membersSnapshot) {
|
||||
if (statsSnapshot.connectionState == ConnectionState.waiting || gamesSnapshot.connectionState == ConnectionState.waiting || membersSnapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator(color: AppTheme.primaryRed));
|
||||
}
|
||||
? Center(
|
||||
child: Text(
|
||||
"Seleciona uma equipa acima.",
|
||||
style: TextStyle(
|
||||
color: Colors.grey, fontSize: 14 * context.sf),
|
||||
),
|
||||
)
|
||||
: StreamBuilder<List<Map<String, dynamic>>>(
|
||||
stream: _supabase
|
||||
.from('player_stats_with_names')
|
||||
.stream(primaryKey: ['id']).eq('team_id', _selectedTeamId!),
|
||||
builder: (context, statsSnapshot) {
|
||||
return StreamBuilder<List<Map<String, dynamic>>>(
|
||||
stream: _supabase
|
||||
.from('games')
|
||||
.stream(primaryKey: ['id']).eq('my_team', _selectedTeamName),
|
||||
builder: (context, gamesSnapshot) {
|
||||
return StreamBuilder<List<Map<String, dynamic>>>(
|
||||
stream: _supabase
|
||||
.from('members')
|
||||
.stream(primaryKey: ['id']).eq('team_id', _selectedTeamId!),
|
||||
builder: (context, membersSnapshot) {
|
||||
if (statsSnapshot.connectionState ==
|
||||
ConnectionState.waiting ||
|
||||
gamesSnapshot.connectionState ==
|
||||
ConnectionState.waiting ||
|
||||
membersSnapshot.connectionState ==
|
||||
ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppTheme.primaryRed));
|
||||
}
|
||||
|
||||
final membersData = membersSnapshot.data ?? [];
|
||||
if (membersData.isEmpty) return Center(child: Text("Esta equipa não tem jogadores registados.", style: TextStyle(color: Colors.grey, fontSize: 14 * context.sf)));
|
||||
final membersData = membersSnapshot.data ?? [];
|
||||
if (membersData.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
"Esta equipa não tem jogadores registados.",
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 14 * context.sf)));
|
||||
}
|
||||
|
||||
final statsData = statsSnapshot.data ?? [];
|
||||
final gamesData = gamesSnapshot.data ?? [];
|
||||
final totalGamesPlayedByTeam = gamesData.where((g) => g['status'] == 'Terminado').length;
|
||||
final statsData = statsSnapshot.data ?? [];
|
||||
final gamesData = gamesSnapshot.data ?? [];
|
||||
final totalGamesPlayedByTeam = gamesData
|
||||
.where((g) => g['status'] == 'Terminado')
|
||||
.length;
|
||||
|
||||
final List<Map<String, dynamic>> playerTotals = _aggregateStats(statsData, gamesData, membersData);
|
||||
final teamTotals = _calculateTeamTotals(playerTotals, totalGamesPlayedByTeam);
|
||||
final List<Map<String, dynamic>> playerTotals =
|
||||
_aggregateStats(statsData, gamesData, membersData);
|
||||
final teamTotals = _calculateTeamTotals(
|
||||
playerTotals, totalGamesPlayedByTeam);
|
||||
|
||||
playerTotals.sort((a, b) {
|
||||
var valA = a[_sortColumn] ?? 0;
|
||||
var valB = b[_sortColumn] ?? 0;
|
||||
return _isAscending ? valA.compareTo(valB) : valB.compareTo(valA);
|
||||
});
|
||||
playerTotals.sort((a, b) {
|
||||
var valA = a[_sortColumn] ?? 0;
|
||||
var valB = b[_sortColumn] ?? 0;
|
||||
return _isAscending
|
||||
? valA.compareTo(valB)
|
||||
: valB.compareTo(valA);
|
||||
});
|
||||
|
||||
return _buildStatsGrid(context, playerTotals, teamTotals, bgColor, textColor);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
),
|
||||
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 = {};
|
||||
|
||||
for (var member in members) {
|
||||
String name = member['name']?.toString() ?? "Desconhecido";
|
||||
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) {
|
||||
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]!['pts'] += (row['pts'] ?? 0);
|
||||
aggregated[name]!['ast'] += (row['ast'] ?? 0);
|
||||
@@ -176,46 +315,85 @@ class _StatusPageState extends State<StatusPage> {
|
||||
aggregated[name]!['stl'] += (row['stl'] ?? 0);
|
||||
aggregated[name]!['blk'] += (row['blk'] ?? 0);
|
||||
}
|
||||
|
||||
for (var game in games) {
|
||||
String? mvp = game['mvp_name'];
|
||||
String? defRaw = game['top_def_name'];
|
||||
if (mvp != null && aggregated.containsKey(mvp)) aggregated[mvp]!['mvp'] += 1;
|
||||
if (mvp != null && aggregated.containsKey(mvp)) {
|
||||
aggregated[mvp]!['mvp'] += 1;
|
||||
}
|
||||
if (defRaw != null) {
|
||||
String defName = defRaw.split(' (')[0].trim();
|
||||
if (aggregated.containsKey(defName)) aggregated[defName]!['def'] += 1;
|
||||
if (aggregated.containsKey(defName)) {
|
||||
aggregated[defName]!['def'] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return aggregated.values.toList();
|
||||
}
|
||||
|
||||
Map<String, dynamic> _calculateTeamTotals(List<Map<String, dynamic>> players, int teamGames) {
|
||||
int tPts = 0, tAst = 0, tRbs = 0, tStl = 0, tBlk = 0, tMvp = 0, tDef = 0;
|
||||
Map<String, dynamic> _calculateTeamTotals(
|
||||
List<Map<String, dynamic>> players, int teamGames) {
|
||||
int tPts = 0,
|
||||
tAst = 0,
|
||||
tRbs = 0,
|
||||
tStl = 0,
|
||||
tBlk = 0,
|
||||
tMvp = 0,
|
||||
tDef = 0;
|
||||
for (var p in players) {
|
||||
tPts += (p['pts'] as int); tAst += (p['ast'] as int); tRbs += (p['rbs'] as int); tStl += (p['stl'] as int); tBlk += (p['blk'] as int); tMvp += (p['mvp'] as int); tDef += (p['def'] as int);
|
||||
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(
|
||||
color: Colors.transparent, // 👇 VOLTOU A ESTAR TRANSPARENTE COMO TINHAS ANTES!
|
||||
color: Colors.transparent,
|
||||
width: double.infinity,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.vertical,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: SingleChildScrollView(
|
||||
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(
|
||||
constraints: BoxConstraints(minWidth: MediaQuery.of(context).size.width),
|
||||
constraints:
|
||||
BoxConstraints(minWidth: MediaQuery.of(context).size.width),
|
||||
child: DataTable(
|
||||
columnSpacing: 20 * 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,
|
||||
dataRowMinHeight: 60 * context.sf,
|
||||
columns: [
|
||||
DataColumn(label: Text('JOGADOR', style: TextStyle(color: textColor))),
|
||||
DataColumn(
|
||||
label: Text('JOGADOR',
|
||||
style: TextStyle(color: textColor))),
|
||||
_buildSortableColumn(context, 'J', 'j', textColor),
|
||||
_buildSortableColumn(context, 'PTS', 'pts', textColor),
|
||||
_buildSortableColumn(context, 'AST', 'ast', textColor),
|
||||
@@ -227,53 +405,83 @@ class _StatusPageState extends State<StatusPage> {
|
||||
],
|
||||
rows: [
|
||||
...players.map((player) => DataRow(cells: [
|
||||
DataCell(
|
||||
Row(
|
||||
children: [
|
||||
ClipOval(
|
||||
child: Container(
|
||||
width: 30 * context.sf,
|
||||
height: 30 * context.sf,
|
||||
color: Colors.grey.withOpacity(0.2),
|
||||
child: (player['image_url'] != null && player['image_url'].toString().isNotEmpty)
|
||||
? CachedNetworkImage(
|
||||
imageUrl: player['image_url'],
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
placeholder: (context, url) => Icon(Icons.person, size: 18 * context.sf, color: Colors.grey),
|
||||
errorWidget: (context, url, error) => Icon(Icons.person, size: 18 * context.sf, color: Colors.grey),
|
||||
)
|
||||
: Icon(Icons.person, size: 18 * context.sf, color: Colors.grey),
|
||||
DataCell(
|
||||
Row(children: [
|
||||
ClipOval(
|
||||
child: Container(
|
||||
width: 30 * context.sf,
|
||||
height: 30 * context.sf,
|
||||
color: Colors.grey.withOpacity(0.2),
|
||||
child: (player['image_url'] != null &&
|
||||
player['image_url']
|
||||
.toString()
|
||||
.isNotEmpty)
|
||||
? CachedNetworkImage(
|
||||
imageUrl: player['image_url'],
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
placeholder: (context, url) => Icon(
|
||||
Icons.person,
|
||||
size: 18 * context.sf,
|
||||
color: Colors.grey),
|
||||
errorWidget: (context, url, error) =>
|
||||
Icon(Icons.person,
|
||||
size: 18 * context.sf,
|
||||
color: Colors.grey),
|
||||
)
|
||||
: Icon(Icons.person,
|
||||
size: 18 * context.sf,
|
||||
color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10 * context.sf),
|
||||
Text(player['name'], style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13 * context.sf, color: textColor))
|
||||
]
|
||||
)
|
||||
),
|
||||
DataCell(Center(child: Text(player['j'].toString(), style: TextStyle(color: textColor)))),
|
||||
_buildStatCell(context, player['pts'], textColor, isHighlight: true),
|
||||
_buildStatCell(context, player['ast'], textColor),
|
||||
_buildStatCell(context, player['rbs'], textColor),
|
||||
_buildStatCell(context, player['stl'], textColor),
|
||||
_buildStatCell(context, player['blk'], textColor),
|
||||
_buildStatCell(context, player['def'], textColor, isBlue: true),
|
||||
_buildStatCell(context, player['mvp'], textColor, isGold: true),
|
||||
])),
|
||||
SizedBox(width: 10 * context.sf),
|
||||
Text(player['name'],
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13 * context.sf,
|
||||
color: textColor)),
|
||||
]),
|
||||
),
|
||||
DataCell(Center(
|
||||
child: Text(player['j'].toString(),
|
||||
style: TextStyle(color: textColor)))),
|
||||
_buildStatCell(context, player['pts'], textColor,
|
||||
isHighlight: true),
|
||||
_buildStatCell(context, player['ast'], textColor),
|
||||
_buildStatCell(context, player['rbs'], textColor),
|
||||
_buildStatCell(context, player['stl'], textColor),
|
||||
_buildStatCell(context, player['blk'], textColor),
|
||||
_buildStatCell(context, player['def'], textColor,
|
||||
isBlue: true),
|
||||
_buildStatCell(context, player['mvp'], textColor,
|
||||
isGold: true),
|
||||
])),
|
||||
DataRow(
|
||||
color: WidgetStateProperty.all(Theme.of(context).colorScheme.surface.withOpacity(0.5)),
|
||||
color: WidgetStateProperty.all(
|
||||
Theme.of(context).colorScheme.surface.withOpacity(0.5)),
|
||||
cells: [
|
||||
DataCell(Text('TOTAL EQUIPA', style: TextStyle(fontWeight: FontWeight.w900, color: textColor, fontSize: 12 * context.sf))),
|
||||
DataCell(Center(child: Text(teamTotals['j'].toString(), style: TextStyle(fontWeight: FontWeight.bold, color: textColor)))),
|
||||
_buildStatCell(context, teamTotals['pts'], textColor, isHighlight: true),
|
||||
DataCell(Text('TOTAL EQUIPA',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
color: textColor,
|
||||
fontSize: 12 * context.sf))),
|
||||
DataCell(Center(
|
||||
child: Text(teamTotals['j'].toString(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: textColor)))),
|
||||
_buildStatCell(context, teamTotals['pts'], textColor,
|
||||
isHighlight: true),
|
||||
_buildStatCell(context, teamTotals['ast'], textColor),
|
||||
_buildStatCell(context, teamTotals['rbs'], textColor),
|
||||
_buildStatCell(context, teamTotals['stl'], textColor),
|
||||
_buildStatCell(context, teamTotals['blk'], textColor),
|
||||
_buildStatCell(context, teamTotals['def'], textColor, isBlue: true),
|
||||
_buildStatCell(context, teamTotals['mvp'], textColor, isGold: true),
|
||||
]
|
||||
)
|
||||
_buildStatCell(context, teamTotals['def'], textColor,
|
||||
isBlue: true),
|
||||
_buildStatCell(context, teamTotals['mvp'], textColor,
|
||||
isGold: true),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -282,70 +490,123 @@ class _StatusPageState extends State<StatusPage> {
|
||||
);
|
||||
}
|
||||
|
||||
DataColumn _buildSortableColumn(BuildContext context, String title, String sortKey, Color textColor) {
|
||||
return DataColumn(label: InkWell(
|
||||
onTap: () => setState(() {
|
||||
if (_sortColumn == sortKey) _isAscending = !_isAscending;
|
||||
else { _sortColumn = sortKey; _isAscending = false; }
|
||||
}),
|
||||
child: Row(children: [
|
||||
Text(title, style: TextStyle(fontSize: 12 * context.sf, fontWeight: FontWeight.bold, color: textColor)),
|
||||
if (_sortColumn == sortKey) Icon(_isAscending ? Icons.arrow_drop_up : Icons.arrow_drop_down, size: 18 * context.sf, color: AppTheme.primaryRed),
|
||||
]),
|
||||
));
|
||||
DataColumn _buildSortableColumn(
|
||||
BuildContext context, String title, String sortKey, Color textColor) {
|
||||
return DataColumn(
|
||||
label: InkWell(
|
||||
onTap: () => setState(() {
|
||||
if (_sortColumn == sortKey) {
|
||||
_isAscending = !_isAscending;
|
||||
} else {
|
||||
_sortColumn = sortKey;
|
||||
_isAscending = false;
|
||||
}
|
||||
}),
|
||||
child: Row(children: [
|
||||
Text(title,
|
||||
style: TextStyle(
|
||||
fontSize: 12 * context.sf,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: textColor)),
|
||||
if (_sortColumn == sortKey)
|
||||
Icon(
|
||||
_isAscending
|
||||
? Icons.arrow_drop_up
|
||||
: Icons.arrow_drop_down,
|
||||
size: 18 * context.sf,
|
||||
color: AppTheme.primaryRed),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
DataCell _buildStatCell(BuildContext context, int value, Color textColor, {bool isHighlight = false, bool isGold = false, bool isBlue = false}) {
|
||||
return DataCell(Center(child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8 * context.sf, vertical: 4 * context.sf),
|
||||
decoration: BoxDecoration(color: isGold && value > 0 ? Colors.amber.withOpacity(0.2) : (isBlue && value > 0 ? Colors.blue.withOpacity(0.1) : Colors.transparent), borderRadius: BorderRadius.circular(6)),
|
||||
child: Text(value == 0 ? "-" : value.toString(), style: TextStyle(
|
||||
fontWeight: (isHighlight || isGold || isBlue) ? FontWeight.w900 : FontWeight.w600,
|
||||
fontSize: 14 * context.sf, color: isGold && value > 0 ? Colors.orange.shade900 : (isBlue && value > 0 ? Colors.blue.shade800 : (isHighlight ? AppTheme.successGreen : textColor))
|
||||
)),
|
||||
)));
|
||||
DataCell _buildStatCell(BuildContext context, int value, Color textColor,
|
||||
{bool isHighlight = false, bool isGold = false, bool isBlue = false}) {
|
||||
return DataCell(Center(
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 8 * context.sf, vertical: 4 * context.sf),
|
||||
decoration: BoxDecoration(
|
||||
color: isGold && value > 0
|
||||
? Colors.amber.withOpacity(0.2)
|
||||
: (isBlue && value > 0
|
||||
? Colors.blue.withOpacity(0.1)
|
||||
: Colors.transparent),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
value == 0 ? "-" : value.toString(),
|
||||
style: TextStyle(
|
||||
fontWeight: (isHighlight || isGold || isBlue)
|
||||
? FontWeight.w900
|
||||
: FontWeight.w600,
|
||||
fontSize: 14 * context.sf,
|
||||
color: isGold && value > 0
|
||||
? Colors.orange.shade900
|
||||
: (isBlue && value > 0
|
||||
? Colors.blue.shade800
|
||||
: (isHighlight ? AppTheme.successGreen : textColor)),
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
void _showTeamSelector(BuildContext context) {
|
||||
showModalBottomSheet(context: context, backgroundColor: Theme.of(context).colorScheme.surface, builder: (context) => StreamBuilder<List<Map<String, dynamic>>>(
|
||||
stream: _teamController.teamsStream,
|
||||
builder: (context, snapshot) {
|
||||
final teams = snapshot.data ?? [];
|
||||
return ListView.builder(itemCount: teams.length, itemBuilder: (context, i) {
|
||||
final team = teams[i];
|
||||
final logoUrl = team['image_url'];
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
builder: (context) => StreamBuilder<List<Map<String, dynamic>>>(
|
||||
stream: _teamController.teamsStream,
|
||||
builder: (context, snapshot) {
|
||||
final teams = snapshot.data ?? [];
|
||||
return ListView.builder(
|
||||
itemCount: teams.length,
|
||||
itemBuilder: (context, i) {
|
||||
final team = teams[i];
|
||||
final logoUrl = team['image_url'];
|
||||
|
||||
return ListTile(
|
||||
leading: ClipOval(
|
||||
child: Container(
|
||||
width: 36 * context.sf,
|
||||
height: 36 * context.sf,
|
||||
color: AppTheme.primaryRed.withOpacity(0.1),
|
||||
child: (logoUrl != null && logoUrl.isNotEmpty)
|
||||
? CachedNetworkImage(
|
||||
imageUrl: logoUrl,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Icon(Icons.shield, 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),
|
||||
),
|
||||
),
|
||||
title: Text(team['name'], style: TextStyle(color: Theme.of(context).colorScheme.onSurface)),
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
_selectedTeamId = team['id'].toString();
|
||||
_selectedTeamName = team['name'];
|
||||
_selectedTeamLogo = logoUrl;
|
||||
});
|
||||
return ListTile(
|
||||
leading: ClipOval(
|
||||
child: Container(
|
||||
width: 36 * context.sf,
|
||||
height: 36 * context.sf,
|
||||
color: AppTheme.primaryRed.withOpacity(0.1),
|
||||
child: (logoUrl != null && logoUrl.isNotEmpty)
|
||||
? CachedNetworkImage(
|
||||
imageUrl: logoUrl,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Icon(Icons.shield,
|
||||
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),
|
||||
),
|
||||
),
|
||||
title: Text(team['name'],
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface)),
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
_selectedTeamId = team['id'].toString();
|
||||
_selectedTeamName = team['name'];
|
||||
_selectedTeamLogo = logoUrl;
|
||||
});
|
||||
|
||||
await _saveSelectedTeam();
|
||||
await _saveSelectedTeamLocally();
|
||||
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
20
lib/utils/session_manager.dart
Normal file
20
lib/utils/session_manager.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
239
lib/widgets/share_game_dialog.dart
Normal file
239
lib/widgets/share_game_dialog.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
16
pubspec.lock
16
pubspec.lock
@@ -109,10 +109,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -516,18 +516,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -953,10 +953,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.10"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -71,6 +71,9 @@ flutter:
|
||||
- assets/assit.png
|
||||
- assets/tov.png
|
||||
- assets/stl.png
|
||||
- assets/campone.png
|
||||
|
||||
|
||||
fonts:
|
||||
- family: playmaker
|
||||
fonts:
|
||||
|
||||
@@ -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_test/flutter_test.dart';
|
||||
|
||||
Reference in New Issue
Block a user