Compare commits
45 Commits
3df3ae1e95
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f2c77285a | ||
| f64f36b78f | |||
| 0772ec526e | |||
| 08235999d3 | |||
|
|
4510ca10c4 | ||
| 031c190d74 | |||
| 1b0ec8dc57 | |||
| cfe5d479ff | |||
|
|
c75e4165f6 | ||
| 016333f6c0 | |||
| 6e5e423ab4 | |||
|
|
be880d416b | ||
| 1abdeb2f56 | |||
| ff01d6c9e6 | |||
| d61e79ec99 | |||
| 5c9e8b2b9c | |||
|
|
9a2b2edefd | ||
| 2dbe505d77 | |||
| 3b0b10ea50 | |||
|
|
6a130acc84 | ||
| 00dc3ee5e1 | |||
| 957b42ea0c | |||
|
|
47b1209668 | ||
| 7dd6c7bd74 | |||
|
|
01b65a3d29 | ||
| 841f94e543 | |||
|
|
ab0d4dc2ba | ||
|
|
50b050e678 | ||
|
|
5d3b3ef271 | ||
| c8e5cf25c5 | |||
| d850b66089 | |||
|
|
166b89a661 | ||
|
|
1cb2260298 | ||
| da9bd0cd4a | |||
| d600835105 | |||
|
|
a8d5b0f002 | ||
|
|
4a1abd0be0 | ||
| f3dcb99de1 | |||
|
|
f41fbc7dd9 | ||
|
|
e3c502c7df | ||
| 3f4ec2d20f | |||
|
|
1231b32c3c | ||
| a59a9a481e | |||
|
|
911bd4c4a3 | ||
|
|
d3fc3386f9 |
76
.atl/skill-registry.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Skill Registry
|
||||
|
||||
**Delegator use only.** Any agent that launches sub-agents reads this registry to resolve compact rules, then injects them directly into sub-agent prompts. Sub-agents do NOT read this registry or individual SKILL.md files.
|
||||
|
||||
See `_shared/skill-resolver.md` for the full resolution protocol.
|
||||
|
||||
## User Skills
|
||||
|
||||
| Trigger | Skill | Path |
|
||||
|---------|-------|------|
|
||||
| Go tests, Bubbletea TUI testing | go-testing | C:/Users/jbwhi/.codex/skills/go-testing/SKILL.md |
|
||||
| Creating a GitHub issue, reporting a bug, or requesting a feature | issue-creation | C:/Users/jbwhi/.codex/skills/issue-creation/SKILL.md |
|
||||
| Creating a pull request or preparing changes for review | branch-pr | C:/Users/jbwhi/.codex/skills/branch-pr/SKILL.md |
|
||||
| Adversarial dual review / judgment day | judgment-day | C:/Users/jbwhi/.codex/skills/judgment-day/SKILL.md |
|
||||
| Creating new AI skills | skill-creator | C:/Users/jbwhi/.codex/skills/skill-creator/SKILL.md |
|
||||
| Browser automation for localhost/file/current browser tab | browser-use:browser | C:/Users/jbwhi/.codex/plugins/cache/openai-bundled/browser-use/0.1.0-alpha1/skills/browser/SKILL.md |
|
||||
| Document editing/render verification | documents:documents | C:/Users/jbwhi/.codex/plugins/cache/openai-primary-runtime/documents/26.430.10722/skills/documents/SKILL.md |
|
||||
| Presentation deck creation/edit/render/export | presentations:Presentations | C:/Users/jbwhi/.codex/plugins/cache/openai-primary-runtime/presentations/26.430.10722/skills/presentations/SKILL.md |
|
||||
| Spreadsheet creation/edit/analyze/visualize | spreadsheets:Spreadsheets | C:/Users/jbwhi/.codex/plugins/cache/openai-primary-runtime/spreadsheets/26.430.10722/skills/spreadsheets/SKILL.md |
|
||||
|
||||
## Compact Rules
|
||||
|
||||
Pre-digested rules per skill. Delegators copy matching blocks into sub-agent prompts as `## Project Standards (auto-resolved)`.
|
||||
|
||||
### go-testing
|
||||
- Use `go test` patterns and Bubbletea `teatest` when touching Go/TUI code.
|
||||
- Prefer deterministic tests and isolate terminal/model effects.
|
||||
- Keep tests close to behavior and avoid brittle timing assumptions.
|
||||
- Not applicable to this Flutter/Dart project unless Go files are introduced.
|
||||
|
||||
### issue-creation
|
||||
- Follow issue-first workflow before PR work when a feature/bug needs tracking.
|
||||
- Capture problem, expected behavior, acceptance criteria, and verification steps.
|
||||
- Do not create noisy or duplicate issues without checking existing context.
|
||||
|
||||
### branch-pr
|
||||
- Use conventional commit/PR language.
|
||||
- Never add AI attribution or `Co-Authored-By`.
|
||||
- Ensure code review summary includes what changed, tests/analyze status, and risks.
|
||||
|
||||
### judgment-day
|
||||
- Run two independent blind reviews of the same target.
|
||||
- Synthesize findings, fix real issues, and re-review until both pass or escalation is needed.
|
||||
- Keep judges focused on correctness, regressions, and requirement coverage.
|
||||
|
||||
### skill-creator
|
||||
- Create skills with clear trigger, concise rules, and progressive disclosure.
|
||||
- Avoid embedding large references in `SKILL.md`; link supporting files instead.
|
||||
- Include actionable constraints and examples only where they prevent mistakes.
|
||||
|
||||
### browser-use:browser
|
||||
- Use the in-app browser for explicit localhost/file/current-tab inspection.
|
||||
- Do not substitute shell `open` or generic browsing for explicit Browser Use requests.
|
||||
- After frontend UI changes, suggest browser testing unless already requested.
|
||||
|
||||
### documents:documents
|
||||
- For `.docx`, render pages to images and visually verify before delivering.
|
||||
- Iterate layout until verified; do not assume generated document layout is correct.
|
||||
|
||||
### presentations:Presentations
|
||||
- Build decks around a clear narrative and chart-first storytelling.
|
||||
- Render and critique slides before final export.
|
||||
|
||||
### spreadsheets:Spreadsheets
|
||||
- Use spreadsheet-native formulas/tables/charts when editing `.xlsx`/CSV workflows.
|
||||
- Recalculate and verify outputs after edits.
|
||||
|
||||
## Project Conventions
|
||||
|
||||
| File | Path | Notes |
|
||||
|------|------|-------|
|
||||
| AGENTS.md | c:/Proyectos/gitea/farolero/AGENTS.md | Flutter/Dart rules: Provider, Clean Architecture, flutter_test, analyze before commit, no Co-Authored-By. |
|
||||
| analysis_options.yaml | c:/Proyectos/gitea/farolero/analysis_options.yaml | Uses `package:flutter_lints/flutter.yaml`. |
|
||||
| pubspec.yaml | c:/Proyectos/gitea/farolero/pubspec.yaml | Flutter app dependencies and asset declarations. |
|
||||
|
||||
Read the convention files listed above for project-specific patterns and rules. All referenced paths have been extracted — no need to read index files to discover more.
|
||||
50
.gga
Normal file
@@ -0,0 +1,50 @@
|
||||
# Gentleman Guardian Angel Configuration
|
||||
# https://github.com/your-org/gga
|
||||
|
||||
# AI Provider (required)
|
||||
# Options: claude, gemini, codex, opencode, ollama:<model>, lmstudio[:model], github:<model>
|
||||
# Examples:
|
||||
# PROVIDER="claude"
|
||||
# PROVIDER="gemini"
|
||||
# PROVIDER="codex"
|
||||
# PROVIDER="opencode"
|
||||
# PROVIDER="opencode:anthropic/claude-opus-4-5"
|
||||
# PROVIDER="ollama:llama3.2"
|
||||
# PROVIDER="ollama:codellama"
|
||||
# PROVIDER="lmstudio"
|
||||
# PROVIDER="lmstudio:qwen2.5-coder-7b-instruct"
|
||||
# PROVIDER="github:gpt-4o"
|
||||
# PROVIDER="github:deepseek-r1"
|
||||
PROVIDER="claude"
|
||||
|
||||
# File patterns to include in review (comma-separated)
|
||||
# Default: * (all files)
|
||||
# Examples:
|
||||
# FILE_PATTERNS="*.ts,*.tsx"
|
||||
# FILE_PATTERNS="*.py"
|
||||
# FILE_PATTERNS="*.go,*.mod"
|
||||
FILE_PATTERNS="*.ts,*.tsx,*.js,*.jsx"
|
||||
|
||||
# File patterns to exclude from review (comma-separated)
|
||||
# Default: none
|
||||
# Examples:
|
||||
# EXCLUDE_PATTERNS="*.test.ts,*.spec.ts"
|
||||
# EXCLUDE_PATTERNS="*_test.go,*.mock.ts"
|
||||
EXCLUDE_PATTERNS="*.test.ts,*.spec.ts,*.test.tsx,*.spec.tsx,*.d.ts"
|
||||
|
||||
# File containing code review rules
|
||||
# Default: AGENTS.md
|
||||
RULES_FILE="AGENTS.md"
|
||||
|
||||
# Strict mode: fail if AI response is ambiguous
|
||||
# Default: true
|
||||
STRICT_MODE="true"
|
||||
|
||||
# Timeout in seconds for AI provider response
|
||||
# Default: 300 (5 minutes)
|
||||
# Increase for large changesets or slow connections
|
||||
TIMEOUT="300"
|
||||
|
||||
# Base branch for --pr-mode (auto-detects main/master/develop if empty)
|
||||
# Default: auto-detect
|
||||
# PR_BASE_BRANCH="main"
|
||||
@@ -5,7 +5,6 @@ on:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
PATH: /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
|
||||
ANDROID_HOME: /Users/freetlab/Library/Android/sdk
|
||||
|
||||
jobs:
|
||||
@@ -14,6 +13,10 @@ jobs:
|
||||
runs-on: [self-hosted, macos, arm64, flutter]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verificar Flutter
|
||||
run: |
|
||||
which flutter
|
||||
flutter --version
|
||||
- name: Obtener dependencias
|
||||
run: flutter pub get
|
||||
- name: Generar l10n
|
||||
@@ -28,6 +31,10 @@ jobs:
|
||||
if: ${{ gitea.ref == 'refs/heads/main' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verificar Flutter
|
||||
run: |
|
||||
which flutter
|
||||
flutter --version
|
||||
|
||||
- name: Fetch completo + Bump versión patch + commit
|
||||
run: |
|
||||
|
||||
2
.gitignore
vendored
@@ -48,3 +48,5 @@ build/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.packages
|
||||
|
||||
.atl/
|
||||
|
||||
16
AGENTS.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Code Review Rules
|
||||
|
||||
## Flutter / Dart
|
||||
|
||||
- Use functional components with Flutter hooks when possible
|
||||
- Follow Clean Architecture: pantallas, modelos, servicios, estado
|
||||
- Use Provider for state management
|
||||
- Use flutter_test for unit testing
|
||||
- Run flutter analyze before committing
|
||||
- Use conventional commits: feat, fix, chore, docs, etc.
|
||||
|
||||
## General
|
||||
|
||||
- No Co-Authored-By in commits
|
||||
- Conventional commit format
|
||||
- Test before push
|
||||
BIN
assets/avatars/avatar_01.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
assets/avatars/avatar_02.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
assets/avatars/avatar_03.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
assets/avatars/avatar_04.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
assets/avatars/avatar_05.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
assets/avatars/avatar_06.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
assets/avatars/avatar_07.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
assets/avatars/avatar_08.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
assets/avatars/avatar_09.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
assets/avatars/avatar_10.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
assets/avatars/avatar_11.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
assets/avatars/avatar_12.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
assets/avatars/avatar_13.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
assets/avatars/avatar_14.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
assets/avatars/avatar_15.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
assets/avatars/avatar_16.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
assets/avatars/avatar_17.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
assets/avatars/avatar_18.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
assets/avatars/avatar_19.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
assets/avatars/avatar_20.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
assets/avatars/avatar_21.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
assets/avatars/avatar_22.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
assets/avatars/avatar_23.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
assets/avatars/avatar_24.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
assets/avatars/avatar_25.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
assets/avatars/avatar_26.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
assets/avatars/avatar_27.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
assets/avatars/avatar_28.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
assets/avatars/avatar_29.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
assets/avatars/avatar_30.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/avatars/capybara_01.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/avatars/capybara_02.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/avatars/capybara_03.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/avatars/capybara_04.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/avatars/capybara_05.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/avatars/capybara_06.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/avatars/capybara_07.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/avatars/capybara_08.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/avatars/capybara_09.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/avatars/capybara_10.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/avatars/capybara_11.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/avatars/capybara_12.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
1056
assets/words/palabras_ar.json
Normal file
1056
assets/words/palabras_ca.json
Normal file
1056
assets/words/palabras_de.json
Normal file
1056
assets/words/palabras_en.json
Normal file
1056
assets/words/palabras_es.json
Normal file
1056
assets/words/palabras_eu.json
Normal file
1056
assets/words/palabras_fr.json
Normal file
1056
assets/words/palabras_hi.json
Normal file
1056
assets/words/palabras_it.json
Normal file
1056
assets/words/palabras_ja.json
Normal file
1056
assets/words/palabras_ko.json
Normal file
1056
assets/words/palabras_nl.json
Normal file
1056
assets/words/palabras_pl.json
Normal file
1056
assets/words/palabras_pt.json
Normal file
1056
assets/words/palabras_ru.json
Normal file
1056
assets/words/palabras_tr.json
Normal file
1056
assets/words/palabras_zh.json
Normal file
1056
assets/words/palabras_zh_TW.json
Normal file
BIN
docs/prototipos/propotipo inicial.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
||||
import '../modelos/jugador.dart';
|
||||
import '../modelos/partida.dart';
|
||||
import '../modelos/palabra.dart';
|
||||
import '../modelos/sala_multijugador.dart';
|
||||
import '../servicios/servicio_notas.dart';
|
||||
|
||||
/// Estado global del juego gestionado con Provider
|
||||
@@ -12,11 +13,17 @@ class EstadoJuego extends ChangeNotifier {
|
||||
final Map<String, String> _votos = {}; // votanteId -> votadoId
|
||||
bool _cargando = false;
|
||||
|
||||
/// Jugador local del host en modo multi-dispositivo
|
||||
Jugador? _hostLocal;
|
||||
|
||||
BancoPalabras? get banco => _banco;
|
||||
Partida? get partida => _partida;
|
||||
Map<String, String> get votos => Map.unmodifiable(_votos);
|
||||
bool get cargando => _cargando;
|
||||
|
||||
/// Jugador local del host (para modo multi-dispositivo)
|
||||
Jugador? get hostLocal => _hostLocal;
|
||||
|
||||
Future<void> cargarBanco() async {
|
||||
_cargando = true;
|
||||
notifyListeners();
|
||||
@@ -25,6 +32,16 @@ class EstadoJuego extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Establece el jugador local del host para modo multi-dispositivo
|
||||
void setHostJugador(String nombre) {
|
||||
_hostLocal = Jugador(
|
||||
id: 'host-local',
|
||||
nombre: nombre,
|
||||
endpointId: null, // El host local no tiene endpointId
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Crea una nueva partida con la configuración dada y lista de jugadores
|
||||
void crearPartida({
|
||||
required ConfigPartida config,
|
||||
@@ -73,6 +90,61 @@ class EstadoJuego extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Crea una partida multi-dispositivo usando los usuarios seleccionados de la
|
||||
/// sala como jugadores reales. La identidad de jugador se conserva por id y
|
||||
/// cada jugador queda asociado al endpoint del cliente que lo controla.
|
||||
void crearPartidaDesdeSala({
|
||||
required ConfigPartida config,
|
||||
required EstadoSalaMultijugador sala,
|
||||
}) {
|
||||
if (_banco == null) return;
|
||||
final usuariosSeleccionados = sala.usuariosSeleccionados;
|
||||
if (usuariosSeleccionados.length < 3) return;
|
||||
|
||||
final palabra = _banco!.palabraAleatoria(config.categoria);
|
||||
final categoriaReal =
|
||||
_banco!.categoriaDepalabra(palabra) ?? config.categoria;
|
||||
|
||||
final jugadores = usuariosSeleccionados.map((usuario) {
|
||||
final clienteId = usuario.clienteIdSeleccionado;
|
||||
final endpointId = clienteId == null
|
||||
? null
|
||||
: sala.clientes[clienteId]?.endpointId;
|
||||
return Jugador(
|
||||
id: usuario.id,
|
||||
nombre: usuario.nombre,
|
||||
endpointId: endpointId,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
final rng = Random.secure();
|
||||
final numImpostores = config.numImpostores.clamp(1, jugadores.length ~/ 3);
|
||||
final impostoresElegidos = <int>{};
|
||||
while (impostoresElegidos.length < numImpostores) {
|
||||
impostoresElegidos.add(rng.nextInt(jugadores.length));
|
||||
}
|
||||
for (final i in impostoresElegidos) {
|
||||
jugadores[i].esImpostor = true;
|
||||
}
|
||||
|
||||
for (final jugador in jugadores) {
|
||||
if (!jugador.esImpostor) {
|
||||
jugador.palabra = palabra;
|
||||
}
|
||||
}
|
||||
|
||||
_partida = Partida(
|
||||
config: config,
|
||||
jugadores: jugadores,
|
||||
palabraSecreta: palabra,
|
||||
categoriaReal: categoriaReal,
|
||||
);
|
||||
|
||||
_votos.clear();
|
||||
ServicioNotas.limpiarNotas();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Avanza a la fase de debate
|
||||
void iniciarDebate() {
|
||||
if (_partida == null) return;
|
||||
|
||||
@@ -255,5 +255,11 @@
|
||||
"searchingGames": "Searching for nearby games...",
|
||||
"noGamesFound": "No games found",
|
||||
"noGamesFoundHint": "Make sure the host has the room open and you are nearby",
|
||||
"orScanQR": "Not showing up? Scan the host's QR code"
|
||||
}
|
||||
"orScanQR": "Not showing up? Scan the host's QR code",
|
||||
"selectYourProfile": "Your profile",
|
||||
"selectProfile": "Select a profile",
|
||||
"createNewUser": "Create new user",
|
||||
"userNameRequired": "Name cannot be empty",
|
||||
"profileSelected": "Profile selected",
|
||||
"availableProfiles": "Available profiles"
|
||||
}
|
||||
|
||||
@@ -277,5 +277,11 @@
|
||||
"votacionSolicitada": "Votación solicitada",
|
||||
"whoDoYouThinkIsTheImpostor": "¿Quién es el impostor?",
|
||||
"selectOnePlayer": "Selecciona a un jugador para votar",
|
||||
"votar": "Votar"
|
||||
}
|
||||
"votar": "Votar",
|
||||
"selectYourProfile": "Tu perfil",
|
||||
"selectProfile": "Selecciona un perfil",
|
||||
"createNewUser": "Crear nuevo usuario",
|
||||
"userNameRequired": "El nombre no puede estar vacio",
|
||||
"profileSelected": "Perfil seleccionado",
|
||||
"availableProfiles": "Perfiles disponibles"
|
||||
}
|
||||
|
||||
@@ -1196,6 +1196,42 @@ abstract class AppLocalizations {
|
||||
/// In es, this message translates to:
|
||||
/// **'Votar'**
|
||||
String get votar;
|
||||
|
||||
/// No description provided for @selectYourProfile.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Tu perfil'**
|
||||
String get selectYourProfile;
|
||||
|
||||
/// No description provided for @selectProfile.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Selecciona un perfil'**
|
||||
String get selectProfile;
|
||||
|
||||
/// No description provided for @createNewUser.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Crear nuevo usuario'**
|
||||
String get createNewUser;
|
||||
|
||||
/// No description provided for @userNameRequired.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'El nombre no puede estar vacio'**
|
||||
String get userNameRequired;
|
||||
|
||||
/// No description provided for @profileSelected.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Perfil seleccionado'**
|
||||
String get profileSelected;
|
||||
|
||||
/// No description provided for @availableProfiles.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Perfiles disponibles'**
|
||||
String get availableProfiles;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -579,4 +579,22 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get votar => 'Votar';
|
||||
|
||||
@override
|
||||
String get selectYourProfile => 'Tu perfil';
|
||||
|
||||
@override
|
||||
String get selectProfile => 'Selecciona un perfil';
|
||||
|
||||
@override
|
||||
String get createNewUser => 'Crear nuevo usuario';
|
||||
|
||||
@override
|
||||
String get userNameRequired => 'El nombre no puede estar vacio';
|
||||
|
||||
@override
|
||||
String get profileSelected => 'Perfil seleccionado';
|
||||
|
||||
@override
|
||||
String get availableProfiles => 'Perfiles disponibles';
|
||||
}
|
||||
|
||||
@@ -582,4 +582,22 @@ class AppLocalizationsCa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get votar => 'Votar';
|
||||
|
||||
@override
|
||||
String get selectYourProfile => 'Tu perfil';
|
||||
|
||||
@override
|
||||
String get selectProfile => 'Selecciona un perfil';
|
||||
|
||||
@override
|
||||
String get createNewUser => 'Crear nuevo usuario';
|
||||
|
||||
@override
|
||||
String get userNameRequired => 'El nombre no puede estar vacio';
|
||||
|
||||
@override
|
||||
String get profileSelected => 'Perfil seleccionado';
|
||||
|
||||
@override
|
||||
String get availableProfiles => 'Perfiles disponibles';
|
||||
}
|
||||
|
||||
@@ -585,4 +585,22 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get votar => 'Votar';
|
||||
|
||||
@override
|
||||
String get selectYourProfile => 'Tu perfil';
|
||||
|
||||
@override
|
||||
String get selectProfile => 'Selecciona un perfil';
|
||||
|
||||
@override
|
||||
String get createNewUser => 'Crear nuevo usuario';
|
||||
|
||||
@override
|
||||
String get userNameRequired => 'El nombre no puede estar vacio';
|
||||
|
||||
@override
|
||||
String get profileSelected => 'Perfil seleccionado';
|
||||
|
||||
@override
|
||||
String get availableProfiles => 'Perfiles disponibles';
|
||||
}
|
||||
|
||||
@@ -579,4 +579,22 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get votar => 'Votar';
|
||||
|
||||
@override
|
||||
String get selectYourProfile => 'Your profile';
|
||||
|
||||
@override
|
||||
String get selectProfile => 'Select a profile';
|
||||
|
||||
@override
|
||||
String get createNewUser => 'Create new user';
|
||||
|
||||
@override
|
||||
String get userNameRequired => 'Name cannot be empty';
|
||||
|
||||
@override
|
||||
String get profileSelected => 'Profile selected';
|
||||
|
||||
@override
|
||||
String get availableProfiles => 'Available profiles';
|
||||
}
|
||||
|
||||
@@ -581,4 +581,22 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get votar => 'Votar';
|
||||
|
||||
@override
|
||||
String get selectYourProfile => 'Tu perfil';
|
||||
|
||||
@override
|
||||
String get selectProfile => 'Selecciona un perfil';
|
||||
|
||||
@override
|
||||
String get createNewUser => 'Crear nuevo usuario';
|
||||
|
||||
@override
|
||||
String get userNameRequired => 'El nombre no puede estar vacio';
|
||||
|
||||
@override
|
||||
String get profileSelected => 'Perfil seleccionado';
|
||||
|
||||
@override
|
||||
String get availableProfiles => 'Perfiles disponibles';
|
||||
}
|
||||
|
||||
@@ -584,4 +584,22 @@ class AppLocalizationsEu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get votar => 'Votar';
|
||||
|
||||
@override
|
||||
String get selectYourProfile => 'Tu perfil';
|
||||
|
||||
@override
|
||||
String get selectProfile => 'Selecciona un perfil';
|
||||
|
||||
@override
|
||||
String get createNewUser => 'Crear nuevo usuario';
|
||||
|
||||
@override
|
||||
String get userNameRequired => 'El nombre no puede estar vacio';
|
||||
|
||||
@override
|
||||
String get profileSelected => 'Perfil seleccionado';
|
||||
|
||||
@override
|
||||
String get availableProfiles => 'Perfiles disponibles';
|
||||
}
|
||||
|
||||
@@ -582,4 +582,22 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get votar => 'Votar';
|
||||
|
||||
@override
|
||||
String get selectYourProfile => 'Tu perfil';
|
||||
|
||||
@override
|
||||
String get selectProfile => 'Selecciona un perfil';
|
||||
|
||||
@override
|
||||
String get createNewUser => 'Crear nuevo usuario';
|
||||
|
||||
@override
|
||||
String get userNameRequired => 'El nombre no puede estar vacio';
|
||||
|
||||
@override
|
||||
String get profileSelected => 'Perfil seleccionado';
|
||||
|
||||
@override
|
||||
String get availableProfiles => 'Perfiles disponibles';
|
||||
}
|
||||
|
||||
@@ -581,4 +581,22 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get votar => 'Votar';
|
||||
|
||||
@override
|
||||
String get selectYourProfile => 'Tu perfil';
|
||||
|
||||
@override
|
||||
String get selectProfile => 'Selecciona un perfil';
|
||||
|
||||
@override
|
||||
String get createNewUser => 'Crear nuevo usuario';
|
||||
|
||||
@override
|
||||
String get userNameRequired => 'El nombre no puede estar vacio';
|
||||
|
||||
@override
|
||||
String get profileSelected => 'Perfil seleccionado';
|
||||
|
||||
@override
|
||||
String get availableProfiles => 'Perfiles disponibles';
|
||||
}
|
||||
|
||||
@@ -582,4 +582,22 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get votar => 'Votar';
|
||||
|
||||
@override
|
||||
String get selectYourProfile => 'Tu perfil';
|
||||
|
||||
@override
|
||||
String get selectProfile => 'Selecciona un perfil';
|
||||
|
||||
@override
|
||||
String get createNewUser => 'Crear nuevo usuario';
|
||||
|
||||
@override
|
||||
String get userNameRequired => 'El nombre no puede estar vacio';
|
||||
|
||||
@override
|
||||
String get profileSelected => 'Perfil seleccionado';
|
||||
|
||||
@override
|
||||
String get availableProfiles => 'Perfiles disponibles';
|
||||
}
|
||||
|
||||
@@ -579,4 +579,22 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get votar => 'Votar';
|
||||
|
||||
@override
|
||||
String get selectYourProfile => 'Tu perfil';
|
||||
|
||||
@override
|
||||
String get selectProfile => 'Selecciona un perfil';
|
||||
|
||||
@override
|
||||
String get createNewUser => 'Crear nuevo usuario';
|
||||
|
||||
@override
|
||||
String get userNameRequired => 'El nombre no puede estar vacio';
|
||||
|
||||
@override
|
||||
String get profileSelected => 'Perfil seleccionado';
|
||||
|
||||
@override
|
||||
String get availableProfiles => 'Perfiles disponibles';
|
||||
}
|
||||
|
||||
@@ -579,4 +579,22 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get votar => 'Votar';
|
||||
|
||||
@override
|
||||
String get selectYourProfile => 'Tu perfil';
|
||||
|
||||
@override
|
||||
String get selectProfile => 'Selecciona un perfil';
|
||||
|
||||
@override
|
||||
String get createNewUser => 'Crear nuevo usuario';
|
||||
|
||||
@override
|
||||
String get userNameRequired => 'El nombre no puede estar vacio';
|
||||
|
||||
@override
|
||||
String get profileSelected => 'Perfil seleccionado';
|
||||
|
||||
@override
|
||||
String get availableProfiles => 'Perfiles disponibles';
|
||||
}
|
||||
|
||||
@@ -582,4 +582,22 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get votar => 'Votar';
|
||||
|
||||
@override
|
||||
String get selectYourProfile => 'Tu perfil';
|
||||
|
||||
@override
|
||||
String get selectProfile => 'Selecciona un perfil';
|
||||
|
||||
@override
|
||||
String get createNewUser => 'Crear nuevo usuario';
|
||||
|
||||
@override
|
||||
String get userNameRequired => 'El nombre no puede estar vacio';
|
||||
|
||||
@override
|
||||
String get profileSelected => 'Perfil seleccionado';
|
||||
|
||||
@override
|
||||
String get availableProfiles => 'Perfiles disponibles';
|
||||
}
|
||||
|
||||
@@ -582,4 +582,22 @@ class AppLocalizationsPl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get votar => 'Votar';
|
||||
|
||||
@override
|
||||
String get selectYourProfile => 'Tu perfil';
|
||||
|
||||
@override
|
||||
String get selectProfile => 'Selecciona un perfil';
|
||||
|
||||
@override
|
||||
String get createNewUser => 'Crear nuevo usuario';
|
||||
|
||||
@override
|
||||
String get userNameRequired => 'El nombre no puede estar vacio';
|
||||
|
||||
@override
|
||||
String get profileSelected => 'Perfil seleccionado';
|
||||
|
||||
@override
|
||||
String get availableProfiles => 'Perfiles disponibles';
|
||||
}
|
||||
|
||||
@@ -583,4 +583,22 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get votar => 'Votar';
|
||||
|
||||
@override
|
||||
String get selectYourProfile => 'Tu perfil';
|
||||
|
||||
@override
|
||||
String get selectProfile => 'Selecciona un perfil';
|
||||
|
||||
@override
|
||||
String get createNewUser => 'Crear nuevo usuario';
|
||||
|
||||
@override
|
||||
String get userNameRequired => 'El nombre no puede estar vacio';
|
||||
|
||||
@override
|
||||
String get profileSelected => 'Perfil seleccionado';
|
||||
|
||||
@override
|
||||
String get availableProfiles => 'Perfiles disponibles';
|
||||
}
|
||||
|
||||
@@ -582,4 +582,22 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get votar => 'Votar';
|
||||
|
||||
@override
|
||||
String get selectYourProfile => 'Tu perfil';
|
||||
|
||||
@override
|
||||
String get selectProfile => 'Selecciona un perfil';
|
||||
|
||||
@override
|
||||
String get createNewUser => 'Crear nuevo usuario';
|
||||
|
||||
@override
|
||||
String get userNameRequired => 'El nombre no puede estar vacio';
|
||||
|
||||
@override
|
||||
String get profileSelected => 'Perfil seleccionado';
|
||||
|
||||
@override
|
||||
String get availableProfiles => 'Perfiles disponibles';
|
||||
}
|
||||
|
||||
@@ -581,4 +581,22 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get votar => 'Votar';
|
||||
|
||||
@override
|
||||
String get selectYourProfile => 'Tu perfil';
|
||||
|
||||
@override
|
||||
String get selectProfile => 'Selecciona un perfil';
|
||||
|
||||
@override
|
||||
String get createNewUser => 'Crear nuevo usuario';
|
||||
|
||||
@override
|
||||
String get userNameRequired => 'El nombre no puede estar vacio';
|
||||
|
||||
@override
|
||||
String get profileSelected => 'Perfil seleccionado';
|
||||
|
||||
@override
|
||||
String get availableProfiles => 'Perfiles disponibles';
|
||||
}
|
||||
|
||||
@@ -578,6 +578,24 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get votar => 'Votar';
|
||||
|
||||
@override
|
||||
String get selectYourProfile => 'Tu perfil';
|
||||
|
||||
@override
|
||||
String get selectProfile => 'Selecciona un perfil';
|
||||
|
||||
@override
|
||||
String get createNewUser => 'Crear nuevo usuario';
|
||||
|
||||
@override
|
||||
String get userNameRequired => 'El nombre no puede estar vacio';
|
||||
|
||||
@override
|
||||
String get profileSelected => 'Perfil seleccionado';
|
||||
|
||||
@override
|
||||
String get availableProfiles => 'Perfiles disponibles';
|
||||
}
|
||||
|
||||
/// The translations for Chinese, as used in Taiwan (`zh_TW`).
|
||||
|
||||
@@ -4,8 +4,11 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:farolero/l10n/generated/app_localizations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'estado/estado_juego.dart';
|
||||
import 'servicios/servicio_historial_partidas.dart';
|
||||
import 'servicios/servicio_idioma.dart';
|
||||
import 'servicios/servicio_nearby.dart';
|
||||
import 'servicios/servicio_perfil_usuario.dart';
|
||||
import 'tema/componentes_farolero.dart';
|
||||
import 'tema/tema_app.dart';
|
||||
import 'pantallas/pantalla_principal.dart';
|
||||
|
||||
@@ -35,6 +38,12 @@ class FaroleroApp extends StatelessWidget {
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => ServicioIdioma()..cargar(),
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => ServicioPerfilUsuario()..cargar(),
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => ServicioHistorialPartidas()..cargar(),
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => ServicioNearby(),
|
||||
),
|
||||
@@ -71,24 +80,42 @@ class PantallaCarga extends StatelessWidget {
|
||||
|
||||
if (estado.cargando || estado.banco == null) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text('🎭', style: TextStyle(fontSize: 72)),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
l10n?.appTitle ?? 'Farolero',
|
||||
style: Theme.of(context).textTheme.headlineLarge,
|
||||
body: FondoFarolero(
|
||||
intenso: true,
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 34, vertical: 28),
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(flex: 2),
|
||||
const Icon(
|
||||
Icons.lightbulb,
|
||||
color: TemaApp.colorNaranja,
|
||||
size: 86,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
const LogoFarolero(size: 58),
|
||||
const Spacer(flex: 3),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: const LinearProgressIndicator(
|
||||
minHeight: 8,
|
||||
color: TemaApp.colorNaranja,
|
||||
backgroundColor: TemaApp.colorSuperficie,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
l10n?.loadingWords ?? 'Cargando palabras...',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: TemaApp.colorDorado,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const CircularProgressIndicator(color: TemaApp.colorAcento),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n?.loadingWords ?? 'Cargando palabras...',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
117
lib/modelos/inicio_partida_multijugador.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
class AsignacionJugador {
|
||||
final String jugadorId;
|
||||
final String nombre;
|
||||
final String clientId;
|
||||
final String? endpointId;
|
||||
|
||||
const AsignacionJugador({
|
||||
required this.jugadorId,
|
||||
required this.nombre,
|
||||
required this.clientId,
|
||||
required this.endpointId,
|
||||
});
|
||||
}
|
||||
|
||||
class JugadorInicioPartida {
|
||||
final String jugadorId;
|
||||
final String nombre;
|
||||
final bool esImpostor;
|
||||
final String? palabra;
|
||||
|
||||
const JugadorInicioPartida({
|
||||
required this.jugadorId,
|
||||
required this.nombre,
|
||||
required this.esImpostor,
|
||||
required this.palabra,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'jugadorId': jugadorId,
|
||||
'nombre': nombre,
|
||||
'esImpostor': esImpostor,
|
||||
if (palabra != null) 'palabra': palabra,
|
||||
};
|
||||
|
||||
factory JugadorInicioPartida.fromJson(Map<String, dynamic> json) {
|
||||
return JugadorInicioPartida(
|
||||
jugadorId: json['jugadorId'] as String,
|
||||
nombre: json['nombre'] as String,
|
||||
esImpostor: json['esImpostor'] as bool? ?? false,
|
||||
palabra: json['palabra'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InicioPartidaCliente {
|
||||
final String clientId;
|
||||
final String? endpointId;
|
||||
final String categoria;
|
||||
final List<JugadorInicioPartida> jugadores;
|
||||
|
||||
const InicioPartidaCliente({
|
||||
required this.clientId,
|
||||
required this.endpointId,
|
||||
required this.categoria,
|
||||
required this.jugadores,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'clientId': clientId,
|
||||
if (endpointId != null) 'endpointId': endpointId,
|
||||
'categoria': categoria,
|
||||
'jugadores': jugadores.map((jugador) => jugador.toJson()).toList(),
|
||||
};
|
||||
|
||||
factory InicioPartidaCliente.fromJson(Map<String, dynamic> json) {
|
||||
return InicioPartidaCliente(
|
||||
clientId: json['clientId'] as String,
|
||||
endpointId: json['endpointId'] as String?,
|
||||
categoria: json['categoria'] as String,
|
||||
jugadores: (json['jugadores'] as List<dynamic>? ?? [])
|
||||
.map((jugadorJson) => JugadorInicioPartida.fromJson(
|
||||
jugadorJson as Map<String, dynamic>,
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InicioPartidaMultijugador {
|
||||
static Map<String, InicioPartidaCliente> crearPayloadsPorCliente({
|
||||
required List<AsignacionJugador> asignaciones,
|
||||
required String palabraSecreta,
|
||||
required String categoria,
|
||||
required Map<String, bool> impostoresPorJugadorId,
|
||||
}) {
|
||||
final payloads = <String, InicioPartidaCliente>{};
|
||||
|
||||
for (final asignacion in asignaciones) {
|
||||
final esImpostor = impostoresPorJugadorId[asignacion.jugadorId] ?? false;
|
||||
final payloadActual = payloads[asignacion.clientId];
|
||||
final jugador = JugadorInicioPartida(
|
||||
jugadorId: asignacion.jugadorId,
|
||||
nombre: asignacion.nombre,
|
||||
esImpostor: esImpostor,
|
||||
palabra: esImpostor ? null : palabraSecreta,
|
||||
);
|
||||
|
||||
if (payloadActual == null) {
|
||||
payloads[asignacion.clientId] = InicioPartidaCliente(
|
||||
clientId: asignacion.clientId,
|
||||
endpointId: asignacion.endpointId,
|
||||
categoria: categoria,
|
||||
jugadores: [jugador],
|
||||
);
|
||||
} else {
|
||||
payloads[asignacion.clientId] = InicioPartidaCliente(
|
||||
clientId: payloadActual.clientId,
|
||||
endpointId: payloadActual.endpointId,
|
||||
categoria: payloadActual.categoria,
|
||||
jugadores: [...payloadActual.jugadores, jugador],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return payloads;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,61 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:farolero/l10n/generated/app_localizations.dart';
|
||||
|
||||
/// Categorías disponibles en el banco de palabras
|
||||
/// Categorías disponibles en el banco de palabras.
|
||||
class BancoPalabras {
|
||||
final Map<String, List<String>> categorias;
|
||||
final Map<String, String> pistasPorCategoria;
|
||||
|
||||
BancoPalabras(this.categorias);
|
||||
BancoPalabras(this.categorias, {Map<String, String>? pistasPorCategoria})
|
||||
: pistasPorCategoria = pistasPorCategoria ?? {};
|
||||
|
||||
static final Map<String, BancoPalabras> _instancias = {};
|
||||
|
||||
static Future<BancoPalabras> cargar({String idioma = 'es'}) async {
|
||||
if (_instancias.containsKey(idioma)) return _instancias[idioma]!;
|
||||
|
||||
// Intentar cargar el banco del idioma solicitado, fallback a castellano
|
||||
String jsonStr;
|
||||
try {
|
||||
final archivo = idioma == 'es'
|
||||
? 'assets/palabras.json'
|
||||
: 'assets/palabras_$idioma.json';
|
||||
jsonStr = await rootBundle.loadString(archivo);
|
||||
jsonStr = await rootBundle.loadString(
|
||||
'assets/words/palabras_$idioma.json',
|
||||
);
|
||||
} catch (_) {
|
||||
// Fallback a castellano si no existe el banco para ese idioma
|
||||
if (idioma != 'es') {
|
||||
return cargar(idioma: 'es');
|
||||
try {
|
||||
final archivoLegacy = idioma == 'es'
|
||||
? 'assets/palabras.json'
|
||||
: 'assets/palabras_$idioma.json';
|
||||
jsonStr = await rootBundle.loadString(archivoLegacy);
|
||||
} catch (_) {
|
||||
if (idioma != 'es') return cargar(idioma: 'es');
|
||||
rethrow;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
|
||||
final data = json.decode(jsonStr) as Map<String, dynamic>;
|
||||
final cats = data['categorias'] as Map<String, dynamic>;
|
||||
final mapa = <String, List<String>>{};
|
||||
final pistas = <String, String>{};
|
||||
|
||||
for (final entrada in cats.entries) {
|
||||
mapa[entrada.key] = List<String>.from(entrada.value);
|
||||
final valor = entrada.value;
|
||||
if (valor is Map<String, dynamic>) {
|
||||
mapa[entrada.key] = List<String>.from(valor['palabras'] as List);
|
||||
final pista = valor['pista'];
|
||||
if (pista is String && pista.isNotEmpty) pistas[entrada.key] = pista;
|
||||
} else {
|
||||
mapa[entrada.key] = List<String>.from(valor as List);
|
||||
}
|
||||
}
|
||||
_instancias[idioma] = BancoPalabras(mapa);
|
||||
|
||||
_instancias[idioma] = BancoPalabras(mapa, pistasPorCategoria: pistas);
|
||||
return _instancias[idioma]!;
|
||||
}
|
||||
|
||||
List<String> get nombresCategorias => categorias.keys.toList();
|
||||
|
||||
/// Obtiene una palabra aleatoria de la categoría dada (o de todas si es null)
|
||||
/// Obtiene una palabra aleatoria de la categoría dada (o de todas si es null).
|
||||
String palabraAleatoria(String? categoria) {
|
||||
final rng = Random();
|
||||
if (categoria == null || categoria == 'todas') {
|
||||
@@ -52,7 +66,7 @@ class BancoPalabras {
|
||||
return lista[rng.nextInt(lista.length)];
|
||||
}
|
||||
|
||||
/// Devuelve la categoría a la que pertenece una palabra
|
||||
/// Devuelve la categoría a la que pertenece una palabra.
|
||||
String? categoriaDepalabra(String palabra) {
|
||||
for (final entrada in categorias.entries) {
|
||||
if (entrada.value.contains(palabra)) return entrada.key;
|
||||
@@ -60,7 +74,10 @@ class BancoPalabras {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Devuelve el nombre localizado de la categoría usando AppLocalizations
|
||||
/// Devuelve la pista localizada de una categoría si el banco la trae.
|
||||
String? pistaDeCategoria(String categoria) => pistasPorCategoria[categoria];
|
||||
|
||||
/// Devuelve el nombre localizado de la categoría usando AppLocalizations.
|
||||
static String nombreBonitoCategoria(String clave, [AppLocalizations? l10n]) {
|
||||
if (l10n != null) {
|
||||
final nombres = {
|
||||
@@ -78,7 +95,6 @@ class BancoPalabras {
|
||||
};
|
||||
return nombres[clave] ?? clave;
|
||||
}
|
||||
// Fallback a castellano si no hay l10n
|
||||
const nombres = {
|
||||
'todas': 'Todas',
|
||||
'animales': 'Animales',
|
||||
@@ -95,3 +111,34 @@ class BancoPalabras {
|
||||
return nombres[clave] ?? clave;
|
||||
}
|
||||
}
|
||||
|
||||
class EntradaPalabraTraducida {
|
||||
final String palabra;
|
||||
final String pista;
|
||||
|
||||
const EntradaPalabraTraducida({required this.palabra, required this.pista});
|
||||
}
|
||||
|
||||
class BancoPalabrasTraducidas {
|
||||
final Map<String, List<EntradaPalabraTraducida>> categorias;
|
||||
|
||||
const BancoPalabrasTraducidas(this.categorias);
|
||||
|
||||
static final Map<String, BancoPalabrasTraducidas> _instancias = {};
|
||||
|
||||
static Future<BancoPalabrasTraducidas> cargar({String idioma = 'es'}) async {
|
||||
if (_instancias.containsKey(idioma)) return _instancias[idioma]!;
|
||||
|
||||
final banco = await BancoPalabras.cargar(idioma: idioma);
|
||||
final mapa = <String, List<EntradaPalabraTraducida>>{};
|
||||
for (final categoria in banco.categorias.entries) {
|
||||
final pista = banco.pistaDeCategoria(categoria.key) ?? categoria.key;
|
||||
mapa[categoria.key] = categoria.value
|
||||
.map((palabra) => EntradaPalabraTraducida(palabra: palabra, pista: pista))
|
||||
.toList();
|
||||
}
|
||||
|
||||
_instancias[idioma] = BancoPalabrasTraducidas(mapa);
|
||||
return _instancias[idioma]!;
|
||||
}
|
||||
}
|
||||
|
||||
294
lib/modelos/sala_multijugador.dart
Normal file
@@ -0,0 +1,294 @@
|
||||
import 'usuario.dart';
|
||||
|
||||
enum FaseSalaMultijugador { lobby, enPartida, finalizada }
|
||||
|
||||
class ResultadoOperacionSala {
|
||||
final bool exitoso;
|
||||
final String? codigo;
|
||||
final String? mensaje;
|
||||
|
||||
const ResultadoOperacionSala._({
|
||||
required this.exitoso,
|
||||
this.codigo,
|
||||
this.mensaje,
|
||||
});
|
||||
|
||||
const ResultadoOperacionSala.ok([String? mensaje])
|
||||
: this._(exitoso: true, mensaje: mensaje);
|
||||
|
||||
const ResultadoOperacionSala.error(String codigo, [String? mensaje])
|
||||
: this._(exitoso: false, codigo: codigo, mensaje: mensaje);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'exitoso': exitoso,
|
||||
if (codigo != null) 'codigo': codigo,
|
||||
if (mensaje != null) 'mensaje': mensaje,
|
||||
};
|
||||
}
|
||||
|
||||
class ClienteSala {
|
||||
final String clientId;
|
||||
final String? endpointId;
|
||||
final String nombre;
|
||||
final bool esHost;
|
||||
final bool conectado;
|
||||
|
||||
const ClienteSala({
|
||||
required this.clientId,
|
||||
this.endpointId,
|
||||
required this.nombre,
|
||||
this.esHost = false,
|
||||
this.conectado = true,
|
||||
});
|
||||
|
||||
ClienteSala copiar({
|
||||
String? clientId,
|
||||
String? endpointId,
|
||||
String? nombre,
|
||||
bool? esHost,
|
||||
bool? conectado,
|
||||
}) {
|
||||
return ClienteSala(
|
||||
clientId: clientId ?? this.clientId,
|
||||
endpointId: endpointId ?? this.endpointId,
|
||||
nombre: nombre ?? this.nombre,
|
||||
esHost: esHost ?? this.esHost,
|
||||
conectado: conectado ?? this.conectado,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'clientId': clientId,
|
||||
if (endpointId != null) 'endpointId': endpointId,
|
||||
'nombre': nombre,
|
||||
'esHost': esHost,
|
||||
'conectado': conectado,
|
||||
};
|
||||
|
||||
factory ClienteSala.fromJson(Map<String, dynamic> json) => ClienteSala(
|
||||
clientId: json['clientId'] as String,
|
||||
endpointId: json['endpointId'] as String?,
|
||||
nombre: json['nombre'] as String,
|
||||
esHost: json['esHost'] as bool? ?? false,
|
||||
conectado: json['conectado'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
class EstadoSalaMultijugador {
|
||||
final String roomId;
|
||||
final String nombreSala;
|
||||
final String hostClientId;
|
||||
FaseSalaMultijugador fase;
|
||||
final Map<String, ClienteSala> clientes;
|
||||
final Map<String, Usuario> usuarios;
|
||||
|
||||
EstadoSalaMultijugador({
|
||||
required this.roomId,
|
||||
required this.nombreSala,
|
||||
required this.hostClientId,
|
||||
this.fase = FaseSalaMultijugador.lobby,
|
||||
Map<String, ClienteSala>? clientes,
|
||||
Map<String, Usuario>? usuarios,
|
||||
}) : clientes = clientes ?? {},
|
||||
usuarios = usuarios ?? {};
|
||||
|
||||
factory EstadoSalaMultijugador.crear({
|
||||
required String roomId,
|
||||
required String nombreSala,
|
||||
required String hostClientId,
|
||||
required String hostNombre,
|
||||
}) {
|
||||
final sala = EstadoSalaMultijugador(
|
||||
roomId: roomId,
|
||||
nombreSala: nombreSala,
|
||||
hostClientId: hostClientId,
|
||||
);
|
||||
sala.registrarCliente(
|
||||
ClienteSala(
|
||||
clientId: hostClientId,
|
||||
nombre: hostNombre,
|
||||
esHost: true,
|
||||
),
|
||||
);
|
||||
return sala;
|
||||
}
|
||||
|
||||
List<Usuario> get usuariosSeleccionados =>
|
||||
usuarios.values.where((usuario) => usuario.estaSeleccionado).toList();
|
||||
|
||||
List<Usuario> get usuariosDisponibles =>
|
||||
usuarios.values.where((usuario) => usuario.estaDisponible).toList();
|
||||
|
||||
int get cantidadUsuariosSeleccionados => usuariosSeleccionados.length;
|
||||
|
||||
List<Usuario> usuariosPorCliente(String clientId) {
|
||||
return usuarios.values
|
||||
.where((usuario) => usuario.clienteIdSeleccionado == clientId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
ClienteSala? clientePorEndpoint(String endpointId) {
|
||||
for (final cliente in clientes.values) {
|
||||
if (cliente.endpointId == endpointId) return cliente;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
ResultadoOperacionSala registrarCliente(ClienteSala cliente) {
|
||||
clientes[cliente.clientId] = cliente;
|
||||
return const ResultadoOperacionSala.ok();
|
||||
}
|
||||
|
||||
ResultadoOperacionSala crearUsuario(Usuario usuario) {
|
||||
if (fase != FaseSalaMultijugador.lobby) {
|
||||
return const ResultadoOperacionSala.error('sala_cerrada');
|
||||
}
|
||||
if (usuarios.containsKey(usuario.id)) {
|
||||
return const ResultadoOperacionSala.error('usuario_duplicado');
|
||||
}
|
||||
final nombreNormalizado = usuario.nombre.trim().toLowerCase();
|
||||
final nombreExiste = usuarios.values.any(
|
||||
(u) => u.nombre.trim().toLowerCase() == nombreNormalizado,
|
||||
);
|
||||
if (nombreExiste) {
|
||||
return const ResultadoOperacionSala.error('nombre_duplicado');
|
||||
}
|
||||
usuarios[usuario.id] = usuario;
|
||||
return const ResultadoOperacionSala.ok();
|
||||
}
|
||||
|
||||
ResultadoOperacionSala seleccionarUsuario({
|
||||
required String usuarioId,
|
||||
required String clienteId,
|
||||
}) {
|
||||
if (fase != FaseSalaMultijugador.lobby) {
|
||||
return const ResultadoOperacionSala.error('sala_cerrada');
|
||||
}
|
||||
final cliente = clientes[clienteId];
|
||||
if (cliente == null || !cliente.conectado) {
|
||||
return const ResultadoOperacionSala.error('cliente_no_disponible');
|
||||
}
|
||||
final usuario = usuarios[usuarioId];
|
||||
if (usuario == null) {
|
||||
return const ResultadoOperacionSala.error('usuario_no_existe');
|
||||
}
|
||||
if (usuario.clienteIdSeleccionado == clienteId) {
|
||||
return const ResultadoOperacionSala.ok();
|
||||
}
|
||||
if (usuario.clienteIdSeleccionado != null) {
|
||||
return const ResultadoOperacionSala.error('usuario_ya_seleccionado');
|
||||
}
|
||||
usuarios[usuarioId] = usuario.copiar(clienteIdSeleccionado: clienteId);
|
||||
return const ResultadoOperacionSala.ok();
|
||||
}
|
||||
|
||||
ResultadoOperacionSala liberarUsuario({
|
||||
required String usuarioId,
|
||||
required String solicitanteClientId,
|
||||
}) {
|
||||
if (fase != FaseSalaMultijugador.lobby) {
|
||||
return const ResultadoOperacionSala.error('sala_cerrada');
|
||||
}
|
||||
final usuario = usuarios[usuarioId];
|
||||
if (usuario == null) {
|
||||
return const ResultadoOperacionSala.error('usuario_no_existe');
|
||||
}
|
||||
final solicitante = clientes[solicitanteClientId];
|
||||
final puedeLiberar =
|
||||
usuario.clienteIdSeleccionado == solicitanteClientId ||
|
||||
(solicitante?.esHost ?? false);
|
||||
if (!puedeLiberar) {
|
||||
return const ResultadoOperacionSala.error('usuario_de_otro_cliente');
|
||||
}
|
||||
usuarios[usuarioId] = usuario.copiar(liberarSeleccion: true);
|
||||
return const ResultadoOperacionSala.ok();
|
||||
}
|
||||
|
||||
ResultadoOperacionSala eliminarUsuario({
|
||||
required String usuarioId,
|
||||
required String solicitanteClientId,
|
||||
}) {
|
||||
final solicitante = clientes[solicitanteClientId];
|
||||
if (!(solicitante?.esHost ?? false)) {
|
||||
return const ResultadoOperacionSala.error('solo_host');
|
||||
}
|
||||
final usuario = usuarios[usuarioId];
|
||||
if (usuario == null) {
|
||||
return const ResultadoOperacionSala.error('usuario_no_existe');
|
||||
}
|
||||
if (usuario.estaSeleccionado) {
|
||||
return const ResultadoOperacionSala.error('usuario_seleccionado');
|
||||
}
|
||||
usuarios.remove(usuarioId);
|
||||
return const ResultadoOperacionSala.ok();
|
||||
}
|
||||
|
||||
void desconectarCliente(String clientId) {
|
||||
final cliente = clientes[clientId];
|
||||
if (cliente == null) return;
|
||||
clientes[clientId] = cliente.copiar(conectado: false);
|
||||
if (fase != FaseSalaMultijugador.lobby) return;
|
||||
for (final entry in usuarios.entries.toList()) {
|
||||
if (entry.value.clienteIdSeleccionado == clientId) {
|
||||
usuarios[entry.key] = entry.value.copiar(liberarSeleccion: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ResultadoOperacionSala validarInicio() {
|
||||
if (fase != FaseSalaMultijugador.lobby) {
|
||||
return const ResultadoOperacionSala.error('sala_cerrada');
|
||||
}
|
||||
if (cantidadUsuariosSeleccionados < 3) {
|
||||
return const ResultadoOperacionSala.error('faltan_jugadores');
|
||||
}
|
||||
if (usuariosPorCliente(hostClientId).isEmpty) {
|
||||
return const ResultadoOperacionSala.error('host_sin_usuario');
|
||||
}
|
||||
return const ResultadoOperacionSala.ok();
|
||||
}
|
||||
|
||||
ResultadoOperacionSala iniciarPartida() {
|
||||
final validacion = validarInicio();
|
||||
if (!validacion.exitoso) return validacion;
|
||||
fase = FaseSalaMultijugador.enPartida;
|
||||
return const ResultadoOperacionSala.ok();
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'roomId': roomId,
|
||||
'nombreSala': nombreSala,
|
||||
'hostClientId': hostClientId,
|
||||
'fase': fase.name,
|
||||
'clientes': clientes.values.map((cliente) => cliente.toJson()).toList(),
|
||||
'usuarios': usuarios.values.map((usuario) => usuario.toJson()).toList(),
|
||||
};
|
||||
|
||||
factory EstadoSalaMultijugador.fromJson(Map<String, dynamic> json) {
|
||||
final clientes = <String, ClienteSala>{};
|
||||
for (final clienteJson in json['clientes'] as List<dynamic>? ?? []) {
|
||||
final cliente = ClienteSala.fromJson(
|
||||
clienteJson as Map<String, dynamic>,
|
||||
);
|
||||
clientes[cliente.clientId] = cliente;
|
||||
}
|
||||
|
||||
final usuarios = <String, Usuario>{};
|
||||
for (final usuarioJson in json['usuarios'] as List<dynamic>? ?? []) {
|
||||
final usuario = Usuario.fromJson(usuarioJson as Map<String, dynamic>);
|
||||
usuarios[usuario.id] = usuario;
|
||||
}
|
||||
|
||||
return EstadoSalaMultijugador(
|
||||
roomId: json['roomId'] as String,
|
||||
nombreSala: json['nombreSala'] as String,
|
||||
hostClientId: json['hostClientId'] as String,
|
||||
fase: FaseSalaMultijugador.values.firstWhere(
|
||||
(fase) => fase.name == json['fase'],
|
||||
orElse: () => FaseSalaMultijugador.lobby,
|
||||
),
|
||||
clientes: clientes,
|
||||
usuarios: usuarios,
|
||||
);
|
||||
}
|
||||
}
|
||||
132
lib/modelos/snapshot_partida_online.dart
Normal file
@@ -0,0 +1,132 @@
|
||||
import 'jugador.dart';
|
||||
import 'partida.dart';
|
||||
|
||||
class SnapshotPartidaOnline {
|
||||
final String? roomId;
|
||||
final String fase;
|
||||
final int ronda;
|
||||
final String categoria;
|
||||
final String? palabraSecreta;
|
||||
final String? ganador;
|
||||
final List<Jugador> jugadores;
|
||||
final ResultadoVotacion? resultadoActual;
|
||||
final List<ResultadoVotacion> historialVotaciones;
|
||||
final List<String> impostores;
|
||||
final String? mensaje;
|
||||
|
||||
const SnapshotPartidaOnline({
|
||||
required this.roomId,
|
||||
required this.fase,
|
||||
required this.ronda,
|
||||
required this.categoria,
|
||||
required this.jugadores,
|
||||
this.palabraSecreta,
|
||||
this.ganador,
|
||||
this.resultadoActual,
|
||||
this.historialVotaciones = const [],
|
||||
this.impostores = const [],
|
||||
this.mensaje,
|
||||
});
|
||||
|
||||
factory SnapshotPartidaOnline.desdePartida(
|
||||
Partida partida, {
|
||||
String? roomId,
|
||||
String? fase,
|
||||
ResultadoVotacion? resultadoActual,
|
||||
String? mensaje,
|
||||
bool revelarImpostores = false,
|
||||
bool revelarPalabra = false,
|
||||
}) {
|
||||
return SnapshotPartidaOnline(
|
||||
roomId: roomId,
|
||||
fase: fase ?? partida.fase.name,
|
||||
ronda: partida.rondaActual,
|
||||
categoria: partida.categoriaReal,
|
||||
palabraSecreta: revelarPalabra ? partida.palabraSecreta : null,
|
||||
ganador: partida.ganador,
|
||||
jugadores: partida.jugadores,
|
||||
resultadoActual: resultadoActual ??
|
||||
(partida.historialVotaciones.isEmpty
|
||||
? null
|
||||
: partida.historialVotaciones.last),
|
||||
historialVotaciones: partida.historialVotaciones,
|
||||
impostores: revelarImpostores
|
||||
? partida.jugadores
|
||||
.where((jugador) => jugador.esImpostor)
|
||||
.map((jugador) => jugador.nombre)
|
||||
.toList()
|
||||
: const [],
|
||||
mensaje: mensaje,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'roomId': roomId,
|
||||
'fase': fase,
|
||||
'round': ronda,
|
||||
'categoria': categoria,
|
||||
if (palabraSecreta != null) 'palabraSecreta': palabraSecreta,
|
||||
if (ganador != null) 'ganador': ganador,
|
||||
'jugadoresTodos': jugadores.map(_jugadorToJson).toList(),
|
||||
if (resultadoActual != null)
|
||||
'resultadoActual': _resultadoToJson(resultadoActual!),
|
||||
'historialVotaciones':
|
||||
historialVotaciones.map(_resultadoToJson).toList(),
|
||||
if (impostores.isNotEmpty) 'impostores': impostores,
|
||||
if (mensaje != null) 'mensaje': mensaje,
|
||||
};
|
||||
|
||||
factory SnapshotPartidaOnline.fromJson(Map<String, dynamic> json) {
|
||||
final jugadoresData = json['jugadoresTodos'] as List<dynamic>? ?? const [];
|
||||
final historialData =
|
||||
json['historialVotaciones'] as List<dynamic>? ?? const [];
|
||||
final resultadoData = json['resultadoActual'] as Map<String, dynamic>?;
|
||||
|
||||
return SnapshotPartidaOnline(
|
||||
roomId: json['roomId'] as String?,
|
||||
fase: json['fase'] as String? ?? '',
|
||||
ronda: (json['round'] as num?)?.toInt() ?? 1,
|
||||
categoria: json['categoria'] as String? ?? '',
|
||||
palabraSecreta: json['palabraSecreta'] as String?,
|
||||
ganador: json['ganador'] as String?,
|
||||
jugadores: jugadoresData
|
||||
.map((data) => Jugador.fromJson(data as Map<String, dynamic>))
|
||||
.toList(),
|
||||
resultadoActual:
|
||||
resultadoData == null ? null : _resultadoFromJson(resultadoData),
|
||||
historialVotaciones: historialData
|
||||
.map((data) => _resultadoFromJson(data as Map<String, dynamic>))
|
||||
.toList(),
|
||||
impostores: (json['impostores'] as List<dynamic>? ?? const [])
|
||||
.map((nombre) => nombre.toString())
|
||||
.toList(),
|
||||
mensaje: json['mensaje'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _jugadorToJson(Jugador jugador) => {
|
||||
'id': jugador.id,
|
||||
'nombre': jugador.nombre,
|
||||
'esImpostor': jugador.esImpostor,
|
||||
'eliminado': jugador.eliminado,
|
||||
};
|
||||
|
||||
static Map<String, dynamic> _resultadoToJson(ResultadoVotacion resultado) => {
|
||||
'eliminadoId': resultado.eliminadoId,
|
||||
'eliminadoNombre': resultado.eliminadoNombre,
|
||||
'eraImpostor': resultado.eraImpostor,
|
||||
'votos': resultado.votos,
|
||||
};
|
||||
|
||||
static ResultadoVotacion _resultadoFromJson(Map<String, dynamic> json) {
|
||||
final votos = (json['votos'] as Map<dynamic, dynamic>? ?? const {}).map(
|
||||
(key, value) => MapEntry(key.toString(), value.toString()),
|
||||
);
|
||||
return ResultadoVotacion(
|
||||
eliminadoId: json['eliminadoId'] as String? ?? '',
|
||||
eliminadoNombre: json['eliminadoNombre'] as String? ?? '',
|
||||
eraImpostor: json['eraImpostor'] as bool? ?? false,
|
||||
votos: votos,
|
||||
);
|
||||
}
|
||||
}
|
||||
67
lib/modelos/usuario.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
/// Modelo de usuario para el pool de usuarios en modo multi-dispositivo
|
||||
class Usuario {
|
||||
final String id;
|
||||
final String nombre;
|
||||
final String? nick;
|
||||
final String? avatar;
|
||||
final String? foto;
|
||||
final String? creadoPorClienteId;
|
||||
final String? clienteIdSeleccionado;
|
||||
|
||||
Usuario({
|
||||
required this.id,
|
||||
required this.nombre,
|
||||
this.nick,
|
||||
this.avatar,
|
||||
this.foto,
|
||||
this.creadoPorClienteId,
|
||||
this.clienteIdSeleccionado,
|
||||
});
|
||||
|
||||
bool get estaSeleccionado => clienteIdSeleccionado != null;
|
||||
bool get estaDisponible => clienteIdSeleccionado == null;
|
||||
|
||||
Usuario copiar({
|
||||
String? id,
|
||||
String? nombre,
|
||||
String? nick,
|
||||
String? avatar,
|
||||
String? foto,
|
||||
String? creadoPorClienteId,
|
||||
String? clienteIdSeleccionado,
|
||||
bool liberarSeleccion = false,
|
||||
}) {
|
||||
return Usuario(
|
||||
id: id ?? this.id,
|
||||
nombre: nombre ?? this.nombre,
|
||||
nick: nick ?? this.nick,
|
||||
avatar: avatar ?? this.avatar,
|
||||
foto: foto ?? this.foto,
|
||||
creadoPorClienteId: creadoPorClienteId ?? this.creadoPorClienteId,
|
||||
clienteIdSeleccionado: liberarSeleccion
|
||||
? null
|
||||
: (clienteIdSeleccionado ?? this.clienteIdSeleccionado),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'nombre': nombre,
|
||||
if (nick != null) 'nick': nick,
|
||||
if (avatar != null) 'avatar': avatar,
|
||||
if (foto != null) 'foto': foto,
|
||||
if (creadoPorClienteId != null) 'creadoPorClienteId': creadoPorClienteId,
|
||||
if (clienteIdSeleccionado != null)
|
||||
'clienteIdSeleccionado': clienteIdSeleccionado,
|
||||
};
|
||||
|
||||
factory Usuario.fromJson(Map<String, dynamic> json) => Usuario(
|
||||
id: json['id'] as String,
|
||||
nombre: json['nombre'] as String,
|
||||
nick: json['nick'] as String?,
|
||||
avatar: json['avatar'] as String?,
|
||||
foto: json['foto'] as String?,
|
||||
creadoPorClienteId: json['creadoPorClienteId'] as String?,
|
||||
clienteIdSeleccionado: json['clienteIdSeleccionado'] as String?,
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:farolero/l10n/generated/app_localizations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../servicios/servicio_idioma.dart';
|
||||
import '../servicios/servicio_perfil_usuario.dart';
|
||||
import '../tema/componentes_farolero.dart';
|
||||
import '../tema/tema_app.dart';
|
||||
|
||||
class PantallaAjustes extends StatefulWidget {
|
||||
@@ -12,21 +14,35 @@ class PantallaAjustes extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _PantallaAjustesState extends State<PantallaAjustes> {
|
||||
double _volumen = 0.7;
|
||||
bool _vibracion = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final servicioIdioma = context.watch<ServicioIdioma>();
|
||||
final perfil = context.watch<ServicioPerfilUsuario>().perfil;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.settingsTitle)),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
body: FondoFarolero(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: AvatarFarolero(
|
||||
texto: perfil.nombre.substring(0, 1).toUpperCase(),
|
||||
assetPath: perfil.avatarAsset,
|
||||
size: 48,
|
||||
),
|
||||
title: Text(perfil.nombre),
|
||||
subtitle: Text('@${perfil.nick}'),
|
||||
trailing: const Icon(Icons.edit),
|
||||
onTap: () => _editarPerfil(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Selector de idioma
|
||||
Card(
|
||||
child: Padding(
|
||||
@@ -65,74 +81,8 @@ class _PantallaAjustesState extends State<PantallaAjustes> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Volumen de efectos de sonido
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.soundVolume,
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
Slider(
|
||||
value: _volumen,
|
||||
onChanged: (v) => setState(() => _volumen = v),
|
||||
activeColor: TemaApp.colorAcento,
|
||||
inactiveColor: TemaApp.colorTarjeta,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Vibración
|
||||
Card(
|
||||
child: SwitchListTile(
|
||||
title: Text(l10n.vibration),
|
||||
value: _vibracion,
|
||||
onChanged: (v) => setState(() => _vibracion = v),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Acerca de
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.about,
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 12),
|
||||
_filaInfo(context, l10n.version, '1.0.0'),
|
||||
const SizedBox(height: 8),
|
||||
_filaInfo(context, l10n.developer, 'FreeTTimeLab'),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
showLicensePage(
|
||||
context: context,
|
||||
applicationName: 'Farolero',
|
||||
applicationVersion: '1.0.0',
|
||||
);
|
||||
},
|
||||
child: Text(l10n.licenses),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -160,14 +110,105 @@ class _PantallaAjustesState extends State<PantallaAjustes> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _filaInfo(BuildContext context, String etiqueta, String valor) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(etiqueta, style: Theme.of(context).textTheme.bodyMedium),
|
||||
Text(valor, style: Theme.of(context).textTheme.bodyLarge),
|
||||
],
|
||||
Future<void> _editarPerfil(BuildContext context) async {
|
||||
final servicioPerfil = context.read<ServicioPerfilUsuario>();
|
||||
final actual = servicioPerfil.perfil;
|
||||
final nombreController = TextEditingController(text: actual.nombre);
|
||||
final nickController = TextEditingController(text: actual.nick);
|
||||
var avatarSeleccionado = actual.avatarAsset;
|
||||
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) => AlertDialog(
|
||||
title: const Text('Perfil del dispositivo'),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nombreController,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nombre',
|
||||
prefixIcon: Icon(Icons.person),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: nickController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nick',
|
||||
prefixIcon: Icon(Icons.alternate_email),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 5,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
),
|
||||
itemCount: ServicioPerfilUsuario.avatares.length,
|
||||
itemBuilder: (context, index) {
|
||||
final avatar = ServicioPerfilUsuario.avatares[index];
|
||||
final seleccionado = avatar == avatarSeleccionado;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
onTap: () => setDialogState(
|
||||
() => avatarSeleccionado = avatar,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: seleccionado
|
||||
? TemaApp.colorNaranja
|
||||
: Colors.transparent,
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
child: AvatarFarolero(
|
||||
texto: '',
|
||||
assetPath: avatar,
|
||||
size: 48,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Cancelar'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await servicioPerfil.guardar(
|
||||
nombre: nombreController.text,
|
||||
nick: nickController.text,
|
||||
avatarAsset: avatarSeleccionado,
|
||||
);
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('Guardar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
nombreController.dispose();
|
||||
nickController.dispose();
|
||||
}
|
||||
|
||||
String _nombreIdiomaDelSistema() {
|
||||
|
||||
@@ -2,10 +2,13 @@ import 'package:flutter/material.dart';
|
||||
import 'package:farolero/l10n/generated/app_localizations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../estado/estado_juego.dart';
|
||||
import '../modelos/inicio_partida_multijugador.dart';
|
||||
import '../modelos/palabra.dart';
|
||||
import '../modelos/partida.dart';
|
||||
import '../servicios/servicio_nearby.dart';
|
||||
import '../servicios/servicio_permisos.dart';
|
||||
import '../servicios/servicio_perfil_usuario.dart';
|
||||
import '../tema/componentes_farolero.dart';
|
||||
import '../tema/tema_app.dart';
|
||||
import 'pantalla_gestor_host.dart';
|
||||
import 'pantalla_lobby_host.dart';
|
||||
@@ -121,15 +124,21 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Pedir nombre del host
|
||||
final nombre = await _pedirNombreHost();
|
||||
// 2. Usar el perfil principal del dispositivo como usuario del host.
|
||||
final nombre = await _seleccionarUsuarioHost();
|
||||
if (nombre == null || nombre.trim().isEmpty) return;
|
||||
|
||||
// 3. Iniciar host en Nearby
|
||||
if (!mounted) return;
|
||||
final nearby = context.read<ServicioNearby>();
|
||||
final perfil = context.read<ServicioPerfilUsuario>().perfil;
|
||||
final nombreSala = '${nombre.trim()} - Farolero';
|
||||
final ok = await nearby.iniciarHost(nombreSala, nombre.trim());
|
||||
final ok = await nearby.iniciarHost(
|
||||
nombreSala,
|
||||
nombre.trim(),
|
||||
miNick: perfil.nick,
|
||||
miAvatar: perfil.avatarAsset,
|
||||
);
|
||||
|
||||
if (!ok) {
|
||||
if (mounted) {
|
||||
@@ -152,11 +161,21 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
onIniciar: () {
|
||||
// Cuando el host toca "Iniciar" con suficientes jugadores
|
||||
final estado = context.read<EstadoJuego>();
|
||||
final jugadoresMulti = [
|
||||
nombre.trim(),
|
||||
...nearby.jugadores.map((j) => j.nombre),
|
||||
];
|
||||
estado.crearPartida(
|
||||
final sala = nearby.estadoSala;
|
||||
if (sala == null) return;
|
||||
final validacion = sala.iniciarPartida();
|
||||
if (!validacion.exitoso) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'No se puede iniciar: ${validacion.codigo ?? "sala inválida"}',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
estado.crearPartidaDesdeSala(
|
||||
config: ConfigPartida(
|
||||
modoMultimovil: true,
|
||||
categoria: _categoria,
|
||||
@@ -164,24 +183,41 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
pistaImpostor: _pistaImpostor,
|
||||
tiempoDebateSegundos: _tiempoDebate,
|
||||
),
|
||||
nombresJugadores: jugadoresMulti,
|
||||
sala: sala,
|
||||
);
|
||||
|
||||
// Enviar palabras a cada jugador via Nearby
|
||||
final partida = estado.partida!;
|
||||
final impostores = <String, bool>{};
|
||||
for (int i = 0; i < nearby.jugadores.length; i++) {
|
||||
final jugadorNearby = nearby.jugadores[i];
|
||||
// El jugador [0] es el host, los de nearby son [1..n]
|
||||
final jugadorPartida = partida.jugadores[i + 1];
|
||||
impostores[jugadorNearby.endpointId] =
|
||||
jugadorPartida.esImpostor;
|
||||
}
|
||||
final asignaciones = partida.jugadores.map((jugador) {
|
||||
final usuarioSala = sala.usuarios[jugador.id];
|
||||
final clientId = usuarioSala?.clienteIdSeleccionado;
|
||||
final cliente = clientId == null ? null : sala.clientes[clientId];
|
||||
return AsignacionJugador(
|
||||
jugadorId: jugador.id,
|
||||
nombre: jugador.nombre,
|
||||
clientId: clientId ?? sala.hostClientId,
|
||||
endpointId: cliente?.endpointId,
|
||||
);
|
||||
}).toList();
|
||||
final impostores = {
|
||||
for (final jugador in partida.jugadores)
|
||||
jugador.id: jugador.esImpostor,
|
||||
};
|
||||
final jugadoresTodos = partida.jugadores
|
||||
.map(
|
||||
(jugador) => {
|
||||
'id': jugador.id,
|
||||
'nombre': jugador.nombre,
|
||||
'eliminado': jugador.eliminado,
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
|
||||
nearby.enviarInicioPartida(
|
||||
nearby.enviarInicioPartidaMulti(
|
||||
asignaciones: asignaciones,
|
||||
palabraSecreta: partida.palabraSecreta,
|
||||
categoria: _categoria,
|
||||
impostores: impostores,
|
||||
impostoresPorJugadorId: impostores,
|
||||
jugadoresTodos: jugadoresTodos,
|
||||
);
|
||||
|
||||
Navigator.pushReplacement(
|
||||
@@ -207,35 +243,9 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _pedirNombreHost() async {
|
||||
final controller = TextEditingController();
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(l10n.yourName),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.yourName,
|
||||
prefixIcon: const Icon(Icons.person),
|
||||
),
|
||||
onSubmitted: (v) => Navigator.pop(ctx, v),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, controller.text),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
/// Devuelve el perfil principal del dispositivo para crear la sala.
|
||||
Future<String?> _seleccionarUsuarioHost() async {
|
||||
return context.read<ServicioPerfilUsuario>().perfil.nombre;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -253,11 +263,38 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.createGame)),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
body: FondoFarolero(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PanelFarolero(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.groups, color: TemaApp.colorNaranja, size: 42),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'¿Cómo quieres jugar?',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
l10n.playersRange,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Modo de juego
|
||||
Card(
|
||||
child: Padding(
|
||||
@@ -492,6 +529,7 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:farolero/l10n/generated/app_localizations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../estado/estado_juego.dart';
|
||||
import '../tema/componentes_farolero.dart';
|
||||
import '../tema/tema_app.dart';
|
||||
import 'pantalla_notas.dart';
|
||||
import 'pantalla_votacion.dart';
|
||||
@@ -46,7 +47,7 @@ class _PantallaDebateState extends State<PantallaDebate> {
|
||||
String _formatearTiempo(int segundos) {
|
||||
final min = segundos ~/ 60;
|
||||
final seg = segundos % 60;
|
||||
return '${min.toString().padLeft(2, '0')}:${seg.toString().padLeft(2, '0')}';
|
||||
return "${min.toString().padLeft(2, '0')}:${seg.toString().padLeft(2, '0')}";
|
||||
}
|
||||
|
||||
void _irAVotacion() {
|
||||
@@ -75,9 +76,10 @@ class _PantallaDebateState extends State<PantallaDebate> {
|
||||
title: Text(l10n.debateRound(partida.rondaActual)),
|
||||
automaticallyImplyLeading: false,
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
body: FondoFarolero(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// Temporizador
|
||||
if (tieneTemporizador) ...[
|
||||
@@ -223,6 +225,7 @@ class _PantallaDebateState extends State<PantallaDebate> {
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:farolero/l10n/generated/app_localizations.dart';
|
||||
import 'package:farolero/modelos/inicio_partida_multijugador.dart';
|
||||
import 'package:farolero/modelos/jugador.dart';
|
||||
import 'package:farolero/pantallas/pantalla_notas_online.dart';
|
||||
import 'package:farolero/pantallas/pantalla_revision_palabra.dart';
|
||||
import 'package:farolero/pantallas/pantalla_votacion_cliente.dart';
|
||||
import 'package:farolero/servicios/servicio_nearby.dart';
|
||||
import 'package:farolero/tema/tema_app.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Pantalla que ve el jugador durante la fase de debate (multidispositivo).
|
||||
/// El cliente recibe el cambio de fase via Nearby y se navega aquí.
|
||||
class PantallaDebateCliente extends StatefulWidget {
|
||||
final int? tiempoDebateSegundos;
|
||||
final String? primerTurnoNombre;
|
||||
final String? partidaId;
|
||||
final String? pistaCategoria;
|
||||
final List<Jugador> jugadores;
|
||||
final List<JugadorInicioPartida> jugadoresControlados;
|
||||
final VoidCallback onSolicitarVotacion;
|
||||
|
||||
const PantallaDebateCliente({
|
||||
super.key,
|
||||
this.tiempoDebateSegundos,
|
||||
this.primerTurnoNombre,
|
||||
this.partidaId,
|
||||
this.pistaCategoria,
|
||||
this.jugadores = const [],
|
||||
this.jugadoresControlados = const [],
|
||||
required this.onSolicitarVotacion,
|
||||
});
|
||||
|
||||
@@ -23,10 +40,36 @@ class _PantallaDebateClienteState extends State<PantallaDebateCliente> {
|
||||
Timer? _timer;
|
||||
int _segundosRestantes = 0;
|
||||
bool _votacionSolicitada = false;
|
||||
OnMensajeCallback? _listener;
|
||||
ServicioNearby? _nearby;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_listener = (endpointId, mensaje) {
|
||||
if (!mounted || mensaje.tipo != TipoMensaje.fase) return;
|
||||
final fase = mensaje.datos['fase'] as String?;
|
||||
if (fase == 'votacion') {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PantallaVotacionCliente(
|
||||
jugadores: widget.jugadores,
|
||||
jugadoresControlados: widget.jugadoresControlados,
|
||||
partidaId: widget.partidaId,
|
||||
pistaCategoria: widget.pistaCategoria,
|
||||
onVotos: _enviarVotos,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final listener = _listener;
|
||||
if (listener != null && mounted) {
|
||||
_nearby = context.read<ServicioNearby>();
|
||||
_nearby!.onMensaje(listener);
|
||||
}
|
||||
});
|
||||
if (widget.tiempoDebateSegundos != null) {
|
||||
_segundosRestantes = widget.tiempoDebateSegundos!;
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
@@ -42,13 +85,35 @@ class _PantallaDebateClienteState extends State<PantallaDebateCliente> {
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
final listener = _listener;
|
||||
if (listener != null) {
|
||||
_nearby?.removeMensajeListener(listener);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _enviarVotos(Map<String, String> votos) {
|
||||
final nearby = context.read<ServicioNearby>();
|
||||
if (nearby.hostEndpointId == null) return;
|
||||
for (final entry in votos.entries) {
|
||||
nearby.enviarMensaje(
|
||||
nearby.hostEndpointId!,
|
||||
MensajeP2P(
|
||||
tipo: TipoMensaje.voto,
|
||||
datos: {
|
||||
'votanteId': entry.key,
|
||||
'votadoId': entry.value,
|
||||
'votoporId': entry.value,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatearTiempo(int segundos) {
|
||||
final min = segundos ~/ 60;
|
||||
final seg = segundos % 60;
|
||||
return '${min.toString().padLeft(2, '0')}:${seg.toString().padLeft(2, '0')}';
|
||||
return "${min.toString().padLeft(2, '0')}:${seg.toString().padLeft(2, '0')}";
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -62,6 +127,35 @@ class _PantallaDebateClienteState extends State<PantallaDebateCliente> {
|
||||
automaticallyImplyLeading: false,
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: l10n.seeYourWord,
|
||||
icon: const Icon(Icons.visibility),
|
||||
onPressed: widget.jugadoresControlados.isEmpty
|
||||
? null
|
||||
: () => mostrarRevisionPalabraOnline(
|
||||
context: context,
|
||||
jugadoresControlados: widget.jugadoresControlados,
|
||||
pistaCategoria: widget.pistaCategoria,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.notesTitle,
|
||||
icon: const Icon(Icons.edit_note),
|
||||
onPressed: _puedeAbrirNotas
|
||||
? () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PantallaNotasOnline(
|
||||
partidaId: widget.partidaId!,
|
||||
jugadores: widget.jugadores,
|
||||
autoresControlados: widget.jugadoresControlados,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
@@ -111,6 +205,36 @@ class _PantallaDebateClienteState extends State<PantallaDebateCliente> {
|
||||
],
|
||||
|
||||
// Instrucciones
|
||||
if (widget.primerTurnoNombre != null) ...[
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: TemaApp.colorNaranja.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: TemaApp.colorNaranja.withValues(alpha: 0.65),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.record_voice_over,
|
||||
color: TemaApp.colorNaranja,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Empieza ${widget.primerTurnoNombre} diciendo su palabra.',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
Text(
|
||||
l10n.debateInstructions,
|
||||
textAlign: TextAlign.center,
|
||||
@@ -153,4 +277,10 @@ class _PantallaDebateClienteState extends State<PantallaDebateCliente> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool get _puedeAbrirNotas {
|
||||
return widget.partidaId != null &&
|
||||
widget.jugadores.isNotEmpty &&
|
||||
widget.jugadoresControlados.isNotEmpty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,22 @@ import 'package:farolero/l10n/generated/app_localizations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../estado/estado_juego.dart';
|
||||
import '../modelos/palabra.dart';
|
||||
import '../servicios/servicio_historial_partidas.dart';
|
||||
import '../servicios/servicio_nearby.dart';
|
||||
import '../tema/tema_app.dart';
|
||||
import 'pantalla_principal.dart';
|
||||
import 'pantalla_ver_palabra.dart';
|
||||
|
||||
class PantallaFinPartida extends StatelessWidget {
|
||||
class PantallaFinPartida extends StatefulWidget {
|
||||
const PantallaFinPartida({super.key});
|
||||
|
||||
@override
|
||||
State<PantallaFinPartida> createState() => _PantallaFinPartidaState();
|
||||
}
|
||||
|
||||
class _PantallaFinPartidaState extends State<PantallaFinPartida> {
|
||||
bool _guardada = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
@@ -20,6 +29,14 @@ class PantallaFinPartida extends StatelessWidget {
|
||||
final ganaronJugadores = partida.ganador == 'jugadores';
|
||||
final impostores =
|
||||
partida.jugadores.where((j) => j.esImpostor).toList();
|
||||
if (!_guardada && partida.ganador != null) {
|
||||
_guardada = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
context.read<ServicioHistorialPartidas>().guardarPartida(partida);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -220,8 +237,10 @@ class PantallaFinPartida extends StatelessWidget {
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
onPressed: () async {
|
||||
await context.read<ServicioNearby>().desconectar();
|
||||
estado.limpiar();
|
||||
if (!context.mounted) return;
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
|
||||
234
lib/pantallas/pantalla_fin_partida_online.dart
Normal file
@@ -0,0 +1,234 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:farolero/l10n/generated/app_localizations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../modelos/inicio_partida_multijugador.dart';
|
||||
import '../modelos/palabra.dart';
|
||||
import '../modelos/snapshot_partida_online.dart';
|
||||
import '../servicios/servicio_nearby.dart';
|
||||
import '../tema/tema_app.dart';
|
||||
import 'pantalla_notas_online.dart';
|
||||
import 'pantalla_principal.dart';
|
||||
import 'pantalla_revision_palabra.dart';
|
||||
|
||||
class PantallaFinPartidaOnline extends StatelessWidget {
|
||||
final SnapshotPartidaOnline snapshot;
|
||||
final List<JugadorInicioPartida> jugadoresControlados;
|
||||
final String? pistaCategoria;
|
||||
|
||||
const PantallaFinPartidaOnline({
|
||||
super.key,
|
||||
required this.snapshot,
|
||||
required this.jugadoresControlados,
|
||||
this.pistaCategoria,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final ganaronJugadores = snapshot.ganador == 'jugadores';
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.gameOver),
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: l10n.seeYourWord,
|
||||
icon: const Icon(Icons.visibility),
|
||||
onPressed: jugadoresControlados.isEmpty
|
||||
? null
|
||||
: () => mostrarRevisionPalabraOnline(
|
||||
context: context,
|
||||
jugadoresControlados: jugadoresControlados,
|
||||
pistaCategoria: pistaCategoria,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.notesTitle,
|
||||
icon: const Icon(Icons.edit_note),
|
||||
onPressed: snapshot.roomId == null || jugadoresControlados.isEmpty
|
||||
? null
|
||||
: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PantallaNotasOnline(
|
||||
partidaId: snapshot.roomId!,
|
||||
jugadores: snapshot.jugadores,
|
||||
autoresControlados: jugadoresControlados,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: ganaronJugadores
|
||||
? [
|
||||
TemaApp.colorVerde.withValues(alpha: 0.3),
|
||||
TemaApp.colorVerde.withValues(alpha: 0.1),
|
||||
]
|
||||
: [
|
||||
TemaApp.colorAcento.withValues(alpha: 0.3),
|
||||
TemaApp.colorAcento.withValues(alpha: 0.1),
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: ganaronJugadores
|
||||
? TemaApp.colorVerde
|
||||
: TemaApp.colorAcento,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
ganaronJugadores ? '🎉' : '🎭',
|
||||
style: const TextStyle(fontSize: 64),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
ganaronJugadores ? l10n.playersWin : l10n.impostorsWin,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
color: ganaronJugadores
|
||||
? TemaApp.colorVerde
|
||||
: TemaApp.colorAcento,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
l10n.theSecretWordWas,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
snapshot.palabraSecreta ?? '?',
|
||||
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
||||
color: TemaApp.colorNaranja,
|
||||
fontSize: 32,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.categoryLabel(
|
||||
BancoPalabras.nombreBonitoCategoria(
|
||||
snapshot.categoria,
|
||||
l10n,
|
||||
),
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
snapshot.impostores.length == 1
|
||||
? l10n.theImpostorWas
|
||||
: l10n.theImpostorsWere,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...snapshot.impostores.map(
|
||||
(nombre) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Text(
|
||||
'🎭 $nombre',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: TemaApp.colorAcento,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (snapshot.historialVotaciones.isNotEmpty)
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.votingHistory,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...snapshot.historialVotaciones.asMap().entries.map(
|
||||
(entrada) {
|
||||
final ronda = entrada.key + 1;
|
||||
final resultado = entrada.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
l10n.roundElimination(
|
||||
ronda,
|
||||
resultado.eliminadoNombre,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: resultado.eraImpostor
|
||||
? TemaApp.colorVerde
|
||||
: TemaApp.colorAcento,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
await context.read<ServicioNearby>().desconectar();
|
||||
if (!context.mounted) return;
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const PantallaPrincipal(),
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.home),
|
||||
label: Text(l10n.mainMenu),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||