Compare commits

...

45 Commits

Author SHA1 Message Date
ShanaiaBot
2f2c77285a chore: bump version to 1.1.17+22 [ci skip] 2026-05-05 23:10:04 +02:00
f64f36b78f Merge branch 'main' of https://git.freetimelab.es/FreeTLab/farolero
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 12s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m18s
2026-05-05 23:09:38 +02:00
0772ec526e Eliminar valores innecesarios de la configuración 2026-05-05 23:00:29 +02:00
08235999d3 Mostrar la versión en la app 2026-05-05 22:56:25 +02:00
ShanaiaBot
4510ca10c4 chore: bump version to 1.1.16+21 [ci skip] 2026-05-05 22:48:30 +02:00
031c190d74 Subidas para permitir compilación
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 11s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m20s
2026-05-05 22:48:05 +02:00
1b0ec8dc57 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/farolero
Some checks failed
Build & Deploy Farolero / Análisis de código (push) Failing after 12s
Build & Deploy Farolero / Build APK + AAB release (push) Has been skipped
2026-05-05 22:45:53 +02:00
cfe5d479ff Posible mejora en el multidispositivo 2026-05-05 22:45:51 +02:00
ShanaiaBot
c75e4165f6 chore: bump version to 1.1.15+20 [ci skip] 2026-05-05 21:50:24 +02:00
016333f6c0 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/farolero
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 17s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m26s
2026-05-05 21:49:44 +02:00
6e5e423ab4 Implementado:
No se puede marcar “vista” sin revelar la palabra antes.
Se puede volver a ver la palabra durante debate/votación/resultado.
Notas online privadas por partida y jugador.
Tests añadidos para notas scoped.
Ajusté roomId en el payload de inicio para que las notas no se mezclen entre partidas.
2026-05-05 21:49:40 +02:00
ShanaiaBot
be880d416b chore: bump version to 1.1.14+19 [ci skip] 2026-05-05 20:54:39 +02:00
1abdeb2f56 Ahora sí, corregido en teoría
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 10s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m58s
2026-05-05 20:54:13 +02:00
ff01d6c9e6 corrección de errores de compilación, eso espero
Some checks failed
Build & Deploy Farolero / Análisis de código (push) Failing after 11s
Build & Deploy Farolero / Build APK + AAB release (push) Has been skipped
2026-05-05 20:48:23 +02:00
d61e79ec99 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/farolero
Some checks failed
Build & Deploy Farolero / Análisis de código (push) Failing after 14s
Build & Deploy Farolero / Build APK + AAB release (push) Has been skipped
2026-05-05 20:38:25 +02:00
5c9e8b2b9c Reintentos de ejecución de partidas online! 2026-05-05 20:38:13 +02:00
ShanaiaBot
9a2b2edefd chore: bump version to 1.1.13+18 [ci skip] 2026-05-04 22:23:46 +02:00
2dbe505d77 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/farolero
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 12s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m16s
2026-05-04 22:23:20 +02:00
3b0b10ea50 traducciones 2026-05-04 22:23:11 +02:00
ShanaiaBot
6a130acc84 chore: bump version to 1.1.12+17 [ci skip] 2026-05-04 20:58:32 +02:00
00dc3ee5e1 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/farolero
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 11s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m21s
2026-05-04 20:58:05 +02:00
957b42ea0c Gestión de usuarios y avatares en la aplicación. Gestión de traducciones de las palabras. 2026-05-04 20:58:02 +02:00
ShanaiaBot
47b1209668 chore: bump version to 1.1.11+16 [ci skip] 2026-05-04 20:24:24 +02:00
7dd6c7bd74 Mejora flujo de datos en partidas multidispositivos
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 12s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m53s
2026-05-04 20:23:47 +02:00
ShanaiaBot
01b65a3d29 chore: bump version to 1.1.10+15 [ci skip] 2026-05-04 13:58:30 +02:00
841f94e543 Completo y absoluto cambio de diseño
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 23s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m53s
2026-05-04 13:57:55 +02:00
ShanaiaBot
ab0d4dc2ba chore: bump version to 1.1.9+14 [ci skip] 2026-04-27 16:04:31 +02:00
Javier Bautista Fernández
50b050e678 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/farolero
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 10s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m57s
2026-04-27 16:04:10 +02:00
Javier Bautista Fernández
5d3b3ef271 feat: Add eliminarUsuario message type and handle user removal in ServicioNearby 2026-04-27 16:04:03 +02:00
c8e5cf25c5 Actualizar .gitea/workflows/build.yml
Some checks failed
Build & Deploy Farolero / Build APK + AAB release (push) Has been skipped
Build & Deploy Farolero / Análisis de código (push) Failing after 13s
2026-04-27 14:43:52 +02:00
d850b66089 Actualizar .gitea/workflows/build.yml 2026-04-27 14:43:36 +02:00
Javier Bautista Fernández
166b89a661 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/farolero
Some checks failed
Build & Deploy Farolero / Análisis de código (push) Failing after 4s
Build & Deploy Farolero / Build APK + AAB release (push) Has been skipped
2026-04-27 14:41:01 +02:00
Javier Bautista Fernández
1cb2260298 chore: Remove PATH from environment variables and add Flutter version check steps 2026-04-27 14:40:43 +02:00
da9bd0cd4a Actualizar .gitea/workflows/build.yml
Some checks failed
Build & Deploy Farolero / Análisis de código (push) Failing after 4s
Build & Deploy Farolero / Build APK + AAB release (push) Has been skipped
2026-04-27 14:37:50 +02:00
d600835105 Actualizar .gitea/workflows/build.yml
Some checks failed
Build & Deploy Farolero / Análisis de código (push) Failing after 6s
Build & Deploy Farolero / Build APK + AAB release (push) Has been cancelled
2026-04-27 14:36:44 +02:00
Javier Bautista Fernández
a8d5b0f002 feat: Implement multiplayer game session management
Some checks failed
Build & Deploy Farolero / Análisis de código (push) Has been cancelled
Build & Deploy Farolero / Build APK + AAB release (push) Has been cancelled
- Add models for managing player assignments and game session initialization in `inicio_partida_multijugador.dart`.
- Create a multiplayer room state management system in `sala_multijugador.dart`, including user registration, selection, and session validation.
- Develop a UI screen for displaying player words sequentially in `pantalla_palabras_cliente.dart`.
- Implement unit tests for the multiplayer session management and player assignment logic in `inicio_partida_multijugador_test.dart` and `sala_multijugador_test.dart`.
2026-04-27 14:02:33 +02:00
ShanaiaBot
4a1abd0be0 chore: bump version to 1.1.8+13 [ci skip] 2026-04-24 21:38:01 +02:00
f3dcb99de1 Merge pull request 'fix: boton ver palabra del host ahora funciona' (#3) from feat/host-como-jugador into main
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 10s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m18s
Reviewed-on: #3
2026-04-24 21:37:42 +02:00
FreeTLab
f41fbc7dd9 fix: boton ver palabra del host ahora funciona 2026-04-24 21:34:40 +02:00
ShanaiaBot
e3c502c7df chore: bump version to 1.1.7+12 [ci skip] 2026-04-24 20:04:17 +02:00
3f4ec2d20f Merge pull request 'feat: host como jugador' (#2) from feat/host-como-jugador into main
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 10s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m51s
Reviewed-on: #2
2026-04-24 20:03:59 +02:00
FreeTLab
1231b32c3c feat: host como jugador 2026-04-24 20:01:54 +02:00
a59a9a481e Merge pull request 'feat/host-como-jugador' (#1) from feat/host-como-jugador into main
Some checks failed
Build & Deploy Farolero / Análisis de código (push) Failing after 19s
Build & Deploy Farolero / Build APK + AAB release (push) Has been skipped
Reviewed-on: #1
2026-04-24 19:50:02 +02:00
FreeTLab
911bd4c4a3 feat: host como jugador 2026-04-24 19:28:47 +02:00
ShanaiaBot
d3fc3386f9 feat(multi-device): host puede participar como jugador
- Añadido modelo Usuario con pool de usuarios sincronizado
- El host ahora recibe palabra y rol como cualquier jugador
- UI de selección de perfil en pantallas de lobby
- Los clientes pueden ver usuarios del servidor o crear nuevos
- El juego no inicia hasta que el host selecciona perfil
2026-04-24 18:47:56 +02:00
132 changed files with 26366 additions and 834 deletions

76
.atl/skill-registry.md Normal file
View 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
View 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"

View File

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

@@ -48,3 +48,5 @@ build/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.atl/

16
AGENTS.md Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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`).

View File

@@ -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,
),
],
),
),
),
);

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

View File

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

View 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,
);
}
}

View 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
View 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?,
);
}

View File

@@ -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() {

View File

@@ -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),
],
),
),
),
);

View File

@@ -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> {
],
),
],
),
),
),
);

View File

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

View File

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

View 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),
),
),
],
),
),
);
}
}

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More